oc_chat
Loading...
Searching...
No Matches
step_routing.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/step_routing.py
4
5
6"""
7Per-ReAct-iteration model, reasoning effort, and max_tokens routing (Phase 3).
8
9Purpose
10-------
11Maps tool batches to profiles described in ``07 Token Efficiency…`` —
12flash for cheap deterministic reads, pro+thinking for exploration and
13writes, and the session model with strongest effort for planning and
14final synthesis. Lets every iteration of the ReAct loop pick the
15right model/effort/max_tokens triple without baking provider-
16specific knowledge into ``main.py``.
17
18Responsibilities
19----------------
20- :func:`classify_tool_batch` — bucket the most recent assistant
21 message's tool calls into ``simple``, ``simple_thinking``,
22 ``thinking``, ``thinking_write``, or ``complex``.
23- :func:`resolve_iteration_routing` — pick model, effort, extra_body,
24 and max_tokens for the next iteration, using the active provider's
25 ``cheap`` and ``reasoning`` tiers when available.
26- :func:`resolve_output_tokens` — derive a bucket's completion cap
27 from the model's declared ``max_output_tokens``, the operator
28 ceiling, and the ``/budget`` profile.
29- :func:`scale_max_tokens_for_profile` — apply the user's ``/budget``
30 profile (cheap / balanced / deep) to a base completion cap.
31- :func:`last_assistant_tool_names` — extract tool names from the
32 last assistant message that issued tool_calls.
33
34Diagnostics
35-----------
36Domain: OC_CHAT.MAIN
37Levels:
38 L2 — lifecycle events (entry/exit boundaries, currently unused).
39 L3 — per-bucket routing decisions in resolve_iteration_routing.
40 L4 — classify_tool_batch result dumps.
41
42Contracts
43---------
44- :class:`IterationRouting` is frozen — never mutate after return.
45- Tier-preferred models are sanity-checked against the model
46 manager's known list; unknown ids fall back to the session model.
47- ``scale_max_tokens_for_profile`` clamps to documented bounds
48 (``cheap`` ≥ 1024; every profile ≤ the ceiling passed in, which
49 ``resolve_output_tokens`` sets to the tighter of the model's declared
50 ``max_output_tokens`` and the operator ceiling).
51- A completion cap is never 0: an undeclared ``max_output_tokens``
52 resolves to :data:`~oc_chat.core.config.UNDECLARED_OUTPUT_TOKENS`,
53 and every bucket has the :data:`_MIN_BUCKET_OUTPUT_TOKENS` floor.
54- The cap bounds reasoning AND answer together on a thinking model.
55 Sizing it for the answer alone is what let the model spend its whole
56 budget on chain-of-thought and return no content at all.
57
58Dependencies
59------------
60- oc_diagnostics (structured debug logging via _dbg)
61- oc_chat.core.config (operator ceiling + undeclared-capability fallback)
62- oc_chat.core.defaults (default - the per-bucket output shares and the minimum bucket output floor)
63
64See Also
65--------
66oc_chat/docs/conventions/CONTRACTS.md §1.6 IterationRouting — owner of the per-
67iteration API-parameters dataclass.
68"""
69from __future__ import annotations
70
71from dataclasses import dataclass
72from typing import Any
73
74from oc_diagnostics import _dbg
75
76from oc_chat.core.config import (
77 UNDECLARED_OUTPUT_TOKENS,
78 get_output_tokens_ceiling,
79)
80from oc_chat.core.defaults import default
81
82#: Each bucket's share of the resolved output budget. Every iteration can be
83#: the one that produces the final answer — including the cheap post-read hop —
84#: so no bucket gets a share that could truncate an ordinary reply. The
85#: gradient only reflects that a deterministic follow-up needs less room than
86#: a planning or writing hop, not that it should be starved.
87_BUCKET_OUTPUT_SHARE: dict[str, float] = {
88 "planning": default("bucket_share_planning"),
89 "thinking": default("bucket_share_thinking"),
90 "thinking_write": default("bucket_share_thinking_write"),
91 "simple_thinking": default("bucket_share_simple_thinking"),
92 "simple": default("bucket_share_simple"),
93}
94
95#: Floor under every bucket, so a provider declaring a tiny output limit still
96#: leaves room for a usable reply rather than a one-line stub.
97_MIN_BUCKET_OUTPUT_TOKENS: int = default("min_bucket_output_tokens")
98
99# Tier names asked of the active provider — provider.tier("cheap") /
100# provider.tier("reasoning") return concrete model ids (DeepSeek maps
101# these to deepseek-v4-flash / deepseek-v4-pro respectively). When a
102# provider has no model for a given tier, _pick_model falls back to the
103# session model.
104_CHEAP_TIER = "cheap"
105_REASONING_TIER = "reasoning"
106
107# Deterministic / heavy I/O — flash, no thinking, low effort, tight completion cap.
108SIMPLE_TOOLS = frozenset({"read_file", "run_command"})
109# Non-deterministic listing / search — pro, thinking, low effort.
110SIMPLE_THINKING_TOOLS = frozenset({"list_dir", "search_files"})
111# Mutations, web, memory, planning — pro, thinking, elevated effort.
112THINKING_TOOLS = frozenset({
113 "write_file",
114 "fetch_url",
115 "web_search",
116 "remember",
117 "recall",
118 "create_plan",
119 "get_status",
120 "mark_step",
121 "stop_plan",
122 "oct_new",
123 # Mutating oct_git_* wrappers — read-only ones (status, check, commit_msg)
124 # stay out because they're cheap and don't need the reasoning model.
125 "oct_git_init",
126 "oct_git_add",
127 "oct_git_commit",
128 "oct_git_branch",
129 "oct_git_restore",
130 "oct_git_ignore",
131 "oct_git_changelog",
132 "oct_git_hooks",
133})
134
135
136@dataclass(frozen=True)
138 """Resolved API parameters for one chat-completion call."""
139
140 model: str
141 effort: str | None
142 extra_body: dict[str, Any] | None
143 max_tokens: int
144 # would_switch_model / cheap_model are VESTIGIAL pending the opt-in
145 # cheap-model offload redesign: plan 132 stopped them triggering a
146 # sub-loop, and plan 134 locks sub-loops to the session model. Retained so
147 # the redesign has the signal; do not re-wire them to pick a model without
148 # that redesign + a re-trigger guard.
149 would_switch_model: bool = False
150 cheap_model: str | None = None
151
152
153def _message_text(message: dict) -> str:
154 """Return message content as text, treating None/non-text as empty."""
155 content = message.get("content")
156 return content if isinstance(content, str) else ""
157
158
159def _known(model_manager, name: str | None) -> bool:
160 return bool(name) and name in model_manager.known_models()
161
162
163def _pick_model(model_manager, preferred: str | None, session_model: str) -> tuple[str, bool, str | None]:
164 """Return ``(model, would_switch, cheap_model)``.
165
166 Always returns *session_model* for the model (preserving prefix cache).
167 If *preferred* differs from *session_model*, ``would_switch=True`` and
168 *cheap_model* is set.
169
170 NOTE (plan 132 + 134): ``would_switch`` / ``cheap_model`` are NO LONGER a
171 sub-loop trigger, and sub-loops no longer run on ``cheap_model`` — the
172 interim model-lock runs them on the **session model** (no silent flash
173 out-of-band usage). These fields are retained only for the future
174 targeted, opt-in cheap-model offload redesign; nothing consumes
175 ``cheap_model`` to pick a model today.
176 """
177 if preferred and _known(model_manager, preferred) and preferred != session_model:
178 return session_model, True, preferred
179 if _known(model_manager, session_model):
180 return session_model, False, None
181 keys = list(model_manager.known_models().keys())
182 fallback = keys[0] if keys else session_model
183 return fallback, False, None
184
185
186def _caps(model_manager, name: str) -> dict:
187 return model_manager.capabilities(name)
188
189
190def _effort_for_model(model_manager, model: str, desired: str) -> str | None:
191 c = _caps(model_manager, model)
192 if not c.get("supports_reasoning"):
193 return None
194 return desired
195
196
197def _thinking_body(model_manager, model: str, effort: str | None) -> dict[str, Any] | None:
198 """Delegate to the active provider's ``build_extra_body`` hook.
199
200 For DeepSeek this returns ``{"thinking": {"type": "enabled"}}`` when
201 the model's capabilities support thinking. Other providers shape the
202 payload however they need (or return ``None`` if they have no
203 equivalent concept).
204 """
205 return model_manager.provider.build_extra_body(
206 model, enable_thinking=True, effort=effort
207 )
208
209
210def classify_tool_batch(tool_names: list[str]) -> str:
211 """Return a profile key: ``simple`` | ``simple_thinking`` | ``thinking`` | ``thinking_write``."""
212 func = "classify_tool_batch"
213 if not tool_names:
214 result = "complex"
215 else:
216 names = set(tool_names)
217 if names & SIMPLE_THINKING_TOOLS and not (names & THINKING_TOOLS) and names <= (SIMPLE_TOOLS | SIMPLE_THINKING_TOOLS):
218 result = "simple_thinking"
219 elif names <= SIMPLE_TOOLS:
220 result = "simple"
221 elif "write_file" in names:
222 result = "thinking_write"
223 elif names & THINKING_TOOLS:
224 result = "thinking"
225 else:
226 result = "simple_thinking"
227 _dbg("OC_CHAT.MAIN", func, f"tools={tool_names} -> {result}", 4)
228 return result
229
230
232 base: int, token_profile: str, *, ceiling: int | None = None
233) -> int:
234 """Scale completion ``max_tokens`` from ``/budget`` token profile.
235
236 *ceiling* bounds the ``deep`` profile. It defaults to the operator
237 ceiling; :func:`resolve_output_tokens` passes the tighter of that and the
238 model's own declared limit, so ``deep`` can never ask a provider for more
239 output than it says it can produce.
240 """
241 prof = (token_profile or "balanced").strip().lower()
242 cap = ceiling if ceiling is not None else get_output_tokens_ceiling()
243 if prof == "cheap":
244 return max(1024, int(base * 0.62))
245 if prof == "deep":
246 return min(cap, int(base * 1.18))
247 return min(cap, base)
248
249
251 model_manager: Any, model: str, bucket: str, token_profile: str
252) -> int:
253 """Resolve this iteration's completion cap from the model's own capability.
254
255 Replaces the former hardcoded per-bucket literals (12_288 / 8192 / 6144 /
256 4096), which were sized for a small model and silently truncated a
257 large one. The budget is the model's declared ``max_output_tokens``,
258 bounded by the operator ceiling, then apportioned by bucket and scaled by
259 the ``/budget`` profile.
260 """
261 func = "resolve_output_tokens"
262 ceiling = get_output_tokens_ceiling()
263 declared = int(_caps(model_manager, model).get("max_output_tokens") or 0)
264 budget = min(declared if declared > 0 else UNDECLARED_OUTPUT_TOKENS, ceiling)
265 share = _BUCKET_OUTPUT_SHARE.get(bucket, 1.0)
266 base = max(_MIN_BUCKET_OUTPUT_TOKENS, int(budget * share))
267 # ``budget`` is the ceiling passed downstream, so the floor lifts a starved
268 # bucket but a model declaring less than the floor still caps at its own
269 # limit — asking for more output than a provider says it can produce risks
270 # a rejected request.
271 out = scale_max_tokens_for_profile(base, token_profile, ceiling=budget)
272 _dbg("OC_CHAT.MAIN", func,
273 f"bucket={bucket} model={model} declared={declared} ceiling={ceiling} "
274 f"budget={budget} share={share} profile={token_profile} -> {out}", 3)
275 return out
276
277
278def should_use_sub_loop(task: str, iteration_count: int, recent_messages: list[dict]) -> bool:
279 """Determine if a task is complex enough to warrant sub-loop usage."""
280 # Heuristics for sub-loop triggering
281 complexity_indicators = [
282 "multiple steps",
283 "analyze",
284 "investigate",
285 "complex",
286 "multiple files",
287 "search",
288 "compare",
289 "debug",
290 "fix",
291 "implement",
292 ]
293
294 # Check if task contains complexity indicators
295 task_lower = task.lower()
296 has_complexity = any(indicator in task_lower for indicator in complexity_indicators)
297
298 # Consider iteration count - if we're stuck, use sub-loop (but not too early)
299 is_stuck = iteration_count > 8 # Increased from 5 to prevent premature sub-loop
300
301 # Consider recent message patterns - look for actual repetition patterns
302 recent_user_messages = [m for m in recent_messages if m.get("role") == "user"]
303 has_repetition = len(recent_user_messages) > 3 and any(
304 "again" in _message_text(m).lower()
305 for m in recent_user_messages[-3:]
306 )
307
308 # Prevent sub-loop triggering on first few iterations to allow main loop to work
309 if iteration_count < 3:
310 return False
311
312 # Only trigger sub-loop if we have a clear reason and haven't used it recently
313 sub_loop_used_recently = any(
314 "[Sub-loop result]" in _message_text(m)
315 for m in recent_messages[-5:] # Check last 5 messages
316 )
317
318 if sub_loop_used_recently:
319 return False
320
321 # Additional safeguard: prevent sub-loop if we're already stuck in a loop
322 if iteration_count > 15: # Emergency cutoff before hitting 30
323 return False
324
325 # Check for repeated error patterns that indicate infinite loop
326 recent_errors = [
327 m for m in recent_messages[-10:]
328 if m.get("role") == "assistant" and
329 any(error in _message_text(m).lower() for error in ["error", "failed", "not found"])
330 ]
331
332 if len(recent_errors) > 3: # Too many errors recently
333 return False
334
335 return has_complexity or is_stuck or has_repetition
336
337
338def last_assistant_tool_names(messages: list[dict]) -> list[str]:
339 """Tool function names from the most recent assistant message that issued tool_calls."""
340 for m in reversed(messages):
341 if m.get("role") != "assistant":
342 continue
343 tcs = m.get("tool_calls") or []
344 out: list[str] = []
345 for tc in tcs:
346 fn = tc.get("function") or {}
347 n = fn.get("name") or ""
348 if n:
349 out.append(n)
350 if out:
351 return out
352 return []
353
354
355# OCT-LINT: disable=no_hardcoded_secrets reason="Config defaults and tier names, not secrets"
357 *,
358 session_model: str,
359 session_reasoning_effort: str,
360 model_manager,
361 iteration: int,
362 is_finalize_nudge: bool,
363 messages: list[dict],
364 token_profile: str = "balanced",
365) -> IterationRouting:
366 """Pick model / effort / thinking / max_tokens for this ReAct iteration."""
367 func = "resolve_iteration_routing"
368 tp = token_profile or "balanced"
369 provider = model_manager.provider
370 reasoning_model = provider.tier(_REASONING_TIER)
371 cheap_model = provider.tier(_CHEAP_TIER)
372
373 # Planning (first hop) and forced final answer: strongest profile on the
374 # provider's reasoning tier (or session model if no reasoning tier).
375 if iteration == 0 or is_finalize_nudge:
376 model, would_switch, cheap_model = _pick_model(model_manager, reasoning_model, session_model)
377 # Planning/finalize always uses the main model regardless of would_switch signal
378 would_switch = False
379 cheap_model = None
380 effort = _effort_for_model(model_manager, model, "high")
381 if is_finalize_nudge:
382 effort = _effort_for_model(model_manager, model, "high") or effort
383 else:
384 # First iteration: allow session preference up to high for "planning".
385 pref = session_reasoning_effort if session_reasoning_effort in ("low", "medium", "high") else "high"
386 e2 = _effort_for_model(model_manager, model, pref)
387 effort = e2 if e2 is not None else effort
388 extra = _thinking_body(model_manager, model, effort)
389 mt = resolve_output_tokens(model_manager, model, "planning", tp)
390 _dbg("OC_CHAT.MAIN", func,
391 f"planning/finalize: model={model} effort={effort} max_tokens={mt} finalize={is_finalize_nudge}", 3)
392 return IterationRouting(model=model, effort=effort, extra_body=extra, max_tokens=mt, would_switch_model=would_switch, cheap_model=cheap_model)
393
394 tool_names = last_assistant_tool_names(messages)
395 bucket = classify_tool_batch(tool_names)
396
397 if bucket == "simple":
398 model, would_switch, cheap_model = _pick_model(model_manager, cheap_model, session_model)
399 effort = _effort_for_model(model_manager, model, "low")
400 mt = resolve_output_tokens(model_manager, model, "simple", tp)
401 _dbg("OC_CHAT.MAIN", func,
402 f"bucket=simple: model={model} effort={effort} max_tokens={mt}", 3)
403 return IterationRouting(model=model, effort=effort, extra_body=None, max_tokens=mt, would_switch_model=would_switch, cheap_model=cheap_model)
404
405 if bucket == "simple_thinking":
406 model, would_switch, cheap_model = _pick_model(model_manager, reasoning_model, session_model)
407 effort = _effort_for_model(model_manager, model, "low")
408 extra = _thinking_body(model_manager, model, effort)
409 mt = resolve_output_tokens(model_manager, model, "simple_thinking", tp)
410 _dbg("OC_CHAT.MAIN", func,
411 f"bucket=simple_thinking: model={model} effort={effort} max_tokens={mt}", 3)
412 return IterationRouting(model=model, effort=effort, extra_body=extra, max_tokens=mt, would_switch_model=would_switch, cheap_model=cheap_model)
413
414 if bucket == "thinking_write":
415 model, would_switch, cheap_model = _pick_model(model_manager, reasoning_model, session_model)
416 effort = _effort_for_model(model_manager, model, "high")
417 extra = _thinking_body(model_manager, model, effort)
418 mt = resolve_output_tokens(model_manager, model, "thinking_write", tp)
419 _dbg("OC_CHAT.MAIN", func,
420 f"bucket=thinking_write: model={model} effort={effort} max_tokens={mt}", 3)
421 return IterationRouting(model=model, effort=effort, extra_body=extra, max_tokens=mt, would_switch_model=would_switch, cheap_model=cheap_model)
422
423 # "thinking" — code/doc style tools without write_file singled out
424 model, would_switch, cheap_model = _pick_model(model_manager, reasoning_model, session_model)
425 effort = _effort_for_model(model_manager, model, "high")
426 extra = _thinking_body(model_manager, model, effort)
427 mt = resolve_output_tokens(model_manager, model, "thinking", tp)
428 _dbg("OC_CHAT.MAIN", func,
429 f"bucket=thinking: model={model} effort={effort} max_tokens={mt}", 3)
430 return IterationRouting(model=model, effort=effort, extra_body=extra, max_tokens=mt, would_switch_model=would_switch, cheap_model=cheap_model)
str classify_tool_batch(list[str] tool_names)
bool _known(model_manager, str|None name)
IterationRouting resolve_iteration_routing(*, str session_model, str session_reasoning_effort, model_manager, int iteration, bool is_finalize_nudge, list[dict] messages, str token_profile="balanced")
int resolve_output_tokens(Any model_manager, str model, str bucket, str token_profile)
dict[str, Any]|None _thinking_body(model_manager, str model, str|None effort)
tuple[str, bool, str|None] _pick_model(model_manager, str|None preferred, str session_model)
int scale_max_tokens_for_profile(int base, str token_profile, *, int|None ceiling=None)
str _message_text(dict message)
dict _caps(model_manager, str name)
bool should_use_sub_loop(str task, int iteration_count, list[dict] recent_messages)
list[str] last_assistant_tool_names(list[dict] messages)
str|None _effort_for_model(model_manager, str model, str desired)