oc_chat
Loading...
Searching...
No Matches
react_loop.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/react_loop.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/unit/test_react_loop_events.py plus plan 112 API-context cap regression tests"
6"""
7The ReAct inner loop — one user turn through up to N iterations.
8
9Purpose
10-------
11:func:`run_turn` executes a single user turn: streams from the API,
12collects tool calls, dispatches them in parallel via
13:func:`_dispatch_tool`, accumulates per-iteration usage onto the
14session, and either commits the final assistant answer or hits the
15ReAct iteration cap. This is the file the rest of the runtime hands
16control to once a user message is added to the session.
17
18Both :func:`run_turn` and :func:`_dispatch_tool` form one tightly
19coupled unit (the loop calls dispatch on every iteration that
20yields tool calls), which is why they share a module rather than
21splitting into ``react_loop.py`` + ``tool_dispatch.py``. The
22*registry* — a separate concern — lives in
23:mod:`oc_chat.core.tool_registry`.
24
25Responsibilities
26----------------
27- :func:`run_turn` — the per-turn ReAct loop; calls the API,
28 dispatches tool calls, accumulates per-iteration usage, applies
29 step routing, and surfaces results. Owns the "one user turn"
30 contract.
31- :func:`_dispatch_tool` — single tool dispatch with
32 :func:`dbg_timed` instrumentation and structured error handling.
33- :func:`_run_bracketed` — the ONE definition of what a running tool
34 looks like: the presenter's dispatch spinner plus the
35 :data:`~oc_chat.core.tool_activity.TOOL_ACTIVITY` record, opened
36 around every dispatch and closed in ``finally``. Used by the
37 allowed path and by all six re-dispatch sites in the three
38 confirmation resolvers (plan 279).
39- :class:`_TurnAccumulator` — private dataclass that replaces the
40 8 turn-level locals previously declared at the top of the loop;
41 carries the per-iteration sums into the closing turn-summary block
42 and the per-turn cache snapshot list.
43
44Diagnostics
45-----------
46Domain: OC_CHAT.MAIN
47Levels:
48 L2 — turn START / END, ReAct iteration markers, final-answer
49 received, ReAct cap hit (override=True at L1 on cap).
50 L3 — per-iteration routing decisions, tool dispatch summary,
51 per-tool OK/ERROR results.
52 L4 — deep tracing (currently unused).
53
54Tool execution timing flows under ``OC_CHAT.TOOLS`` via
55``dbg_timed("OC_CHAT.TOOLS", "_dispatch_tool", label=tool_name)``.
56
57Contracts
58---------
59- The ReAct loop respects ``session.max_react_iterations`` (clamped
60 to 1–99) — falling back to ``DEFAULT_MAX_REACT_ITERATIONS``.
61- Multiple tool calls in one assistant message run in parallel via
62 :class:`ThreadPoolExecutor`; results are reordered to match the
63 original tool_call order before being appended to history.
64- The session's ``last_turn_max_prompt_tokens`` is updated each turn
65 so :func:`maybe_compress` has an accurate trigger.
66- All file-modifying tool calls (``write_file``) are recorded in
67 ``ctx.goal_files_changed`` when a goal is in flight, for later
68 changelog updates.
69- Per-turn auto-save happens at the end of every turn via
70 :func:`save_chat_log` + :func:`save_session_metadata` so a crash
71 mid-session at most loses the in-flight turn.
72
73Dependencies
74------------
75- oc_diagnostics (structured debug logging via _dbg)
76- oc_chat.core.cache_usage (DeepSeek prompt-cache hit ratio reporting)
77- oc_chat.core.api_context (build the message list sent to the API)
78- oc_chat.core.session (ensure_system_includes_tool_reference helper)
79- oc_chat.core.step_routing (per-iteration model/effort/max_tokens routing)
80- oc_chat.core.persistence (per-turn auto-save)
81- oc_chat.core.event_bus (default_bus for synchronous M2 event publication)
82- oc_chat.core.events (make_* TurnEvent factories and new_event_id)
83- oc_chat.core.tool_activity (the terminal-only in-flight tool record)
84- oc_chat.core.cancellation (two-stage Ctrl+C: cancellation token, stop confirmation, tool-cancel reset)
85- oc_chat.core.config (get_max_context_tokens for the context-size gauge)
86- oc_chat.core.context (_estimate_tokens fallback for the context usage display)
87- oc_chat.core.iteration_stat (IterationStat per-iteration statistics record)
88- oc_chat.core.permission_validator (permission_decision fields for auto-allow, cap and stall denials, and tool capability)
89- oc_chat.core.provider_plugin (PerformanceStats on the turn accumulator)
90- oc_chat.core.sub_loop (run_sub_loop to offload a sub-task)
91- oc_chat.core.turn_cost (TurnCostAccumulator and price_usage for the per-sample USD estimate)
92- oc_ui.terminal.enhanced_presenter (EnhancedPresenter type of the optional run_turn argument)
93- oc_chat.core.external_content_gate (lazy; external-fetch confirmation and binding source trust to the active session)
94- oc_chat.core.resource_caps (lazy; cost thresholds, per-turn tool-call cap and worker clamp)
95- oc_chat.core.session_checkpoints (lazy; mini report and the cost, tool-call and stall checkpoints)
96- oc_chat.core.session_mode (lazy; remembered answers, Auto mode and budgets for Tier-2 confirmation)
97- oc_chat.core.settings (lazy; runtime settings and mode for the verification config)
98- oc_chat.core.stall_detector (lazy; StallDetector and stall_sentence for repeated-call denial)
99- oc_chat.core.tier2_session_grant (lazy; the session work-directory grant for Tier-2 python runs)
100- oc_chat.core.tokenizer (lazy; token-count estimate of a cancelled stream with no usage chunk)
101- oc_chat.core.verification (lazy; verification baseline, runs and changed-path tracking)
102- oc_chat.tools._argv_policy (lazy; classify_command to re-derive the Tier-2 argv)
103- oc_chat.tools._sandbox (lazy; redact_secrets on error text and sub-loop results)
104- oc_chat.tools.filesystem (lazy; sandbox root and the protected-write confirmation)
105- oc_chat.tools.shell (lazy; human-confirmed command and budget for Tier-2 re-dispatch)
106- oc_pricing (lazy; rate_at quote for the provider budget event)
107- oc_ui.presenter_base (lazy; Presenter type hint)
108- oc_ui.terminal.input (lazy; read_single_key prompts and the TurnStats type hint)
109
110See Also
111--------
112oc_chat/docs/conventions/CONTRACTS.md §5 Streaming chunk tuples — the
113``("reasoning"|"content"|"tool_call"|"usage"|"finish_reason", data)``
114shapes the loop consumes; §6 Tool-call dict shape — the dict shape
115passed to :func:`_dispatch_tool`.
116"""
117from __future__ import annotations
118
119import hashlib
120import json
121from concurrent.futures import ThreadPoolExecutor
122from dataclasses import dataclass, field
123from typing import TYPE_CHECKING, Callable, TypeVar
124
125from oc_diagnostics import _dbg, dbg_timed
126from oc_ui.terminal.enhanced_presenter import EnhancedPresenter
127from oc_chat.core.cancellation import (
128 CancellationError,
129 CancellationToken,
130 confirm_or_continue,
131 reset_tool_cancel,
132)
133from oc_chat.core.provider_plugin import PerformanceStats
134from oc_chat.core.turn_cost import TurnCostAccumulator, price_usage
135
136from oc_chat.core.event_bus import default_bus
137from oc_chat.core.events import (
138 make_assistant_message, make_error, make_permission_decision,
139 make_prompt_rendered, make_provider_budget_consumed,
140 make_tool_call_completed, make_tool_call_proposed, make_tool_call_started,
141 make_user_input_received, new_event_id,
142)
143from oc_chat.core.api_context import messages_for_api, resolve_tool_result_cap
144from oc_chat.core.config import get_max_context_tokens
145from oc_chat.core.context import _estimate_tokens
146from oc_chat.core.cache_usage import cache_hit_ratio_pct
148 auto_allow_decision_fields, cap_deny_decision_fields, link_started_to_decision,
149 stall_deny_decision_fields, tool_capability,
150)
151from oc_chat.core.persistence import save_chat_log, save_session_metadata
152from oc_chat.core.session import ensure_system_includes_tool_reference
153from oc_chat.core.step_routing import resolve_iteration_routing, should_use_sub_loop
154from oc_chat.core.sub_loop import run_sub_loop
155from oc_chat.core.tool_activity import TOOL_ACTIVITY
156import time
157from oc_chat.core.iteration_stat import IterationStat
158from datetime import datetime, timezone
159
160
161#: Hard backstop on sub-loop offloads per turn (plan 132). This is a runaway
162#: guard, NOT a behaviour limiter: the real fix is removing the would_switch_model
163#: coupling that caused the iter-0→119 infinite offload. Set well above legitimate
164#: use (multiple sub-loops per turn for distinct sub-tasks is designed, tested
165#: M2.4 behaviour — see test_sub_loop_event_chain) and well below runaway. Even
166#: if a guard regresses, a turn can never spend itself offloading. Tunable from
167#: dogfooding data; revisit with the targeted-offload redesign (tracked in the
168#: living ROADMAP-EXTENDED.md backlog: read-size/intent-driven, opt-in).
169_MAX_SUB_LOOPS_PER_TURN: int = 5
170
171#: Placeholder ``content`` for a reasoning-only completion — an iteration that
172#: streamed chain-of-thought but was cut off before emitting a single content
173#: token (typically the output-token cap being spent entirely on reasoning).
174#: The API needs a non-empty string here; the model reads it as the record of
175#: what happened on the previous hop.
176REASONING_ONLY_CONTENT: str = (
177 "[This reply was cut off before any answer text was produced — the "
178 "reasoning above is all that was generated. Continue from it.]"
179)
180
181#: Operator-facing notice for the same condition. Distinct from the
182#: nothing-at-all case, which stays "[No response content received]".
183#: ``{cause}`` comes from the provider's finish_reason — a confirmed cap hit
184#: reads differently from a stop the provider declined to explain.
185REASONING_ONLY_NOTICE: str = (
186 "[No answer text — the model produced only reasoning ({n} chars) before the "
187 "response ended{cause}. The reasoning is kept in this session; ask it to "
188 "continue, or raise the output cap with /budget deep.]"
189)
190
191#: ``{cause}`` fillers. Absence of a finish_reason is "unknown", never "stop" —
192#: saying "hit the cap" without the provider's word for it would be a guess.
193CAUSE_LENGTH: str = ", having hit its {cap}-token output cap"
194CAUSE_UNKNOWN: str = " (the provider reported no finish reason)"
195
196#: The plain truncation the operator sees most: a real answer cut mid-sentence.
197TRUNCATED_ANSWER_NOTICE: str = (
198 "[Answer truncated — the model hit its {cap}-token output cap mid-response. "
199 "Ask it to continue, or raise the cap with /budget deep.]"
200)
201
202#: Truncation that lands mid tool-call leaves ``arguments`` an unparseable JSON
203#: fragment. _dispatch_tool already answers the model with a re-issue error;
204#: this tells the human why a tool call failed for no apparent reason.
205TRUNCATED_TOOL_CALL_NOTICE: str = (
206 "[Tool call(s) truncated — the model hit its {cap}-token output cap while "
207 "emitting {n} tool call(s), so their arguments may be incomplete.]"
208)
209
210
211def _extract_last_user_task(messages: list[dict]) -> str:
212 """Extract the most recent user message content for sub-loop task analysis."""
213 for m in reversed(messages):
214 if m.get("role") == "user" and m.get("content"):
215 return m["content"]
216 return ""
217
218
219def _detect_response_repetition(messages: list[dict]) -> bool:
220 """Detect if recent assistant responses are too similar (repetition bug)."""
221 recent_assistant_messages = [
222 m.get("content", "") for m in reversed(messages)
223 if m.get("role") == "assistant" and m.get("content")
224 ]
225
226 if len(recent_assistant_messages) < 3:
227 return False
228
229 # Check similarity between last 3 responses
230 last_three = recent_assistant_messages[:3]
231 similarity_threshold = 0.8 # 80% similarity threshold
232
233 for i in range(len(last_three) - 1):
234 for j in range(i + 1, len(last_three)):
235 # Simple similarity check based on common words
236 words1 = set(last_three[i].lower().split())
237 words2 = set(last_three[j].lower().split())
238
239 if words1 and words2:
240 intersection = words1.intersection(words2)
241 union = words1.union(words2)
242 similarity = len(intersection) / len(union)
243
244 if similarity > similarity_threshold:
245 return True
246
247 return False
248 from oc_ui.presenter_base import Presenter
249 from oc_ui.terminal.input import TurnStats
250
251
252_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
253
254
255@dataclass
257 """Per-turn running totals carried across ReAct iterations.
258
259 Replaces the 8 turn-level ``int`` locals previously declared at
260 the top of :func:`run_turn`. No behaviour change — purely a
261 typed shape that makes the contract obvious to anyone reading
262 and lays groundwork for a public ``TurnResult`` in G.6.D.
263 """
264
265 prompt_tokens: int = 0
266 completion_tokens: int = 0
267 total_tokens: int = 0
268 tool_calls: int = 0
269 iterations: int = 0
270 prompt_cached: int = 0
271 cache_hit_tokens: int = 0
272 cache_miss_tokens: int = 0
273 max_prompt_in_turn: int = 0
274 perf_stats: PerformanceStats = field(default_factory=PerformanceStats)
275 # Plan 188 T4: the honesty-gated USD estimate. Folded per SAMPLE (each
276 # iteration at its own route.model, each sub-loop at its own) rather than
277 # from the turn's summed tokens, so a routed or offloaded turn is priced
278 # exactly. One unpriceable sample suppresses the whole turn's line.
279 cost: TurnCostAccumulator = field(default_factory=TurnCostAccumulator)
280 # Plan 137 (verification backpressure) turn-scoped state — reset each turn via a
281 # fresh _TurnAccumulator (never session-scoped). ``changed_files`` is CUMULATIVE
282 # across the turn's iterations (so later writes + the model's fix are reverified);
283 # ``pending_findings`` carries the last non-clean result for the cap-hit notice.
284 changed_files: set = field(default_factory=set)
285 verification_runs: int = 0
286 pending_findings: "VerificationResult | None" = None
287 # Plan 173 item 3 (F22): one-shot latch so the per-turn tool-call cap-approach
288 # warning is injected to the model at most once per turn (not every iteration).
289 cap_warning_emitted: bool = False
290 # Plan 237 (CAPS-01) turn-scoped checkpoint state.
291 # ``cap_extensions`` counts the cap-widths the operator has bought this turn,
292 # so the next checkpoint lands at 200, 300, … rather than re-asking at 100.
293 # The other two feed the mini report; they are populated unconditionally
294 # (unlike ``changed_files``, which is gated on the plan-137 flag) because the
295 # checkpoint must be able to say what the turn DID whether or not
296 # verification is on. Counting a name and a path per call is free.
297 cap_extensions: int = 0
298 tool_name_counts: dict = field(default_factory=dict)
299 files_written: list = field(default_factory=list)
300
301
302def _now_iso() -> str:
303 """Millisecond-precision local ISO-8601 timestamp for event envelopes."""
304 return datetime.now().astimezone().isoformat(timespec="milliseconds")
305
306
307def _now_utc() -> datetime:
308 """The pricing clock: a tz-AWARE UTC instant (plan 202 decision 3).
309
310 oc_pricing treats a naive ``at`` as ``None``, and every model in its
311 table is effective-dated/TOU-banded — a naive instant would therefore
312 turn EVERY sample unpriceable: a silent pricing blackout dressed as an
313 honest "unknown". This seam is the single source of pricing time, and
314 each sample captures it ONCE at the send seam so the ``$``-line fold
315 and the budget event can never price the same sample under two clock
316 reads that straddle a tariff/band boundary. Monkeypatchable in tests.
317 """
318 return datetime.now(timezone.utc)
319
320
321def _safe_tool_args(tc: dict) -> dict:
322 """Best-effort parse of a tool call's JSON ``arguments`` into a dict."""
323 try:
324 parsed = json.loads(tc.get("arguments") or "{}")
325 except (json.JSONDecodeError, TypeError):
326 return {"_raw": tc.get("arguments", "")}
327 return parsed if isinstance(parsed, dict) else {"_raw": tc.get("arguments", "")}
328
329
331 tool_calls: list[dict],
332 allowed: int,
333 ev: Callable[[], dict],
334 stall_denied: set[int] | None = None,
335) -> list[str]:
336 """M8.1 WU-0a: decide every proposed call **before** any of them starts.
337
338 The classify half of the classify → decide → emit-started-for-allowed lifecycle
339 (§7.2 / F7 / F31). Exactly two verdicts are computable pre-dispatch, and both are
340 emitted here as one ``permission_decision`` per call, carrying its ``tool_call_id``:
341
342 - calls at or beyond the per-turn budget (``idx >= allowed``, the same predicate
343 :func:`_dispatch_or_cap` denies on) — a **cap-deny**, carrying the fail-closed
344 ``risk_class="critical"`` sentinel and ``denial_reason_code``;
345 - every other call — an **auto-allow**, whose rung is derived from the tool's
346 capability alone.
347
348 Tier-2 / protected-write / external-content verdicts are computed inside the tool
349 bodies and stay post-dispatch until WU-0b (§A.9(b) narrows "exactly one
350 ``permission_decision`` per call" to "per call whose verdict is computable
351 pre-dispatch"), so this function never consults a gate.
352
353 Parameters
354 ----------
355 tool_calls : list[dict]
356 This iteration's proposed calls, in call order.
357 allowed : int
358 The per-turn budget's allowed prefix length (``_allowed_calls``).
359 ev : Callable[[], dict]
360 The turn's envelope-kwargs factory (``event_id``/``parent``/``session``/``ts``).
361
362 Returns
363 -------
364 list[str]
365 The ``permission_decision_id`` of each call's decision, positionally aligned
366 with *tool_calls*, so the started event can carry its deciding id.
367 """
368 func = "_publish_pre_dispatch_decisions"
369 decision_ids: list[str] = []
370 stalled = stall_denied or set()
371 for idx, tc in enumerate(tool_calls):
372 if idx in stalled:
373 build = stall_deny_decision_fields
374 elif idx >= allowed:
375 build = cap_deny_decision_fields
376 else:
377 build = auto_allow_decision_fields
378 fields = build(tool_call_id=tc["id"], tool_name=tc["name"], args=_safe_tool_args(tc))
379 pd_id = new_event_id()
380 decision_ids.append(pd_id)
381 default_bus().publish(make_permission_decision(
382 **ev(), permission_decision_id=pd_id, **fields))
383 _dbg("OC_CHAT.TOOLS", func,
384 f"decided {len(decision_ids)} call(s) pre-dispatch "
385 f"({allowed} in cap, {len(stalled)} stall-denied, "
386 f"{max(0, len(tool_calls) - allowed)} cap-denied)", 2)
387 return decision_ids
388
389
390#: Return type of whatever :func:`_run_bracketed` is handed; the bracket is
391#: transparent, so what goes in comes out.
392_T = TypeVar("_T")
393
394
395def _run_bracketed(presenter, label: str, fn: "Callable[[], _T]") -> _T:
396 """Run *fn* with the terminal's busy chrome open, and close it whatever happens.
397
398 ONE place decides what a running tool looks like (plan 279 P3). The chrome
399 is two things: the presenter's dispatch spinner, which hosts the status bar
400 and its ticking timers, and :data:`~oc_chat.core.tool_activity.TOOL_ACTIVITY`,
401 the record a child's output lines land on. Before this existed only the
402 allowed-path dispatch was bracketed, so a Tier-2 command — which returns
403 ``requires_confirmation`` in milliseconds and is re-dispatched by a
404 confirmation resolver AFTER that bracket has closed — ran with no chrome at
405 all. A twelve-minute suite showed nothing.
406
407 Transparent by construction: the value *fn* returns is returned unchanged,
408 an exception propagates unchanged, and nothing about the call's result, its
409 events or its logging passes through here. Both halves close in ``finally``,
410 so a raising tool strands neither a Live display nor a stale tail.
411 """
412 handle = presenter.spinner_start(label)
413 TOOL_ACTIVITY.begin(label)
414 try:
415 return fn()
416 finally:
417 TOOL_ACTIVITY.end()
418 presenter.spinner_stop(handle)
419
420
421def _dispatch_tool(tc: dict, tool_registry: dict) -> tuple[dict, dict]:
422 """Execute a single tool call. Returns ``(tc, result)`` preserving original *tc* for ordering."""
423 func = "_dispatch_tool"
424 _dbg("OC_CHAT.TOOLS", func, f"executing {tc['name']}", 3)
425 with dbg_timed("OC_CHAT.TOOLS", func, label=tc["name"], level=3):
426 tool_fn = tool_registry.get(tc["name"])
427 if not tool_fn:
428 _dbg("OC_CHAT.TOOLS", func, f"unknown tool: {tc['name']}", 1, override=True)
429 return tc, {"error": f"Unknown tool: {tc['name']}"}
430 # Parse arguments separately from execution: a malformed/truncated
431 # arguments JSON (observed with write_file + DeepSeek — the streamed
432 # tool call ends mid-string) must NOT surface as a cryptic
433 # JSONDecodeError the model can't act on. Instrument the raw payload
434 # for root-cause analysis (length cut vs early stream end vs model
435 # glitch) and hand the model an actionable retry message.
436 raw_args = tc.get("arguments") or "{}"
437 try:
438 args = json.loads(raw_args)
439 except json.JSONDecodeError as e:
440 _dbg("OC_CHAT.TOOLS", func,
441 f"MALFORMED tool args for {tc['name']}: {type(e).__name__}: {e}; "
442 f"len={len(raw_args)} raw={raw_args[:300]!r}", 1, override=True)
443 return tc, {"error": (
444 f"Your `{tc['name']}` tool-call arguments were not valid JSON "
445 f"({e}). They appear truncated or malformed. Re-issue the call "
446 f"with complete, valid JSON arguments; for large file content, "
447 f"write it in smaller pieces."
448 )}
449 try:
450 result = tool_fn(**args)
451 except Exception as e:
452 _dbg("OC_CHAT.TOOLS", func,
453 f"tool {tc['name']} raised {type(e).__name__}: {e}", 1, override=True)
454 result = {"error": str(e)}
455 return tc, result
456
457
459 """True only when a real interactive TTY is attached (a human is present).
460
461 Fail-closed default: any non-TTY (sub-loop, batch, piped/redirected stdin,
462 pytest capture) or any probe error returns ``False``. Isolated as a tiny
463 seam so the M8.0-C confirmation relay is testable without faking ``sys.stdin``.
464 """
465 import sys as _sys
466 try:
467 return bool(_sys.stdin and _sys.stdin.isatty())
468 except (ValueError, OSError, AttributeError):
469 return False
470
471
472def _budget_text(seconds: "int | None") -> str:
473 """How a budget reads in an operator-facing line (plan 283).
474
475 ``None`` is a real answer here — "run until it finishes" — and must never
476 render as the word ``None`` in a line whose whole job is to say how long the
477 terminal is about to be busy.
478 """
479 return "no limit" if seconds is None else f"{seconds} s"
480
481
483 tc: dict, tool_registry: dict, presenter, command: str,
484 program: str, budget: "int | None",
485) -> dict:
486 """Re-dispatch an approved Tier-2 call under its approval and its budget.
487
488 The ONE place a plan-283 answer becomes a running command, so the three
489 approval routes — a key at the prompt, this session's memory, and Auto mode
490 — cannot drift apart in what they authorise or how long they allow. The
491 authority is still and only :func:`shell.human_confirmed_command` with the
492 exact string; the budget rides beside it and decides nothing.
493
494 An expiry hook is installed only when there is a budget to expire. "Run
495 until it finishes" has no expiry, so there is nothing to ask about, and
496 passing a hook there would arm a prompt that could never fire.
497 """
498 from oc_chat.core import session_mode
499 from oc_chat.tools import shell
500
501 on_expired = None
502 if budget is not None:
503 def on_expired() -> "int | None": # noqa: F811
504 return session_mode.ask_budget_extension(presenter, program, budget)
505
506 with shell.human_confirmed_command(command):
507 with shell.human_budget(budget, on_expired=on_expired):
508 # Plan 279: the bracket the twelve-minute suite was missing.
509 _, confirmed_result = _run_bracketed(
510 presenter, f"running {tc['name']}…",
511 lambda: _dispatch_tool(tc, tool_registry))
512 return confirmed_result
513
514
516 tc: dict, result: dict, tool_registry: dict, presenter,
517) -> dict:
518 """M8.0-C: out-of-band human-confirm channel for a Tier-2 ``requires_confirmation``.
519
520 A Tier-2 command (``run_command`` over python/pytest/pip-install/oct) returns
521 ``{"requires_confirmation": True, ...}`` that no model-reachable flag can satisfy
522 (M8.0-A). Here -- in the single-threaded post-dispatch loop, never inside the
523 parallel executor -- the decision is relayed to the *human*:
524
525 * interactive TTY -> show the exact command + reason, prompt 'y/N'; on 'y'
526 re-dispatch the SAME call with the command authorized via
527 :func:`shell.human_confirmed_command` (a ContextVar the model cannot set);
528 * headless / sub-loop / batch / piped stdin (no TTY) -> fail closed.
529
530 Either denial path hands the model a terminal ``error`` it can act on (stop
531 asking), not the raw ``requires_confirmation`` it cannot satisfy. Any non-
532 confirmation result is returned unchanged (identity).
533
534 Plan 235 (SHELL-02) adds a THIRD answer, ``s``, offered only when the pending
535 command is ``python <script inside the work directory>``: it grants that
536 DIRECTORY for the session, so the agent's next probe script there runs with no
537 keystroke (:mod:`oc_chat.core.tier2_session_grant`). The grant only decides
538 whether the human is ASKED — a grant-satisfied run still re-dispatches under
539 :func:`shell.human_confirmed_command`, so the ContextVar exact-string contract
540 remains the single authorisation seam. It is consulted only AFTER the TTY
541 check above, so the headless path is unchanged, and never off ``permissive``.
542
543 Plan 283 (SHELL-04) widens the ANSWER, never the authority. ``y`` now runs
544 under the program's default budget (:mod:`oc_chat.core.session_mode`), ``c``
545 runs until it finishes, ``t`` takes a typed budget, ``a`` and ``d`` record a
546 standing yes or no for that program, and ``u`` runs it and switches the
547 session to Auto mode. Every one of them re-dispatches through
548 :func:`shell.human_confirmed_command` exactly as ``y`` always has; what is
549 new is only how long the human is willing to wait and how often they are
550 willing to be asked. The order below is deliberate: the TTY check first (so
551 headless is untouched byte for byte), then the plan-235 grant (unchanged),
552 then this session's memory, then Auto mode, and only then the prompt.
553 """
554 func = "_resolve_tier2_confirmation"
555 if not (isinstance(result, dict) and result.get("requires_confirmation")):
556 return result
557
558 from oc_chat.tools import shell
559
560 command = result.get("command", "")
561 reason = result.get("reason", "")
562
563 # Fail closed unless a real interactive human is present to approve.
564 if not _stdin_is_interactive():
565 _dbg("OC_CHAT.TOOLS", func, f"no TTY -> Tier-2 denied: {command!r}", 2, override=True)
566 return shell.deny_unconfirmed_tier2(
567 result, note="No interactive terminal is attached, so a human cannot approve it here.")
568
569 # Plan 235: re-derive the argv from the same classifier `run_command` used, so
570 # what the grant inspects is what would execute — never the raw string.
572 active_session_grant, grant_work_dir_for_session, grantable_work_dir,
573 session_grant_satisfies,
574 )
575 from oc_chat.tools._argv_policy import classify_command
576 from oc_chat.tools.filesystem import _sandbox_root
577 sandbox_root = _sandbox_root()
578 _decision = classify_command(command, sandbox_root)
579 argv = _decision.argv if _decision.decision == "confirm" else []
580
581 if session_grant_satisfies(argv, sandbox_root):
582 granted = active_session_grant()
583 # Say so on EVERY such run: the transcript must show the gate was down.
584 presenter.show_info(
585 f"[grant] running under the session grant on {granted} "
586 f"(no confirmation asked — /grants revoke to drop it):\n {command}"
587 )
588 _dbg("OC_CHAT.TOOLS", func,
589 f"session grant satisfied Tier-2 without a prompt: {command!r} "
590 f"(granted dir {granted})", 2, override=True)
591 with shell.human_confirmed_command(command):
592 # Plan 279: bracketed, so a granted run animates exactly like an
593 # allowed one. The bracket is transparent — `granted_result` is the
594 # tool's own object, unchanged.
595 _, granted_result = _run_bracketed(
596 presenter, f"running {tc['name']}…",
597 lambda: _dispatch_tool(tc, tool_registry))
598 return granted_result
599
600 # Plan 283: what this session already decided about this program, and what
601 # Auto mode would decide, are both consulted before the human is disturbed.
602 # Neither is authority — each only answers the question the prompt asks.
603 from oc_chat.core import session_mode
604
605 program = session_mode.program_key(argv)
606 default_budget = session_mode.default_budget_for(program)
607
608 remembered = session_mode.remembered_answer(program)
609 if remembered is not None:
610 if not remembered.run:
611 presenter.show_info(
612 f"[remembered] denied {program} under the session answer "
613 f"(/mode forget to drop it):\n {command}")
614 _dbg("OC_CHAT.TOOLS", func,
615 f"session answer denied Tier-2 without a prompt: {command!r}", 2,
616 override=True)
617 return shell.deny_unconfirmed_tier2(
618 result, note="A human reviewed the request and declined to run it.")
619 presenter.show_info(
620 f"[remembered] running {program} under the session answer "
621 f"(budget {_budget_text(remembered.budget_s)}; /mode forget to drop it):"
622 f"\n {command}")
623 _dbg("OC_CHAT.TOOLS", func,
624 f"session answer satisfied Tier-2 without a prompt: {command!r}", 2,
625 override=True)
627 tc, tool_registry, presenter, command, program, remembered.budget_s)
628
629 if session_mode.auto_mode_active():
630 auto = session_mode.auto_answer_for(program)
631 presenter.show_info(
632 f"[auto] running {program} in Auto mode "
633 f"(budget {_budget_text(auto.budget_s)}; /mode ask to stop):"
634 f"\n {command}")
635 _dbg("OC_CHAT.TOOLS", func,
636 f"Auto mode answered the Tier-2 ask: {command!r}", 2, override=True)
638 tc, tool_registry, presenter, command, program, auto.budget_s)
639
640 presenter.show_info(
641 f"[confirm] The agent requests a Tier-2 command:\n {command}"
642 + (f"\n Reason: {reason}" if reason else "")
643 )
644 # The third key is offered ONLY when pressing it would satisfy this command;
645 # otherwise the prompt is byte-identical to the pre-235 two-key one.
646 offer_dir = grantable_work_dir(argv, sandbox_root)
647 # ``u`` is offered on exactly the terms the mode itself runs on: permissive
648 # only, like the grant. Offering a key that could not take effect would be
649 # the worst of both — a keystroke that looks like it did something.
650 offer_auto = session_mode.auto_mode_available()
651
652 # ONE legend naming every key the prompt below offers (P2, v1.1).
653 legend = [
654 f" Budget for {program}: {default_budget} s.",
655 f" [y] run with the {default_budget} s budget"
656 f" [c] run until it finishes [t] type a budget",
657 f" [a] always run {program} this session"
658 f" [d] never ask about {program} again",
659 ]
660 if offer_auto:
661 legend.append(" [u] run it and switch this session to Auto mode")
662 legend.append(" [N] deny (default)")
663 presenter.show_info("\n".join(legend))
664
665 if offer_dir is not None:
666 presenter.show_info(
667 f" [s] grant python on {offer_dir} for this session (any script the "
668 f"agent writes there runs without asking until /grants revoke or the "
669 f"session ends)"
670 )
671 keys = ["y", "N", "c", "t", "a", "d"]
672 if offer_auto:
673 keys.append("u")
674 if offer_dir is not None:
675 keys.append("s")
676 prompt = f"Run this command? ({'/'.join(keys)}) "
677 valid = "".join(key.lower() for key in keys)
678 from oc_ui.terminal.input import read_single_key
679 try:
680 answer = read_single_key(prompt, valid=valid)
681 except (EOFError, KeyboardInterrupt):
682 # Interrupt at the prompt -> fail closed (deny) and return a well-formed
683 # result, rather than propagating and orphaning the tool_call.
684 return shell.deny_unconfirmed_tier2(result, note="Confirmation interrupted; denied (fail-closed).")
685
686 # Every key that is not an approval falls through to the SAME denial the
687 # two-key prompt returned, including a key whose precondition has gone —
688 # the offer flags are re-checked here rather than trusted from the prompt.
689 budget: "int | None" = default_budget
690 if answer == "s" and offer_dir is not None:
691 # 's' records the grant AND runs the current command, under today's
692 # clamped timeout: the plan-235 path gains no budget (P4).
693 grant_work_dir_for_session(offer_dir)
694 _dbg("OC_CHAT.TOOLS", func, f"human approved Tier-2: {command!r}", 2, override=True)
695 with shell.human_confirmed_command(command):
696 _, confirmed_result = _run_bracketed(
697 presenter, f"running {tc['name']}…",
698 lambda: _dispatch_tool(tc, tool_registry))
699 return confirmed_result
700 if answer == "c":
701 budget = None
702 elif answer == "t":
703 budget = session_mode.read_typed_budget(presenter)
704 if budget is None:
705 _dbg("OC_CHAT.TOOLS", func,
706 f"no usable budget typed -> Tier-2 denied: {command!r}", 2, override=True)
707 return shell.deny_unconfirmed_tier2(
708 result, note="A human reviewed the request and declined to run it.")
709 elif answer == "a":
710 session_mode.remember_answer(
711 program, session_mode.RememberedAnswer(run=True, budget_s=default_budget))
712 elif answer == "d":
713 session_mode.remember_answer(
714 program, session_mode.RememberedAnswer(run=False))
715 _dbg("OC_CHAT.TOOLS", func,
716 f"human declined Tier-2 and will not be asked again: {command!r}", 2,
717 override=True)
718 return shell.deny_unconfirmed_tier2(
719 result, note="A human reviewed the request and declined to run it.")
720 elif answer == "u" and offer_auto:
721 # v1.1: the mode is reachable from the answer itself, exactly as
722 # ``/mode auto`` would set it — the same one setter, no second path.
723 session_mode.set_mode(session_mode.AUTO)
724 presenter.show_info(
725 " Auto mode is ON for this session: the routine asks are answered "
726 "without a keystroke. /mode ask switches back; a protected write "
727 "still always reaches you.")
728 elif answer != "y":
729 _dbg("OC_CHAT.TOOLS", func, f"human declined Tier-2: {command!r}", 2, override=True)
730 return shell.deny_unconfirmed_tier2(
731 result, note="A human reviewed the request and declined to run it.")
732
733 # Approved: re-dispatch the SAME call with this exact command authorized.
734 _dbg("OC_CHAT.TOOLS", func, f"human approved Tier-2: {command!r}", 2, override=True)
736 tc, tool_registry, presenter, command, program, budget)
737
738
740 tc: dict, result: dict, tool_registry: dict, presenter,
741) -> dict:
742 """M8.0 F67: relay an external-content gate block to the human (§7.3).
743
744 ``fetch_url`` / ``web_search`` return ``{"gate": "external_content", ...}`` when
745 the source is not session-trusted. Here -- single-threaded, with stdin/presenter
746 -- a three-choice prompt is shown:
747
748 * ``a`` allow once -> re-dispatch under :func:`ecg.allow_external_source_once`
749 (a one-shot ContextVar grant, NOT persisted as trust);
750 * ``t`` trust this source for the session -> :func:`ecg.trust_source_for_session`
751 then re-dispatch (later fetches from it skip the prompt);
752 * ``d`` / Enter (default) deny -> terminal error.
753
754 Headless / sub-loop / batch (no TTY) fail closed. Non-gate results pass through.
755 """
756 func = "_resolve_external_content_confirmation"
757 if not (isinstance(result, dict) and result.get("gate") == "external_content"):
758 return result
759
760 from oc_chat.core import external_content_gate as ecg
761
762 source = result.get("source", "")
763 tool = result.get("tool", tc.get("name", ""))
764 detail = result.get("detail", "")
765
766 if not _stdin_is_interactive():
767 _dbg("OC_CHAT.TOOLS", func, f"no TTY -> external fetch denied: {source!r}", 2, override=True)
768 return ecg.deny_external_fetch(
769 result, note="No interactive terminal is attached, so a human cannot approve it here.")
770
771 # Plan 279: both approved paths are bracketed — a slow fetch is exactly the
772 # case where the terminal must not look dead.
773 _label = f"running {tc['name']}…"
774
775 # Plan 283: Auto mode answers this ask with ``a`` — allow ONCE — and never
776 # with ``t``. Trust is a standing grant on a source and outlives the mode;
777 # a one-shot allow expires with the call, which is the most a mode should be
778 # able to hand out on the human's behalf.
779 from oc_chat.core import session_mode
780 if session_mode.auto_mode_active():
781 presenter.show_info(
782 f"[auto] allowing ONE fetch from '{source}' in Auto mode "
783 f"(not trusted for the session; /mode ask to stop)")
784 _dbg("OC_CHAT.TOOLS", func,
785 f"Auto mode allowed one external fetch: {source!r}", 2, override=True)
786 with ecg.allow_external_source_once(source):
787 _, approved = _run_bracketed(
788 presenter, _label, lambda: _dispatch_tool(tc, tool_registry))
789 return approved
790
791 presenter.show_info(
792 f"[confirm] {tool} wants to pull EXTERNAL, untrusted content from '{source}'"
793 + (f":\n {detail}" if detail else "")
794 + "\n External content can carry injected instructions."
795 )
796 from oc_ui.terminal.input import read_single_key
797 try:
798 answer = read_single_key(
799 f" [a]llow once / [t]rust '{source}' this session / [d]eny (default deny): ", valid="atd")
800 except (EOFError, KeyboardInterrupt):
801 return ecg.deny_external_fetch(result, note="Confirmation interrupted; denied (fail-closed).")
802
803 if answer == "t":
804 ecg.trust_source_for_session(source)
805 _dbg("OC_CHAT.TOOLS", func, f"human trusted source: {source!r}", 2, override=True)
806 # now session-trusted -> passes
807 _, approved = _run_bracketed(
808 presenter, _label, lambda: _dispatch_tool(tc, tool_registry))
809 return approved
810 if answer == "a":
811 _dbg("OC_CHAT.TOOLS", func, f"human allowed once: {source!r}", 2, override=True)
812 with ecg.allow_external_source_once(source):
813 _, approved = _run_bracketed(
814 presenter, _label, lambda: _dispatch_tool(tc, tool_registry))
815 return approved
816 _dbg("OC_CHAT.TOOLS", func, f"human denied external fetch: {source!r}", 2, override=True)
817 return ecg.deny_external_fetch(result, note="A human reviewed the request and declined.")
818
819
821 tc: dict, result: dict, tool_registry: dict, presenter,
822) -> dict:
823 """M8.0 F70/F70a: relay a human-confirm protected-write block to the human.
824
825 ``write_file`` returns ``{"gate": "protected_write", ...}`` for a config-as-code
826 or importable-tree target (a write-then-import in-process code-execution risk).
827 Here -- single-threaded, with stdin/presenter:
828
829 * interactive TTY -> show the target + reason, prompt 'y/N'; on 'y' re-dispatch
830 the SAME call under :func:`filesystem.human_confirmed_write` (a ContextVar the
831 model cannot set);
832 * headless (no TTY) -> **F70a**: denied unless a signed ``PlanAutonomyEnvelope``
833 covers the write (M8.3; none in M8.0), never a silent auto-allow.
834
835 Non-protected-write results pass through unchanged.
836
837 **This resolver has no Auto-mode path, and must never grow one (plan 283
838 P3).** Its two siblings consult :func:`oc_chat.core.session_mode` and answer
839 themselves when the operator has switched the session to ``auto``; this one
840 does not, because the paths it guards are the ruled set — the security layer,
841 the thresholds, any octrc, ``oct/oct/core/``, MCP and the scanner — and those
842 are the decisions the project requires a human to make one at a time. A write
843 there is a protected write today or it is a gap in
844 :func:`oc_chat.tools._sandbox.is_protected_write_path`, which is where the
845 covered paths are defined; either way the answer is never "a mode said yes".
846 ``test_283_session_mode.py::test_w4d_a_protected_write_still_reads_a_key_in_auto``
847 is the negative control that fails the moment this changes.
848 """
849 func = "_resolve_protected_write_confirmation"
850 if not (isinstance(result, dict) and result.get("gate") == "protected_write"):
851 return result
852
853 from oc_chat.tools import filesystem
854
855 path = result.get("path", "")
856 reason = result.get("reason", "")
857
858 # Plan 279: every approved re-dispatch in this resolver is bracketed. On the
859 # headless path the presenter's spinner hooks are no-ops, so it costs nothing
860 # there and keeps all six sites one shape instead of five.
861 _label = f"running {tc['name']}…"
862
863 if not _stdin_is_interactive():
864 if filesystem._plan_envelope_covers_write(path):
865 _dbg("OC_CHAT.TOOLS", func, f"headless write covered by envelope: {path!r}", 2, override=True)
866 with filesystem.human_confirmed_write(path):
867 _, approved = _run_bracketed(
868 presenter, _label, lambda: _dispatch_tool(tc, tool_registry))
869 return approved
870 _dbg("OC_CHAT.TOOLS", func, f"headless -> protected-write denied (no envelope): {path!r}",
871 2, override=True)
872 return filesystem.deny_protected_write(
873 result, note="No interactive terminal and no signed PlanAutonomyEnvelope (M8.3); denied.")
874
875 # Show a CONTENT preview so the human can inspect WHAT is written (e.g. the
876 # endpoints.toml base_url / API host) — a path-only prompt is a weak guard for the
877 # config-as-code tier. oct_new has no `content` arg (it generates the file), so the
878 # preview is empty there.
879 _content = ""
880 try:
881 _content = str((json.loads(tc.get("arguments") or "{}") or {}).get("content", ""))
882 except (ValueError, TypeError):
883 _content = ""
884 _preview = _content if len(_content) <= 800 else _content[:800] + "\n …[truncated]…"
885 presenter.show_info(
886 f"[confirm] {tc.get('name', 'write')} wants to write '{path}' ({reason})."
887 "\n config-as-code / importable-tree write — a write-then-import can execute "
888 "code in the harness process."
889 + (f"\n --- content ---\n{_preview}\n --- end content ---" if _content else "")
890 )
891 from oc_ui.terminal.input import read_single_key
892 try:
893 answer = read_single_key(f" Allow this write to '{path}'? (y/N) ", valid="yn")
894 except (EOFError, KeyboardInterrupt):
895 return filesystem.deny_protected_write(result, note="Confirmation interrupted; denied (fail-closed).")
896 if answer != "y":
897 _dbg("OC_CHAT.TOOLS", func, f"human declined write: {path!r}", 2, override=True)
898 return filesystem.deny_protected_write(result, note="A human reviewed the write and declined.")
899
900 _dbg("OC_CHAT.TOOLS", func, f"human approved write: {path!r}", 2, override=True)
901 with filesystem.human_confirmed_write(path):
902 _, approved = _run_bracketed(
903 presenter, _label, lambda: _dispatch_tool(tc, tool_registry))
904 return approved
905
906
907def _provider_name(client) -> str:
908 """The active provider's name, or ``""`` when the client exposes none."""
909 return getattr(getattr(client, "provider", None), "name", "") or ""
910
911
912def _tool_error_summary(err: object, rc: object) -> Optional[str]:
913 """Why a tool call failed, or ``None`` when it did not (plan 203 / G10).
914
915 Built from exactly the two failure signals the loop already holds at the
916 completion seam — the ``error`` key and a non-zero ``returncode`` — so
917 the summary can never disagree with the ``outcome`` computed beside it.
918 A successful call yields ``None``, never an empty string — "no error" and
919 "an error with no message" are different facts.
920
921 **Secrets are scrubbed here, and the cap is not what protects them.**
922 A tool error is built from an exception the tool did not sanitise
923 (``oct_tools`` returns ``{"error": str(exc)}`` verbatim), so a failed
924 authenticated operation can quote a credential — a git auth failure
925 naming a token in its remote URL is the concrete case, and it is only
926 ~100 characters, comfortably inside ``ERROR_SUMMARY_MAX_CHARS``. The
927 cap bounds *volume*, not *sensitivity*; without this scrub the token
928 would land verbatim in the durable ``events.jsonl``, which is the
929 designated forensic substrate and the mining corpus. This is the same
930 control ``shell.py`` already applies to command stdout/stderr and to
931 ``str(e)`` at the identical exception→string seam.
932
933 Redaction runs BEFORE the contract's truncation, so a pattern is matched
934 against the whole string rather than a clipped one, and a marker can
935 never be cut in half in a way that resurrects the tail of a token.
936 """
937 from oc_chat.tools._sandbox import redact_secrets # noqa: PLC0415
938
939 if err:
940 return redact_secrets(str(err))
941 if isinstance(rc, int) and rc != 0:
942 # No error string on this branch — the F27 gate-refusal case. The
943 # returncode is not sensitive, but it goes through the same scrub so
944 # the function has one exit contract rather than two.
945 return redact_secrets(f"tool exited with returncode {rc}")
946 return None
947
948
949def _publish_provider_budget(env: dict, provider: str, model: str, *,
950 cache_hit_tokens: int, cache_miss_tokens: int,
951 prompt_tokens: int, completion_tokens: int,
952 at: datetime, session=None) -> None:
953 """Publish ``provider_budget_consumed`` for ONE usage sample (plan 190 T1).
954
955 The STEP-0 audit confirmed plan 188's finding: this kind had zero
956 producers. The data it needs already exists at both ``acc.cost.add``
957 seams, so publication reuses exactly the inputs the accumulator prices
958 — the event and the turn's ``$`` line can never disagree about a sample.
959
960 **The honesty gate (188 T4, inherited).** ``price_usage`` returns
961 ``None`` when the sample cannot be priced. A ``0.0`` must never stand in
962 for "unknown" — it would be summed downstream as real spend.
963
964 **Plan 203 closes 190's loop.** ``cost_usd`` is now ``Optional[float]``
965 in the contract, so an unpriceable sample finally HAS a truthful
966 encoding: we publish the event with ``cost_usd=None``. The tokens — which
967 we do know — reach the stream either way, and the projection counts the
968 sample as unpriced instead of the event vanishing. Silence was the honest
969 answer only while the payload could not say "unknown"; now that it can,
970 silence would itself be the lie, because a consumer could not distinguish
971 "no spend" from "spend we could not price".
972
973 **Plan 202: the pricing instant travels in.** *at* is the sample's ONE
974 captured instant (the send-seam ``_now_utc()`` read) — the same value
975 the accumulator folds, so the event and the turn's ``$`` line can
976 never price the same sample under two clock reads that disagree
977 across the 2026-08-16T16:00Z cutover or a peak-window edge.
978
979 *env* is one ``_ev()`` envelope dict (fresh ``event_id`` per call).
980 """
981 from oc_pricing import rate_at # noqa: PLC0415
982
983 func = "_publish_provider_budget"
984 usd = price_usage(
985 provider, model,
986 cache_hit_tokens=cache_hit_tokens, cache_miss_tokens=cache_miss_tokens,
987 prompt_tokens=prompt_tokens, completion_tokens=completion_tokens,
988 at=at,
989 )
990 if usd is None:
991 _dbg("OC_CHAT.MAIN", func,
992 f"sample unpriceable ({provider}/{model}); publishing cost_usd=None", 3)
993 quote = rate_at(provider, model, at=at) if usd is not None else None
994 audit_model = model if usd is not None else None
995 audit_cache_read = cache_hit_tokens if usd is not None else None
996 audit_cache_miss = cache_miss_tokens if usd is not None else None
997 audit_priced_at = at.isoformat() if usd is not None else None
998 paid_quote = quote if quote is not None and quote.billing_kind != "free" else None
999 rate_effective_from = (
1000 paid_quote.effective_from.isoformat() if paid_quote is not None else None
1001 )
1002 rate_verified_at = paid_quote.verified_at if paid_quote is not None else None
1003 # Plan 237 (CAPS-01 P2): the SESSION fold happens here, off the very value
1004 # this event carries. One pricing source, one number — a second formula over
1005 # tokens would eventually disagree with the event stream, and the figure that
1006 # can stop a session must be the one an auditor can reproduce from the bus.
1007 # ``None`` increments the unpriced count and adds nothing: an unpriceable
1008 # sample is unknown spend, never zero spend.
1009 if session is not None:
1010 try:
1011 session.stats.add_cost_sample(usd)
1012 except AttributeError as exc: # pragma: no cover - a session without stats
1013 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {exc}", 3)
1014 default_bus().publish(make_provider_budget_consumed(
1015 **env, provider_name=provider,
1016 input_tokens=prompt_tokens, output_tokens=completion_tokens,
1017 cost_usd=usd,
1018 model=audit_model,
1019 cache_read_tokens=audit_cache_read,
1020 cache_miss_tokens=audit_cache_miss,
1021 priced_at=audit_priced_at,
1022 rate_effective_from=rate_effective_from,
1023 rate_verified_at=rate_verified_at,
1024 ))
1025
1026
1027def _mini_report(session, acc: "_TurnAccumulator", turn_t0: float):
1028 """Build the plan-237 checkpoint mini report from live turn state."""
1029 from oc_chat.core.session_checkpoints import build_mini_report
1030
1031 cost = session.stats.cost
1032 return build_mini_report(
1033 turn_tool_calls=acc.tool_calls,
1034 tool_name_counts=acc.tool_name_counts,
1035 files_written=acc.files_written,
1036 priced_usd=cost.priced_usd,
1037 unpriced_samples=cost.unpriced_samples,
1038 elapsed_ms=int((time.monotonic() - turn_t0) * 1000),
1039 )
1040
1041
1042def _cost_gate(session, presenter, acc: "_TurnAccumulator", turn_t0: float):
1043 """Evaluate the plan-237 dollar hard cap and checkpoints for this session.
1044
1045 Called after every folded sample (main loop and sub-loop), so the session
1046 stops on the sample that crossed the line rather than one request later.
1047 Returns a ``CostVerdict``; the caller owns every side effect.
1048 """
1049 from oc_chat.core.resource_caps import (
1050 _COST_CEILING_ENV_VAR, resolve_cost_thresholds_usd,
1051 resolve_session_cost_hard_cap_usd,
1052 )
1053 from oc_chat.core.session_checkpoints import evaluate_cost_checkpoints
1054
1055 return evaluate_cost_checkpoints(
1056 session_id=session.session_id,
1057 cost=session.stats.cost,
1058 presenter=presenter,
1059 report=_mini_report(session, acc, turn_t0),
1060 is_interactive=_stdin_is_interactive(),
1061 hard_cap_usd=resolve_session_cost_hard_cap_usd(),
1062 thresholds=resolve_cost_thresholds_usd(),
1063 env_var_name=_COST_CEILING_ENV_VAR,
1064 )
1065
1066
1067def _end_turn_on_cost(session, presenter, reason: str, ev) -> None:
1068 """Close a turn cleanly at a cost bound: say why, in every channel.
1069
1070 The refusal is appended as a normal assistant message BEFORE the loop
1071 breaks, so the transcript is well-formed (no assistant message carrying
1072 tool_calls has been added for this iteration yet, so nothing is orphaned)
1073 and the model reads the reason on the next turn instead of being cut off
1074 without explanation.
1075 """
1076 func = "_end_turn_on_cost"
1077 presenter.show_error(reason)
1078 session.add_assistant(reason)
1079 default_bus().publish(make_assistant_message(**ev(), text=reason, is_final=True))
1080 _dbg("OC_CHAT.MAIN", func, "turn ended by a cost checkpoint", 1, override=True)
1081
1082
1083def _prompt_layer_digests(api_messages: list, tools) -> "tuple[list[str], dict[str, str]]":
1084 """Hash the request layers that are exactly recoverable at the send seam.
1085
1086 Returns ``(layer_ids, layer_hashes)`` for
1087 :func:`~oc_chat.core.events.make_prompt_rendered`. Only layers whose
1088 bytes we can read off the assembled request are hashed — nothing is
1089 inferred:
1090
1091 - ``system_prompt`` — the system message actually sent. In this product
1092 that one message carries the inventory's ``base_system_prompt`` +
1093 ``compact_tool_map`` + environment block concatenated
1094 (:mod:`oc_chat.core.session`), so it is hashed as the single unit it
1095 is on the wire rather than split into layers we cannot separate.
1096 - ``tool_schemas`` — the tool definitions sent alongside, which sit in
1097 the same cached prefix. Omitted entirely when no tools are sent.
1098
1099 Hashes are truncated SHA-256 (16 hex chars): identifiers for change
1100 detection, not a way to recover the prompt text.
1101 """
1102 layer_ids: list[str] = []
1103 layer_hashes: dict[str, str] = {}
1104 system = next((m for m in api_messages if m.get("role") == "system"), None)
1105 if system is not None:
1106 layer_ids.append("system_prompt")
1107 layer_hashes["system_prompt"] = hashlib.sha256(
1108 str(system.get("content", "")).encode("utf-8")
1109 ).hexdigest()[:16]
1110 if tools:
1111 layer_ids.append("tool_schemas")
1112 layer_hashes["tool_schemas"] = hashlib.sha256(
1113 json.dumps(tools, sort_keys=True, default=str).encode("utf-8")
1114 ).hexdigest()[:16]
1115 return layer_ids, layer_hashes
1116
1117
1118def _prompt_version_for(layer_ids: list, layer_hashes: dict) -> str:
1119 """Derive the ``prompt_version`` from the hashed layers (plan 190 T1).
1120
1121 ``make_prompt_rendered`` requires a ``prompt_version``, and the product
1122 declares none — the STEP-0 audit found zero non-test references to the
1123 field anywhere. Rather than stamp a fabricated literal, the version is
1124 *content-addressed*: a digest over the layer digests, so it changes
1125 exactly when a hashed layer changes and is identical across processes
1126 for an identical prefix. That is precisely the property cache
1127 governance needs (synthesis §5).
1128 """
1129 joined = "|".join(f"{lid}={layer_hashes.get(lid, '')}" for lid in layer_ids)
1130 return "sha256:" + hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
1131
1132
1133def _fold_sub_loop_usage(session, acc, turn_stats, client, sub_result,
1134 sub_model: str = "", *, at: datetime) -> None:
1135 """Fold a sub-loop's token spend into parent COST accounting (plan 134 B2).
1136
1137 Cost only: ``session.record_usage`` + the turn accumulator's cost fields +
1138 ``turn_stats`` for the live display. Deliberately does **not** touch
1139 ``acc.max_prompt_in_turn`` (M1) — sub-loop prompt tokens are cost, not
1140 main-loop context, and would inflate C2's ``ctx:%`` gauge.
1141
1142 Plan 188 T4: *sub_model* is the model the sub-loop actually ran on, so its
1143 USD sample is priced at that model's rates rather than the parent's.
1144
1145 Plan 202: *at* is the sub-loop sample's ONE captured pricing instant —
1146 the caller reads ``_now_utc()`` once and hands the same value here and
1147 to ``_publish_provider_budget``, so the fold and the event agree.
1148 """
1149 usage = sub_result.usage
1150 if not usage:
1151 return
1152 session.record_usage(usage)
1153 acc.prompt_tokens += int(usage.get("prompt_tokens", 0) or 0)
1154 acc.completion_tokens += int(usage.get("completion_tokens", 0) or 0)
1155 acc.total_tokens += int(usage.get("total_tokens", 0) or 0)
1156 ptd = usage.get("prompt_tokens_details")
1157 if isinstance(ptd, dict):
1158 acc.prompt_cached += int(ptd.get("cached_tokens", 0) or 0)
1159 sub_cache = client.parse_usage(usage)
1160 acc.cache_hit_tokens += sub_cache.cache_hit_tokens
1161 acc.cache_miss_tokens += sub_cache.cache_miss_tokens
1162 if sub_model:
1163 acc.cost.add(
1164 _provider_name(client), sub_model,
1165 cache_hit_tokens=sub_cache.cache_hit_tokens,
1166 cache_miss_tokens=sub_cache.cache_miss_tokens,
1167 prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
1168 completion_tokens=int(usage.get("completion_tokens", 0) or 0),
1169 at=at,
1170 )
1171 if turn_stats is not None:
1172 turn_stats.add_usage(usage)
1173
1174
1175def _warn_unknown_usage_fields(session, presenter, provider_name, model,
1176 unknown_raw) -> None:
1177 """Warn once per (provider, model, field) about unrecognised usage fields.
1178
1179 Throttle state lives on the **session** (transient, non-persisted) so the
1180 sub-loop needs no session handle and we avoid a process-lifetime module
1181 global (plan 134 N4 — the same anti-pattern A3 deletes). Always-on: a new
1182 usage field is an accounting-integrity signal, not verbose noise.
1183 """
1184 func = "_warn_unknown_usage_fields"
1185 if not unknown_raw:
1186 return
1187 seen = getattr(session, "_warned_usage_fields", None)
1188 if seen is None:
1189 seen = set()
1190 session._warned_usage_fields = seen
1191 for entry in unknown_raw:
1192 field_name = entry.get("field")
1193 key = (provider_name, model, field_name)
1194 if key in seen:
1195 continue
1196 seen.add(key)
1197 _dbg("OC_CHAT.MAIN", func,
1198 f"unknown sub-loop usage field {field_name!r} "
1199 f"(provider={provider_name} model={model}); carried raw, not merged",
1200 1, override=True)
1201 presenter.show_info(
1202 f"[accounting] new usage field '{field_name}' from "
1203 f"{provider_name}/{model} — carried raw, not folded into cost."
1204 )
1205
1206
1207def _schema_tool_name(schema: dict) -> str:
1208 """Tool name from an OpenAI-style ``{"function":{"name":…}}`` or flat schema."""
1209 if not isinstance(schema, dict):
1210 return ""
1211 fn = schema.get("function", schema)
1212 return str(fn.get("name", "")) if isinstance(fn, dict) else ""
1213
1214
1216 tool_registry: dict, tool_schemas: list, mutating: "tuple[str, ...]",
1217) -> "tuple[dict, list]":
1218 """Drop the *mutating* tools from BOTH the registry and the schemas (plan 137
1219 §3.10) so a sub-loop is never even told it has them. Returns new objects; the
1220 inputs are not mutated."""
1221 mut = set(mutating)
1222 reg = {k: v for k, v in tool_registry.items() if k not in mut}
1223 schemas = [s for s in (tool_schemas or []) if _schema_tool_name(s) not in mut]
1224 return reg, schemas
1225
1226
1227#: M8.0 WU-9c / F35 — the sub-loop's deny-by-default POSITIVE allowlist (least
1228#: authority): only pure read / list / search / recall tools. Everything else —
1229#: ``fetch_url``/``web_search`` (egress), ``run_command`` (exec), ``write_file``/
1230#: ``remember`` (mutation), every ``oct_*`` (subprocess), plan mutation, and any
1231#: future tool — is denied by default. A subtractive filter would leak a newly added
1232#: tool to the sub-loop; this does not (F10/F35).
1233_SUBLOOP_ALLOWED_TOOLS: frozenset[str] = frozenset({
1234 "read_file", "read_chunks", "file_info", "list_dir", "search_files", "text_search", "recall",
1235})
1236
1237
1239 tool_registry: dict, tool_schemas: list,
1240) -> "tuple[dict, list]":
1241 """Restrict a sub-loop to the :data:`_SUBLOOP_ALLOWED_TOOLS` positive allowlist
1242 (deny-by-default). Returns new ``(registry, schemas)``; the inputs are unchanged."""
1243 reg = {k: v for k, v in tool_registry.items() if k in _SUBLOOP_ALLOWED_TOOLS}
1244 schemas = [s for s in (tool_schemas or []) if _schema_tool_name(s) in _SUBLOOP_ALLOWED_TOOLS]
1245 return reg, schemas
1246
1247
1249 session: "ChatSession",
1250 acc: "_TurnAccumulator",
1251 vcfg: "VerificationConfig",
1252 presenter: "Presenter",
1253 baseline: object | None,
1254) -> None:
1255 """Plan 137 §3.2/§3.4 — the post-mutating-dispatch additive verification hook.
1256
1257 Caller has already confirmed ``vcfg.enabled``. Runs the deterministic sensor on
1258 the turn's CUMULATIVE ``changed_files`` (capped at ``max_runs_per_turn``), and on
1259 NEW findings appends one compacted ``[verification]`` user message to the volatile
1260 tail (never ``messages[0]``/``[1]``). Degraded ⇒ a one-line note, never a block.
1261 Fully additive: it only appends; it never alters control flow.
1262 """
1263 func = "_maybe_run_verification"
1264 if acc.verification_runs >= vcfg.max_runs_per_turn:
1265 _dbg("OC_CHAT.MAIN", func, f"per-turn cap reached ({vcfg.max_runs_per_turn})", 3)
1266 return
1267 if not acc.changed_files:
1268 return
1269 from oc_chat.core.verification import run_verification, render_observation
1270 acc.verification_runs += 1
1271 result = run_verification(sorted(acc.changed_files), baseline, vcfg, presenter=presenter)
1272 if result.findings:
1273 acc.pending_findings = result
1274 session.messages.append({
1275 "role": "user",
1276 "content": render_observation(result, vcfg.max_findings),
1277 })
1278 session.dirty = True
1279 _dbg("OC_CHAT.MAIN", func,
1280 f"injected {len(result.findings)} new finding(s) over {len(acc.changed_files)} file(s)", 2)
1281 elif result.degraded:
1282 presenter.show_info(f"[verification] skipped/degraded: {result.skip_reason or 'unavailable'}")
1283 _dbg("OC_CHAT.MAIN", func, f"degraded: {result.skip_reason}", 3)
1284 else:
1285 _dbg("OC_CHAT.MAIN", func, "clean — no new findings", 3)
1286
1287
1289 session: "ChatSession",
1290 client: "ClientInterface",
1291 presenter: "Presenter",
1292 model_manager: "ModelManager",
1293 tool_registry: dict,
1294 tool_schemas: list,
1295 ctx: "Context | None" = None,
1296 turn_stats: "TurnStats | None" = None,
1297 enhanced_presenter: "EnhancedPresenter | None" = None,
1298 cancellation: "CancellationToken | None" = None,
1299) -> None:
1300 """Execute one user-turn through up to ``MAX_REACT_ITERATIONS`` ReAct cycles.
1301
1302 Multiple tool calls in a single model response are executed in
1303 parallel. Captures usage data and records it on the session.
1304 Shows per-iteration usage and a final aggregated turn summary.
1305
1306 If *ctx* is provided and ``ctx.in_goal`` is ``True``, file-modifying
1307 tool calls (``write_file``) are recorded in ``ctx.goal_files_changed``
1308 for later changelog updates.
1309
1310 If *turn_stats* is provided (a :class:`TurnStats`), its counters are
1311 updated so the prompt's rprompt can show live token counts.
1312 """
1313 func = "run_turn"
1314 # Plan 147 D1: the summary-ordering pin — show_turn_summary's duration is
1315 # computed directly from this turn-start monotonic timestamp at call
1316 # time, independent of TurnStats.finish_turn (called after, below).
1317 _turn_t0 = time.monotonic()
1318 # ── Turn-level accumulator ─────────────────────────────────────────────
1319 acc = _TurnAccumulator()
1320 from oc_chat.core.stall_detector import StallDetector
1321 stall_detector = StallDetector.from_defaults()
1322
1323 # ── M2.3: event-stream instrumentation ─────────────────────────────────
1324 # turn_id groups the turn's events and is the synthetic non-None parent the
1325 # contract requires (a dedicated session-root event remains future work).
1326 # sid is the session's bound, persistent identity (M2.5 — the _session_id_for
1327 # shim has been retired). _ev() supplies the shared envelope kwargs; each
1328 # emission still names its make_* factory explicitly (ADR-3).
1329 # user_input_received fires at turn entry, before any client call (ADR-5).
1330 turn_id = new_event_id()
1331 sid = session.session_id
1332
1333 def _ev() -> dict:
1334 return {"event_id": new_event_id(), "parent_event_id": turn_id,
1335 "session_id": sid, "ts": _now_iso(), "turn_id": turn_id}
1336
1337 default_bus().publish(make_user_input_received(
1338 **_ev(), text=_extract_last_user_task(session.messages)))
1339
1340 # Phase O2: rebind the client to the active endpoint at turn start so
1341 # /endpoint use <name> takes effect on the next user turn. Guarded
1342 # so tests / callers without an endpoint registry are unaffected.
1343 _ep_registry = getattr(ctx, "endpoint_registry", None) if ctx is not None else None
1344 if _ep_registry is not None and _ep_registry.entries:
1345 try:
1346 client.rebind_to(_ep_registry.active())
1347 except KeyError:
1348 # Empty registry caught here defensively; the .entries guard
1349 # above should already prevent it. Continue with current client.
1350 pass
1351
1352 if turn_stats is not None:
1353 turn_stats.start_turn()
1354
1355 react_cap = session.react_cap()
1356 _dbg("OC_CHAT.MAIN", func, "turn START", 2)
1357
1358 # M8.0 F67: the external-content trust set is session-scoped. Re-bind it to the
1359 # active session each turn so a /session new|load|switch (which changes the
1360 # session between turns) starts with an empty trust set — trust never leaks
1361 # across sessions in one process.
1362 from oc_chat.core.external_content_gate import bind_active_session
1363 bind_active_session(session)
1364
1365 presenter.start_turn()
1366
1367 ensure_system_includes_tool_reference(session, tool_schemas)
1368
1369 # ── Plan 137: verification backpressure gate (off ⇒ no new code path) ───────
1370 # Resolve the typed config once per turn. When disabled, _verif_enabled is
1371 # False and neither the changed-files tracking nor the sensor hook below runs,
1372 # so run_turn is byte-identical to pre-137. The baseline is captured lazily on
1373 # the first enabled turn (≈ session start — nothing has mutated yet) and cached
1374 # on the session instance for reuse.
1375 from oc_chat.core.settings import runtime_settings, runtime_mode, verification_config
1376 _vcfg = verification_config(runtime_settings(), runtime_mode())
1377 _verif_enabled = _vcfg.enabled
1378 _verif_baseline = None
1379 if _verif_enabled and _vcfg.baseline:
1380 _verif_baseline = getattr(session, "_verification_baseline", None)
1381 if _verif_baseline is None:
1382 from oc_chat.core.verification import capture_baseline
1383 _bspin = presenter.spinner_start("capturing verification baseline…")
1384 try:
1385 _verif_baseline = capture_baseline(_vcfg)
1386 finally:
1387 presenter.spinner_stop(_bspin)
1388 try:
1389 session._verification_baseline = _verif_baseline
1390 except (AttributeError, TypeError):
1391 pass # session forbids dynamic attrs — proceed with an unsaved baseline
1392
1393 sub_loops_this_turn = 0 # plan 132: per-turn offload backstop
1394
1395 # Clear any stale cooperative tool-cancel flag from a prior turn so tools in
1396 # this turn start runnable; a Ctrl+C during this turn re-sets it.
1397 reset_tool_cancel()
1398
1399 def _confirm_stop() -> bool:
1400 # Ask-before-cancelling (operator decision 2026-08-12, 188-T5 v2):
1401 # press 1 cancelled NOTHING — the handler only requested this
1402 # checkpoint — so "n" genuinely resumes with the in-flight tool's
1403 # result intact. 'y' stops; a non-interactive interrupt (no TTY)
1404 # stops, never loops.
1405 import sys as _sys
1406 from oc_ui.terminal.input import read_single_key
1407
1408 if not _sys.stdin.isatty():
1409 return True
1410 return read_single_key("\nStop the current action? (y/N) ", valid="yn") == "y"
1411
1412 for _iteration in range(react_cap):
1413 # Two-stage Ctrl+C: a first press set ``interrupt_requested``; confirm it
1414 # here at the iteration boundary (no Live region active). A hard cancel
1415 # (second press) raises CancellationError straight through.
1416 confirm_or_continue(cancellation, _confirm_stop)
1417
1418 # Plan 237 (CAPS-01 P4): the hard cap is a PRE-FLIGHT, not only a
1419 # post-fold check. A session that reached the cap on its previous turn
1420 # must make no provider call on this one either — "no further provider
1421 # call is made this session" is the whole promise, and it has to survive
1422 # a turn boundary. Priced spend only; the refusal says so.
1423 _precheck = _cost_gate(session, presenter, acc, _turn_t0)
1424 if _precheck.stop:
1425 _end_turn_on_cost(session, presenter, _precheck.reason, _ev)
1426 break
1427
1428 t0 = time.monotonic()
1429
1430 route = resolve_iteration_routing(
1431 session_model=session.model,
1432 session_reasoning_effort=session.reasoning_effort,
1433 model_manager=model_manager,
1434 iteration=_iteration,
1435 is_finalize_nudge=(_iteration == react_cap - 1),
1436 messages=session.messages,
1437 token_profile=getattr(session, "token_profile", "balanced") or "balanced",
1438 )
1439
1440 _dbg("OC_CHAT.MAIN", func,
1441 f"iter {_iteration}/{react_cap} model={route.model} effort={route.effort} max_tokens={route.max_tokens}", 3)
1442
1443 caps = model_manager.capabilities(route.model)
1444 tools = tool_schemas if (tool_registry and caps.get("supports_tools", True)) else None
1445
1446 tool_calls_this_iter: list = []
1447 full_content = ""
1448 full_reasoning = ""
1449 last_usage = None
1450 # None == the provider did not report one. Absence is "unknown", never
1451 # "stop" — only an explicit "length" proves a max_tokens truncation.
1452 finish_reason: str | None = None
1453
1454 api_messages = messages_for_api(
1455 session.messages,
1456 session_name=session.name,
1457 inject_finalize_nudge=(_iteration == react_cap - 1),
1458 model=route.model,
1459 )
1460
1461 # ── Sub-loop delegation ──────────────────────────────────
1462 # Enhanced sub-loop triggering logic with task complexity analysis
1463 should_use_sub = should_use_sub_loop(
1464 task=_extract_last_user_task(session.messages),
1465 iteration_count=_iteration,
1466 recent_messages=session.messages[-10:] # Last 10 messages for analysis
1467 )
1468
1469 # Check for response repetition bug (E4) and trigger context refresh if needed
1470 is_repeating = _detect_response_repetition(session.messages)
1471
1472 # Plan 132: route.would_switch_model is NO LONGER a sub-loop trigger.
1473 # The cheap-tier signal kept reclassifying the same stale read batch
1474 # into an offload every iteration (sub-loop results are injected as
1475 # user messages, so last_assistant_tool_names never advances), causing
1476 # the iter-0→119 infinite offload in terminal-out-01.md. The sub-loop
1477 # now fires ONLY on the guarded should_use_sub_loop() or is_repeating,
1478 # and is hard-capped per turn. The would_switch_model / cheap_model
1479 # fields remain (step_routing unchanged) for a future targeted-offload
1480 # redesign — see the living ROADMAP-EXTENDED.md backlog (read-size/
1481 # intent-driven, opt-in). DO NOT
1482 # re-add would_switch_model here without that redesign + a re-trigger guard.
1483 # Plan 134 interim model-lock: sub-loops run on the SESSION model, not
1484 # route.cheap_model — no silent flash usage until the targeted, opt-in
1485 # cheap-model offload redesign lands (which stays deferred). This is the
1486 # one-line interim; route.cheap_model is intentionally not used here now.
1487 sub_model = session.model
1488 wants_sub_loop = should_use_sub or is_repeating
1489 if wants_sub_loop and sub_loops_this_turn >= _MAX_SUB_LOOPS_PER_TURN:
1490 presenter.show_info(
1491 f"[sub-loop] iter={_iteration}: offload suppressed "
1492 f"(per-turn cap {_MAX_SUB_LOOPS_PER_TURN} reached)"
1493 )
1494 _dbg("OC_CHAT.MAIN", func,
1495 f"sub-loop suppressed at iter {_iteration}: per-turn cap reached", 2)
1496 if wants_sub_loop and sub_loops_this_turn < _MAX_SUB_LOOPS_PER_TURN:
1497 sub_loops_this_turn += 1
1498 presenter.show_info(
1499 f"[sub-loop] iter={_iteration}: running sub-loop on {sub_model} "
1500 f"(complexity={should_use_sub} repetition={is_repeating})"
1501 )
1502 _dbg("OC_CHAT.MAIN", func,
1503 f"triggering sub-loop on {sub_model} complexity_task={should_use_sub} repetition={is_repeating}", 2)
1504 # Extract the most recent user request as the task
1505 sub_task = _extract_last_user_task(session.messages)
1506 if not sub_task:
1507 sub_task = "Complete the assigned task using available tools."
1508
1509 # If we're repeating, add context refresh instruction
1510 if is_repeating:
1511 # OCT-LINT: disable=no_hardcoded_secrets reason="Static event labels, prompts, and model-routing strings are not secrets"
1512 sub_task += "\n\n[CONTEXT_REFRESH: The previous responses were too similar. Please provide a different perspective or approach.]"
1513
1514 # Integrate enhanced UI into react_loop for real-time updates
1515 if enhanced_presenter is not None:
1516 enhanced_presenter.show_sub_loop_trigger(sub_model, sub_task)
1517
1518 # Update enhanced UI statistics
1519 if hasattr(enhanced_presenter, 'get_stats_tracker'):
1520 stats_tracker = enhanced_presenter.get_stats_tracker()
1521 if stats_tracker:
1522 # Prefer API-reported prompt_tokens; fall back to estimate
1523 api_prompt = last_usage.get("prompt_tokens", 0) if last_usage else 0
1524 if api_prompt > 0:
1525 stats_tracker.update_context_usage(api_prompt)
1526 else:
1527 estimated_tokens = _estimate_tokens(session.messages)
1528 stats_tracker.update_context_usage(estimated_tokens)
1529
1530 # Update turn statistics. Plan 147 D1: no duration_ms here —
1531 # last_usage['dur_ms'] is never populated by any provider
1532 # (the dead always-zero path this removes); StatisticsTracker's
1533 # duration_ms param defaults to 0 and is unused by any reader.
1534 prompt_tokens = last_usage.get('prompt_tokens', 0) if last_usage else 0
1535 completion_tokens = last_usage.get('completion_tokens', 0) if last_usage else 0
1536 cache_hits = last_usage.get('cache_hit_tokens', 0) if last_usage else 0
1537 tool_calls = len(tool_calls_this_iter) if tool_calls_this_iter else 0
1538
1539 stats_tracker.update_turn_stats(
1540 prompt_tokens=prompt_tokens,
1541 completion_tokens=completion_tokens,
1542 cache_hits=cache_hits,
1543 tool_calls=tool_calls
1544 )
1545
1546 # ── M8.0 WU-9c / F35: least-authority sub-loop surface ─────────────
1547 # The sub-loop ALWAYS gets a deny-by-default POSITIVE allowlist (pure
1548 # read/list/search/recall) — independent of the verification config and
1549 # strictly stronger than the old subtractive read-only filter: it never
1550 # leaks fetch_url/run_command/oct_*/write_file/remember (or a newly added
1551 # tool) to a sub-agent whose result re-enters as model_generated.
1552 _sub_registry, _sub_schemas = _subloop_positive_allowlist(
1553 tool_registry, tool_schemas or [])
1554 sub_result = run_sub_loop(
1555 client=client,
1556 task=sub_task,
1557 model=sub_model,
1558 tool_registry=_sub_registry,
1559 tool_schemas=_sub_schemas,
1560 session_name=session.name,
1561 presenter=presenter, # Phase H.4a-iii: wire spinner through to sub-loop
1562 parent_event_id=turn_id, parent_session_id=sid, # M2.4: sub-loop event chain
1563 )
1564 # Inject sub-loop result as a user message so the main loop can
1565 # reason over it next iteration (user role avoids the tool-role
1566 # API validation error). Bound it at the MAIN model's tool-result
1567 # cap (plan 134 A4) — it's a user message, so api_context's
1568 # tool/assistant truncation never touches it; this is the only
1569 # bound. On truncation, point at the archived full transcript.
1570 # M8.0 WU-9 review fix (F46/F51): the sub-loop's synthesized text re-enters
1571 # the deciding context as a user message — redact secret patterns at that
1572 # boundary (a sub-loop tool like recall can surface unredacted memory).
1573 from oc_chat.tools._sandbox import redact_secrets
1574 sub_text = redact_secrets(sub_result.text)
1575 _sub_cap = resolve_tool_result_cap(session.model)
1576 if len(sub_text) > _sub_cap:
1577 if sub_result.artifact_path:
1578 _marker = (
1579 f"\n[OUTPUT_TRUNCATED at {_sub_cap} chars; full sub-loop "
1580 f"transcript archived at {sub_result.artifact_path}]"
1581 )
1582 else:
1583 _marker = f"\n[OUTPUT_TRUNCATED at {_sub_cap} chars]"
1584 sub_text = sub_text[:_sub_cap] + _marker
1585 session.messages.append({
1586 "role": "user",
1587 "content": f"[Sub-loop result]\n{sub_text}",
1588 })
1589
1590 # Update enhanced UI statistics if available
1591 if hasattr(presenter, 'get_stats_tracker'):
1592 stats_tracker = presenter.get_stats_tracker()
1593 if stats_tracker:
1594 # Prefer API-reported prompt_tokens; fall back to estimate
1595 api_prompt = last_usage.get("prompt_tokens", 0) if last_usage else 0
1596 if api_prompt > 0:
1597 stats_tracker.update_context_usage(api_prompt)
1598 else:
1599 estimated_tokens = _estimate_tokens(session.messages)
1600 stats_tracker.update_context_usage(estimated_tokens)
1601
1602 # Update turn statistics. Plan 147 D1: no duration_ms here —
1603 # last_usage['dur_ms'] is never populated by any provider
1604 # (the dead always-zero path this removes); StatisticsTracker's
1605 # duration_ms param defaults to 0 and is unused by any reader.
1606 prompt_tokens = last_usage.get('prompt_tokens', 0) if last_usage else 0
1607 completion_tokens = last_usage.get('completion_tokens', 0) if last_usage else 0
1608 cache_hits = last_usage.get('cache_hit_tokens', 0) if last_usage else 0
1609 tool_calls = len(tool_calls_this_iter) if tool_calls_this_iter else 0
1610
1611 stats_tracker.update_turn_stats(
1612 prompt_tokens=prompt_tokens,
1613 completion_tokens=completion_tokens,
1614 cache_hits=cache_hits,
1615 tool_calls=tool_calls
1616 )
1617 # Fold the sub-loop's real token spend into parent COST + live
1618 # display (B2) — not just a tool-call tick. Cost only; the ctx:%
1619 # gauge stays main-loop-only (M1, handled inside the helper).
1620 acc.tool_calls += 1
1621 # Plan 202 decision 3: ONE pricing instant for the sub-loop
1622 # sample — the fold below and the publish share it, so the two
1623 # cannot disagree across a tariff/band boundary.
1624 at_sub = _now_utc()
1625 _fold_sub_loop_usage(session, acc, turn_stats, client, sub_result,
1626 sub_model=sub_model, at=at_sub)
1627 # Plan 190 T1: a sub-loop's spend is a sample too. Published at
1628 # the CALL SITE (not inside the fold helper) so that helper stays
1629 # pure accounting, and guarded on sub_model exactly as the fold's
1630 # own pricing is — an unknown sub-model is unpriceable, not free.
1631 if sub_model and sub_result.usage:
1632 _sub_cache = client.parse_usage(sub_result.usage)
1634 _ev(), _provider_name(client), sub_model,
1635 cache_hit_tokens=_sub_cache.cache_hit_tokens,
1636 cache_miss_tokens=_sub_cache.cache_miss_tokens,
1637 prompt_tokens=int(sub_result.usage.get("prompt_tokens", 0) or 0),
1638 completion_tokens=int(sub_result.usage.get("completion_tokens", 0) or 0),
1639 at=at_sub, session=session,
1640 )
1641 # Plan 237: a sub-loop's spend is session spend, so the same bounds
1642 # apply to it. Evaluated here rather than only on the main-loop
1643 # sample, or a turn that offloads its work would spend past the cap
1644 # without ever being asked.
1645 _sub_verdict = _cost_gate(session, presenter, acc, _turn_t0)
1646 if _sub_verdict.stop:
1647 _end_turn_on_cost(session, presenter, _sub_verdict.reason, _ev)
1648 break
1650 session, presenter, _provider_name(client), sub_model,
1651 sub_result.unknown_usage_raw)
1652 _dbg("OC_CHAT.MAIN", func,
1653 f"sub-loop returned {len(sub_result.text)} chars "
1654 f"(iters={sub_result.iterations} tools={sub_result.tool_calls})", 2)
1655 continue # next main-loop iteration uses sub-loop result
1656
1657 # Merge provider-translated generation options into extra_body
1658 _extra_body = route.extra_body
1659 if ctx is not None and ctx.generation_options is not None:
1660 _gen_opts = client.provider.translate_generation_options(ctx.generation_options)
1661 if _gen_opts:
1662 _extra_body = {**(_extra_body or {}), **_gen_opts}
1663
1664 # Plan 190 T1: prompt_rendered at the SEND seam, not at the
1665 # messages_for_api() call above — the sub-loop branch assembles a
1666 # request it then discards, and an event claiming that prompt was
1667 # rendered for the provider would be false. Here the request is
1668 # final (messages + tools + model) and about to go out.
1669 _layer_ids, _layer_hashes = _prompt_layer_digests(api_messages, tools)
1670 default_bus().publish(make_prompt_rendered(
1671 **_ev(),
1672 prompt_version=_prompt_version_for(_layer_ids, _layer_hashes),
1673 layer_ids=_layer_ids, layer_hashes=_layer_hashes,
1674 ))
1675
1676 # Plan 202 decision 3: THIS iteration's pricing instant, captured
1677 # ONCE at the send seam. The usage that comes back from this
1678 # request is priced at the tariff in force now — by both the
1679 # accumulator fold and the budget event below, which must never
1680 # read the clock again (two reads could straddle the live
1681 # 2026-08-16T16:00Z cutover or a peak-window edge and disagree).
1682 at_sample = _now_utc()
1683
1684 try:
1685 with presenter.turn():
1686 presenter.show_waiting()
1687 for chunk_type, data in client.stream_chat(
1688 api_messages,
1689 route.model,
1690 effort=route.effort,
1691 extra_body=_extra_body,
1692 tools=tools,
1693 max_tokens=route.max_tokens,
1694 cancellation=cancellation,
1695 ):
1696 if chunk_type == "reasoning":
1697 full_reasoning += data
1698 presenter.show_reasoning(data)
1699 elif chunk_type == "content":
1700 full_content += data
1701 presenter.show_content(data)
1702 elif chunk_type == "tool_call":
1703 tool_calls_this_iter.append(data)
1704 elif chunk_type == "usage":
1705 last_usage = data
1706 elif chunk_type == "finish_reason":
1707 finish_reason = data
1708 except (KeyboardInterrupt, CancellationError):
1709 # User pressed Ctrl+C (KeyboardInterrupt) or cooperative
1710 # cancellation was signalled (CancellationError). Record
1711 # partial cost so session totals stay accurate.
1712 _dbg("OC_CHAT.MAIN", func,
1713 "stream cancelled by user (Ctrl+C)", 2, override=True)
1714
1715 if last_usage is not None:
1716 # API-confirmed usage chunk arrived before the interrupt
1717 partial_usage = last_usage
1718 cost_label = "exact"
1719 else:
1720 # Estimate via Phase P.1a tokenizer; cl100k_base is close
1721 # enough to DeepSeek tokenization for cost estimation.
1722 from oc_chat.core.tokenizer import count_message_tokens, count_tokens
1723 est_prompt = count_message_tokens(api_messages)
1724 est_completion = (
1725 count_tokens(full_content) + count_tokens(full_reasoning)
1726 )
1727 partial_usage = {
1728 "prompt_tokens": est_prompt,
1729 "completion_tokens": est_completion,
1730 "total_tokens": est_prompt + est_completion,
1731 "_estimated": True,
1732 }
1733 cost_label = "approx"
1734
1735 session.record_usage(partial_usage)
1736 if turn_stats is not None:
1737 turn_stats.add_usage(partial_usage)
1738
1739 # Keep prompt-token tracker in sync so compression heuristics
1740 # still trigger correctly on the next turn.
1741 pt = int(partial_usage.get("prompt_tokens") or 0)
1742 session.last_turn_max_prompt_tokens = max(
1743 int(getattr(session, "last_turn_max_prompt_tokens", 0) or 0), pt
1744 )
1745 acc.prompt_tokens += int(partial_usage.get("prompt_tokens", 0))
1746 acc.completion_tokens += int(partial_usage.get("completion_tokens", 0))
1747 acc.total_tokens += int(partial_usage.get("total_tokens", 0))
1748 acc.max_prompt_in_turn = max(acc.max_prompt_in_turn, pt)
1749
1750 p = partial_usage.get("prompt_tokens", 0)
1751 c = partial_usage.get("completion_tokens", 0)
1752 presenter.show_info(
1753 f"Cancelled. ({cost_label} cost: {p} prompt + {c} completion tokens)"
1754 )
1755
1756 # M2.3: terminal error event before re-raise (ADR-4).
1757 default_bus().publish(make_error(
1758 **_ev(), error_summary="cancelled: stream cancelled by user"))
1759 # Re-raise so the REPL backstop pops the orphaned user message
1760 raise
1761 except Exception as e:
1762 # SYSTEM_ERROR — surfacing stream/transport failure with override
1763 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: {type(e).__name__}: {e}", 1, override=True)
1764 # M2.3: terminal error event before re-raise (ADR-4).
1765 default_bus().publish(make_error(
1766 **_ev(), error_summary=f"{type(e).__name__}: {e}"))
1767 raise
1768
1769 acc.iterations += 1
1770
1771 # ── Truncation telemetry ───────────────────────────────────────────
1772 # "length" is the provider saying it stopped because the completion
1773 # budget ran out, not because the model was finished. Logged at L1
1774 # with the routing that produced it so the caps can be sized from
1775 # real hit-rates instead of guessed. A truncated tool-call batch gets
1776 # a notice here because the failure surfaces later as an opaque
1777 # "invalid JSON" tool error the operator can't otherwise explain.
1778 if finish_reason == "length":
1779 _dbg("OC_CHAT.MAIN", func,
1780 f"TRUNCATED at max_tokens: iter={_iteration} model={route.model} "
1781 f"effort={route.effort} max_tokens={route.max_tokens} "
1782 f"content={len(full_content)}c reasoning={len(full_reasoning)}c "
1783 f"tool_calls={len(tool_calls_this_iter)}", 1, override=True)
1784 if tool_calls_this_iter:
1785 presenter.show_info(TRUNCATED_TOOL_CALL_NOTICE.format(
1786 cap=route.max_tokens, n=len(tool_calls_this_iter)))
1787
1788 # ── Per-iteration usage stats ──────────────────────────────────────
1789 usage_stats = None # default; set below
1790 if last_usage:
1791 presenter.show_usage(last_usage)
1792 session.record_usage(last_usage)
1793 # Accumulate turn-level totals
1794 acc.prompt_tokens += last_usage.get("prompt_tokens", 0)
1795 acc.completion_tokens += last_usage.get("completion_tokens", 0)
1796 acc.total_tokens += last_usage.get("total_tokens", 0)
1797 if last_usage.get("prompt_tokens_details"):
1798 pd = last_usage["prompt_tokens_details"]
1799 if isinstance(pd, dict):
1800 acc.prompt_cached += pd.get("cached_tokens", 0)
1801
1802 # Provider-aware cache stats: DeepSeek's parse_usage uses the
1803 # generic helper; future providers can override.
1804 usage_stats = client.parse_usage(last_usage)
1805 perf = client.parse_performance(last_usage)
1806 if not perf.is_default():
1807 acc.perf_stats = perf
1808 _dbg("OC_CHAT.CLIENT", func,
1809 f"usage: prompt={last_usage.get('prompt_tokens', 0)} "
1810 f"completion={last_usage.get('completion_tokens', 0)} "
1811 f"cache_hit={usage_stats.cache_hit_tokens} "
1812 f"cache_miss={usage_stats.cache_miss_tokens}", 3)
1813 acc.cache_hit_tokens += usage_stats.cache_hit_tokens
1814 acc.cache_miss_tokens += usage_stats.cache_miss_tokens
1815
1816 # Plan 188 T4: price THIS sample at the model that produced it —
1817 # and (plan 202) at the ONE instant captured at its send seam.
1818 acc.cost.add(
1819 _provider_name(client), route.model,
1820 cache_hit_tokens=usage_stats.cache_hit_tokens,
1821 cache_miss_tokens=usage_stats.cache_miss_tokens,
1822 prompt_tokens=int(last_usage.get("prompt_tokens") or 0),
1823 completion_tokens=int(last_usage.get("completion_tokens") or 0),
1824 at=at_sample,
1825 )
1826 # Plan 190 T1: the same sample on the bus (cost_usd=None if
1827 # unpriceable) — same tokens, same model, same at_sample.
1829 _ev(), _provider_name(client), route.model,
1830 cache_hit_tokens=usage_stats.cache_hit_tokens,
1831 cache_miss_tokens=usage_stats.cache_miss_tokens,
1832 prompt_tokens=int(last_usage.get("prompt_tokens") or 0),
1833 completion_tokens=int(last_usage.get("completion_tokens") or 0),
1834 at=at_sample, session=session,
1835 )
1836 # Plan 237: evaluate the money bounds on the sample that just
1837 # landed. This runs BEFORE this iteration's tool calls are appended
1838 # to history, so a stop here leaves no assistant message carrying
1839 # unanswered tool_calls — the transcript stays well-formed.
1840 _verdict = _cost_gate(session, presenter, acc, _turn_t0)
1841 if _verdict.stop:
1842 _end_turn_on_cost(session, presenter, _verdict.reason, _ev)
1843 break
1844
1845 pt = int(last_usage.get("prompt_tokens") or 0)
1846 acc.max_prompt_in_turn = max(acc.max_prompt_in_turn, pt)
1847
1848 # Update thread-safe turn stats for live rprompt
1849 if turn_stats is not None:
1850 turn_stats.add_usage(last_usage)
1851
1852
1853 # ── Per-iteration stat emission (G.6.D) ───────────────────────
1854 dur_ms = int((time.monotonic() - t0) * 1000)
1855 _u = last_usage or {}
1856 _cache_hit = usage_stats.cache_hit_tokens if usage_stats else 0
1857 _cache_miss = usage_stats.cache_miss_tokens if usage_stats else 0
1858 _now = datetime.now().astimezone()
1859 _ts = _now.isoformat(timespec='milliseconds')
1860 stat = IterationStat(
1861 ts=_ts,
1862 iter=_iteration,
1863 model=route.model,
1864 effort=route.effort or "",
1865 tools=tuple(tc["name"] for tc in tool_calls_this_iter),
1866 prompt_tokens=_u.get("prompt_tokens", 0),
1867 completion_tokens=_u.get("completion_tokens", 0),
1868 cache_hit_tokens=_cache_hit,
1869 cache_miss_tokens=_cache_miss,
1870 dur_ms=dur_ms,
1871 )
1872 if getattr(session, 'show_iteration_stats', True):
1873 presenter.show_iteration_stat(stat)
1874 if session.iteration_stats_file is not None:
1875 try:
1876 _tools_csv = ",".join(stat.tools) if stat.tools else "-"
1877 session.iteration_stats_file.write(
1878 f'{stat.ts},{stat.iter},{stat.model},{stat.effort},{_tools_csv},'
1879 f'{stat.prompt_tokens},{stat.completion_tokens},'
1880 f'{stat.cache_hit_tokens},{stat.cache_miss_tokens},{stat.dur_ms}\n'
1881 )
1882 session.iteration_stats_file.flush()
1883 except Exception:
1884 pass # best-effort
1885
1886 if tool_calls_this_iter:
1887 _dbg("OC_CHAT.TOOLS", func,
1888 f"dispatching {len(tool_calls_this_iter)} tool(s): "
1889 f"{[tc['name'] for tc in tool_calls_this_iter]}", 2)
1890 # M8.0 WU-9a (Appendix B / F38): per-turn tool-call cap. Calls beyond the
1891 # budget are DENIED (not executed) but still answered, so every requested
1892 # tool_call_id gets a result and the API pairing stays well-formed.
1893 from oc_chat.core.resource_caps import (
1894 partition_call_budget,
1895 resolve_max_tool_calls_per_turn,
1896 )
1897 _tool_cap = resolve_max_tool_calls_per_turn() # absolute OOB ceiling; a settings write may lower, never raise (F33)
1898 # Plan 237 (CAPS-01 P1): the count is now a CHECKPOINT, not a wall.
1899 # The effective budget is the resolved cap plus one cap-width per
1900 # extension the operator has already bought this turn, so the next
1901 # pause lands at 200, 300, … With no extension (the headless path,
1902 # and every turn before the first checkpoint) this IS ``_tool_cap``
1903 # and every line below behaves exactly as it did at BASE.
1904 _effective_cap = _tool_cap * (1 + acc.cap_extensions)
1905 if acc.tool_calls + len(tool_calls_this_iter) > _effective_cap:
1906 from oc_chat.core.session_checkpoints import tool_call_checkpoint
1907 if tool_call_checkpoint(
1908 presenter=presenter,
1909 report=_mini_report(session, acc, _turn_t0),
1910 effective_cap=_effective_cap,
1911 is_interactive=_stdin_is_interactive(),
1912 session_id=session.session_id,
1913 ):
1914 acc.cap_extensions += 1
1915 _effective_cap = _tool_cap * (1 + acc.cap_extensions)
1916 _dbg("OC_CHAT.TOOLS", func,
1917 f"per-turn tool-call budget extended to {_effective_cap}", 2)
1918 _allowed_calls, _denied_calls = partition_call_budget(
1919 acc.tool_calls, len(tool_calls_this_iter), cap=_effective_cap)
1920 # Plan 246: completed observations make the NEXT identical proposal
1921 # occurrence N. Deny it and the remaining batch tail before dispatch.
1922 from oc_chat.core.stall_detector import stall_sentence
1923 _stall_denied_indices: set[int] = set()
1924 _stall_hit = None
1925 for _stall_idx, _stall_tc in enumerate(tool_calls_this_iter):
1926 if _stall_idx >= _allowed_calls:
1927 break
1928 _stall = stall_detector.check_proposal(
1929 _stall_tc["name"], _safe_tool_args(_stall_tc),
1930 )
1931 if _stall is not None:
1932 _stall_denied_indices.update(
1933 range(_stall_idx, _allowed_calls),
1934 )
1935 _stall_hit = (
1936 _stall_tc,
1937 _stall,
1938 stall_sentence(_stall),
1939 )
1940 break
1941 if _denied_calls:
1942 _dbg("OC_CHAT.TOOLS", func,
1943 f"per-turn tool-call cap: {_denied_calls} call(s) denied "
1944 f"(budget {_effective_cap}/turn)", 1, override=True)
1945 acc.tool_calls += len(tool_calls_this_iter)
1946 # Plan 237: per-tool counts for the checkpoint mini report. Counted
1947 # over the PROPOSED calls, the same population ``acc.tool_calls``
1948 # counts, so the report's "N tool calls" and its tool breakdown can
1949 # never tell two different stories.
1950 for _tc_named in tool_calls_this_iter:
1951 _name = _tc_named.get("name", "?")
1952 acc.tool_name_counts[_name] = acc.tool_name_counts.get(_name, 0) + 1
1953
1954 # Plan 173 item 3 (F22): warn the MODEL as it approaches the per-turn
1955 # tool-call cap so it can checkpoint instead of being truncated silently.
1956 # One-shot per turn, fired at >=80% of the budget but before the cap
1957 # (the at-cap case is already answered by the per-call denial result
1958 # from _dispatch_or_cap, which tells the model the call did not run).
1959 _cap_warn_at = max(1, (_tool_cap * 4) // 5) # 80% of the budget
1960 if (not acc.cap_warning_emitted
1961 and acc.tool_calls >= _cap_warn_at
1962 and acc.tool_calls < _tool_cap):
1963 acc.cap_warning_emitted = True
1964 _calls_left = _tool_cap - acc.tool_calls
1965 session.messages.append({
1966 "role": "user",
1967 "content": (
1968 f"[system] Approaching the per-turn tool-call limit: "
1969 f"{acc.tool_calls}/{_tool_cap} calls used this turn, {_calls_left} "
1970 f"remain. Checkpoint your progress and make only the essential "
1971 f"remaining calls; work past the limit is deferred to a new turn."
1972 ),
1973 })
1974 session.dirty = True
1975 _dbg("OC_CHAT.TOOLS", func,
1976 f"cap-approach warning injected at {acc.tool_calls}/{_tool_cap}", 2)
1977
1978 # Append the assistant message with tool_calls so history is well-formed.
1979 # reasoning_content must be included when thinking mode is on — the API
1980 # rejects subsequent turns if it is absent from the history.
1981 assistant_msg: dict = {
1982 "role": "assistant",
1983 "content": full_content or None,
1984 "tool_calls": [
1985 {
1986 "id": tc["id"],
1987 "type": "function",
1988 "function": {"name": tc["name"], "arguments": tc["arguments"]},
1989 }
1990 for tc in tool_calls_this_iter
1991 ],
1992 }
1993 if full_reasoning:
1994 assistant_msg["reasoning_content"] = full_reasoning
1995 session.add_assistant_with_tool_calls(assistant_msg)
1996
1997 # M2.3: proposed events before any presenter side-effect (ADR-5).
1998 for _tc in tool_calls_this_iter:
1999 default_bus().publish(make_tool_call_proposed(
2000 **_ev(), tool_name=_tc["name"], requested_args=_safe_tool_args(_tc)))
2001
2002 # M8.1 WU-0a (F7/F31): the batch barrier — classify every proposed call
2003 # and apply the per-turn cap BEFORE any tool_call_started, so a cap-denied
2004 # call can no longer announce itself as started.
2005 _decision_ids = _publish_pre_dispatch_decisions(
2006 tool_calls_this_iter,
2007 _allowed_calls,
2008 _ev,
2009 stall_denied=_stall_denied_indices,
2010 )
2011
2012 # Announce every tool call before executing. Plan 147 D4: the
2013 # per-tool show_tool_progress() call that used to follow here is
2014 # removed — it was this method's only production call site, and
2015 # the spinner_start bracket a few lines below carries the same
2016 # (now dispatch-chrome-rendering) information; show_tool_progress
2017 # itself is unchanged as standalone public presenter API.
2018 for tc in tool_calls_this_iter:
2019 presenter.show_tool_call(tc["name"], tc["arguments"])
2020
2021 # M2.3: all started events before dispatch (and before any
2022 # executor.submit) so event order matches call order (ADR-5).
2023 # M8.1 WU-0a: published for ALLOWED calls only, each carrying the
2024 # permission_decision_id that allowed it — the decision↔call linkage
2025 # plan 190 documented as underivable.
2026 for _idx, _tc in enumerate(tool_calls_this_iter):
2027 if _idx >= _allowed_calls or _idx in _stall_denied_indices:
2028 continue # cap-denied above: decided, never started
2029 default_bus().publish(link_started_to_decision(
2030 make_tool_call_started(
2031 **_ev(), tool_call_id=_tc["id"], tool_name=_tc["name"]),
2032 _decision_ids[_idx]))
2033
2034 # Execute tools — parallel when there are multiple calls.
2035 # Phase H.4a-ii: bracket the dispatch with a presenter spinner so
2036 # slow tools (run_command, fetch_url) don't leave the terminal
2037 # silent. The spinner is a no-op on plain/non-TTY presenters.
2038 from oc_chat.core.resource_caps import clamp_workers
2039
2040 def _dispatch_or_cap(tc: dict, idx: int) -> "tuple[dict, dict]":
2041 # WU-9a: deny calls beyond the per-turn budget without executing them.
2042 if idx >= _allowed_calls:
2043 return tc, {"error": (
2044 f"Per-turn tool-call cap reached ({_effective_cap} "
2045 f"calls/turn); this call was not executed. Continue in a new turn."
2046 )}
2047 if idx in _stall_denied_indices:
2048 return tc, {"error": _stall_hit[2]}
2049 return _dispatch_tool(tc, tool_registry)
2050
2051 # Plan 279: the same `_run_bracketed` the confirmation resolvers use.
2052 # The labels and therefore the presenter call sequence are unchanged;
2053 # what is new is that TOOL_ACTIVITY is open for the duration, so the
2054 # bar can show the elapsed time and the last line a child printed.
2055 # On the multi-tool path the record carries whichever child wrote
2056 # last, which is the honest answer to "what is happening right now".
2057 if len(tool_calls_this_iter) > 1:
2058 _spinner_label = f"running {len(tool_calls_this_iter)} tools…"
2059
2060 def _dispatch_all() -> "list[tuple[dict, dict]]":
2061 with ThreadPoolExecutor(max_workers=clamp_workers(len(tool_calls_this_iter))) as executor:
2062 futures = [executor.submit(_dispatch_or_cap, tc, i) for i, tc in enumerate(tool_calls_this_iter)]
2063 return [f.result() for f in futures] # preserves submission order
2064
2065 ordered = _run_bracketed(presenter, _spinner_label, _dispatch_all)
2066 else:
2067 _spinner_label = f"running {tool_calls_this_iter[0]['name']}…"
2068 ordered = _run_bracketed(
2069 presenter, _spinner_label,
2070 lambda: [_dispatch_or_cap(tool_calls_this_iter[0], 0)])
2071
2072 _observed_indices = [
2073 idx for idx in range(len(ordered))
2074 if idx < _allowed_calls and idx not in _stall_denied_indices
2075 ]
2076 _last_observed_idx = (
2077 _observed_indices[-1] if _observed_indices else None
2078 )
2079 for _result_idx, (tc, result) in enumerate(ordered):
2080 # M8.0 confirmation relays (single-threaded, with stdin/presenter):
2081 # F67 external-content gate (3-choice) and the M8.0-C Tier-2 gate
2082 # (y/N). Both re-dispatch on approval, fail closed headless/declined,
2083 # and are identity otherwise.
2084 if isinstance(result, dict) and result.get("requires_confirmation"):
2085 _gate = result.get("gate")
2086 if _gate == "external_content":
2087 result = _resolve_external_content_confirmation(tc, result, tool_registry, presenter)
2088 elif _gate == "protected_write":
2089 result = _resolve_protected_write_confirmation(tc, result, tool_registry, presenter)
2090 else:
2091 result = _resolve_tier2_confirmation(tc, result, tool_registry, presenter)
2092 # Plan 173 item 1 (F11 / retired plan-160): emit the audit
2093 # permission_decision event at every Tier-2 / protected-write /
2094 # external-content confirm point (the defined-but-never-emitted M2
2095 # kind). decided_by="human" when an interactive TTY answered;
2096 # "policy" when a headless run fails closed. decision="deny" iff the
2097 # relay blocked (a residual error / still-requires-confirmation).
2098 _pd_blocked = isinstance(result, dict) and (
2099 result.get("requires_confirmation") or result.get("error") is not None)
2100 _pd_by = "human" if _stdin_is_interactive() else "policy"
2101 # Plan 235 P7: name HOW an allowed relay was authorised, inside
2102 # the closed AuthorizationMode vocabulary. A run satisfied by a
2103 # 235 session grant is a "human_confirm" too — the human DID
2104 # decide, once, for the directory. It is deliberately NOT
2105 # "grant": that value denotes the M8.1 signed one-use grant and
2106 # the validator requires the grant fingerprint fields with it, so
2107 # using it here would misattest. The Literal cannot currently
2108 # distinguish a session-grant run from a keystroke; a
2109 # "session_grant" member is a reported follow-up, not this plan.
2110 # Denials keep the pre-235 three-field shape exactly.
2111 default_bus().publish(make_permission_decision(
2112 **_ev(),
2113 permission_decision_id=new_event_id(),
2114 decision="deny" if _pd_blocked else "allow",
2115 decided_by=_pd_by,
2116 authorization_mode=(
2117 "human_confirm" if (not _pd_blocked and _pd_by == "human") else None),
2118 ))
2119 _err = result.get("error") if isinstance(result, dict) else None
2120 # Plan 173 item 1 (F27): a non-zero returncode is a failure too, so a
2121 # gate-refused oct_git_commit (returncode != 0, no "error" key) is no
2122 # longer indistinguishable in the event stream from one that landed.
2123 _rc = result.get("returncode") if isinstance(result, dict) else None
2124 _failed = bool(_err) or (isinstance(_rc, int) and _rc != 0)
2125 _status = ("ERROR: " + _err) if _err else (f"rc={_rc}" if _failed else "OK")
2126 _dbg("OC_CHAT.TOOLS", func, f"tool={tc['name']} {_status}", 1 if _failed else 3)
2127 # M2.3: completion event before presenter side-effect (ADR-5); outcome
2128 # was the payload's only failure signal — returncode-aware (F27) and,
2129 # since plan 203 (audit G10), accompanied by WHY it failed.
2130 _error_summary = _tool_error_summary(_err, _rc)
2131 default_bus().publish(make_tool_call_completed(
2132 **_ev(), tool_call_id=tc["id"], outcome="error" if _failed else "ok",
2133 error_summary=_error_summary))
2134 if _result_idx in _observed_indices:
2135 _capability = tool_capability(tc["name"])
2136 stall_detector.observe(
2137 tool=tc["name"],
2138 arguments=_safe_tool_args(tc),
2139 outcome="error" if _failed else "ok",
2140 error_summary=_error_summary,
2141 is_write=_capability not in {"read", "list"},
2142 iteration=_iteration,
2143 iteration_complete=_result_idx == _last_observed_idx,
2144 )
2145 presenter.show_tool_result(tc["name"], result)
2146
2147 # ── Track file changes for /goal changelog updates ─────────
2148 if ctx is not None and ctx.in_goal and tc["name"] == "write_file":
2149 try:
2150 args = json.loads(tc["arguments"] or "{}")
2151 path = args.get("path", "")
2152 if path and path not in ctx.goal_files_changed:
2153 ctx.goal_files_changed.append(path)
2154 except (json.JSONDecodeError, KeyError):
2155 pass
2156
2157 # ── Track file changes for /plan turns (Phase J) ──────────
2158 if ctx is not None and ctx.in_plan and tc["name"] == "write_file":
2159 try:
2160 args = json.loads(tc["arguments"] or "{}")
2161 path = args.get("path", "")
2162 if path and path not in ctx.plan_files_changed:
2163 ctx.plan_files_changed.append(path)
2164 except (json.JSONDecodeError, KeyError):
2165 pass
2166
2167 # ── Plan 237: files written this turn, for the checkpoint mini
2168 # report. Ungated (unlike the plan-137 block below, which needs
2169 # the verification flag): the operator answering "continue?"
2170 # must see what the turn has already changed on disk whether or
2171 # not verification is configured. Successful writes only.
2172 if not _failed and tc["name"] == "write_file":
2173 _written = _safe_tool_args(tc).get("path", "")
2174 if _written and _written not in acc.files_written:
2175 acc.files_written.append(_written)
2176
2177 # ── Plan 137: track changed files from SUCCESSFUL mutating results
2178 # (cumulative, turn-scoped; works regardless of ctx — incl. headless).
2179 # Gated on the verification flag so the off path is byte-identical.
2180 if _verif_enabled and tc["name"] in _vcfg.mutating_tools:
2181 from oc_chat.core.verification import changed_paths_from_result
2182 for _p in changed_paths_from_result(tc["name"], _safe_tool_args(tc), result):
2183 acc.changed_files.add(_p)
2184
2185 tool_result_msg = {
2186 "role": "tool",
2187 "tool_call_id": tc["id"],
2188 "content": json.dumps(result, ensure_ascii=False),
2189 }
2190 session.add_tool_result(tool_result_msg)
2191
2192 if _stall_hit is not None:
2193 from oc_chat.core.session_checkpoints import stall_checkpoint
2194 _stall_tc, _stall, _stall_reason = _stall_hit
2195 if stall_checkpoint(
2196 presenter=presenter,
2197 report=_mini_report(session, acc, _turn_t0),
2198 sentence=_stall_reason,
2199 is_interactive=_stdin_is_interactive(),
2200 session_id=session.session_id,
2201 ):
2202 stall_detector.reset_shape(
2203 _stall_tc["name"], _safe_tool_args(_stall_tc),
2204 )
2205 else:
2206 default_bus().publish(make_error(
2207 **_ev(), error_summary=_stall_reason,
2208 ))
2209 presenter.show_error(_stall_reason)
2210 break
2211
2212 # ── Plan 137: post-mutating-dispatch verification hook (additive) ──
2213 # After all tool results are appended, before the loop continues. Off ⇒
2214 # never entered (byte-identical). On a successful mutating iteration it
2215 # runs the sensor on the CUMULATIVE changed files and appends one
2216 # [verification] observation on new findings. The existing continue-to-
2217 # next-iteration is unchanged; the final-answer break is never touched.
2218 if _verif_enabled and acc.changed_files:
2219 _maybe_run_verification(session, acc, _vcfg, presenter, _verif_baseline)
2220
2221 else:
2222 # No tool calls — this is the final answer
2223 _dbg("OC_CHAT.MAIN", func, "final answer received", 2)
2224 if full_content:
2225 msg_entry = {
2226 "role": "assistant",
2227 "content": full_content,
2228 }
2229 if full_reasoning:
2230 msg_entry["reasoning_content"] = full_reasoning
2231 session.messages.append(msg_entry)
2232 session.dirty = True
2233 session.stats.add_message()
2234 # M2.3: final answer after it is durable in the session (ADR-5).
2235 default_bus().publish(make_assistant_message(
2236 **_ev(), text=full_content, is_final=True))
2237 # The answer is complete prose as far as the loop is concerned,
2238 # but the provider says it ran out of budget mid-sentence. Say
2239 # so — this is the plain truncation an operator hits first.
2240 if finish_reason == "length":
2241 presenter.show_info(
2242 TRUNCATED_ANSWER_NOTICE.format(cap=route.max_tokens))
2243 elif full_reasoning:
2244 # Reasoning-only completion. The model spent its whole output
2245 # budget on chain-of-thought and stopped before the first
2246 # content token — the user watched it stream, so the work is
2247 # real and visible. Persist it: the empty branch below pops the
2248 # user message, which for this shape erased the prompt AND the
2249 # reasoning, leaving session.messages byte-identical to before
2250 # the turn (the "it forgot everything" report). Mirrors the
2251 # tool-call branch above, which already keeps reasoning_content
2252 # alongside absent content.
2253 msg_entry = {
2254 "role": "assistant",
2255 "content": REASONING_ONLY_CONTENT,
2256 "reasoning_content": full_reasoning,
2257 }
2258 session.messages.append(msg_entry)
2259 session.dirty = True
2260 session.stats.add_message()
2261 # M2.3: durable before the presenter side-effect (ADR-5).
2262 default_bus().publish(make_assistant_message(
2263 **_ev(), text=REASONING_ONLY_CONTENT, is_final=True))
2264 _dbg("OC_CHAT.MAIN", func,
2265 f"reasoning-only completion: {len(full_reasoning)} chars kept, "
2266 f"no content token received (finish_reason={finish_reason})",
2267 1, override=True)
2268 _cause = (CAUSE_LENGTH.format(cap=route.max_tokens)
2269 if finish_reason == "length" else CAUSE_UNKNOWN)
2270 presenter.show_info(REASONING_ONLY_NOTICE.format(
2271 n=len(full_reasoning), cause=_cause))
2272 else:
2273 if session.messages and session.messages[-1]["role"] == "user":
2274 session.messages.pop()
2275 # M2.3: empty completion is a terminal error for replay (ADR-4).
2276 default_bus().publish(make_error(
2277 **_ev(), error_summary="empty_completion: no response content received"))
2278 presenter.show_info("[No response content received]")
2279 break
2280 else:
2281 _dbg("OC_CHAT.MAIN", func,
2282 f"hit ReAct cap ({react_cap} iterations) without final answer", 1, override=True)
2283 # Plan 137 §3.2: a mutation on the final react_cap iteration is verified +
2284 # recorded but the model gets no same-turn chance to reconcile — flag it
2285 # here (gated; off ⇒ the original message, byte-identical).
2286 _cap_msg = (
2287 f"[Reached the maximum of {react_cap} ReAct iterations without a final reply — "
2288 "try narrowing the task, /session clear, /session new, or starting a new session.]"
2289 )
2290 if _verif_enabled and acc.pending_findings is not None:
2291 _cap_msg += (
2292 " Note: verification found unreconciled issues in the files changed "
2293 "this turn — review them before continuing."
2294 )
2295 presenter.show_info(_cap_msg)
2296
2297 session.last_turn_max_prompt_tokens = acc.max_prompt_in_turn
2298
2299 n_user_msgs = sum(1 for m in session.messages if m.get("role") == "user")
2300 if n_user_msgs >= 12 and not session.rollover_hint_shown:
2301 session.rollover_hint_shown = True
2302 presenter.show_info(
2303 "[Tip] This conversation has many user turns. For a shorter prefix and better cache "
2304 "efficiency, consider `/session save` then `/session new` (or export) and continue in a fresh session."
2305 )
2306
2307 presenter.end_turn()
2308 # ── Turn summary (aggregated across all iterations) ────────────────────
2309 _dbg("OC_CHAT.MAIN", func,
2310 f"turn END iters={acc.iterations} tools={acc.tool_calls} "
2311 f"prompt={acc.prompt_tokens} completion={acc.completion_tokens} "
2312 f"cached={acc.prompt_cached}", 2)
2313 if acc.iterations > 0:
2314 pc = acc.prompt_cached if acc.prompt_cached > 0 else None
2315 has_cache = acc.cache_hit_tokens + acc.cache_miss_tokens > 0
2316 pct = cache_hit_ratio_pct(acc.cache_hit_tokens, acc.cache_miss_tokens) if has_cache else None
2317 saved = acc.cache_hit_tokens if has_cache else None
2318 # Plan 134 C2: a REAL context-size gauge — the turn's MAIN-LOOP prompt
2319 # high-water mark over the model's context window. Uses acc.max_prompt_in_turn
2320 # only (NOT summed parent+sub-loop usage — B2 separation), so a sub-loop turn
2321 # does not inflate it. The caller computes the %; presenters just render it.
2322 ctx_window = get_max_context_tokens(session.model)
2323 context_used_pct = None
2324 if ctx_window > 0 and acc.max_prompt_in_turn > 0:
2325 context_used_pct = max(0.0, min(100.0,
2326 acc.max_prompt_in_turn / ctx_window * 100.0))
2327 presenter.show_turn_summary(
2328 prompt=acc.prompt_tokens,
2329 completion=acc.completion_tokens,
2330 total=acc.total_tokens,
2331 iterations=acc.iterations,
2332 tool_calls=acc.tool_calls,
2333 prompt_cached=pc,
2334 cache_hit_pct=pct,
2335 cache_saved_tokens=saved,
2336 performance_stats=acc.perf_stats,
2337 model=session.model,
2338 context_used_pct=context_used_pct,
2339 duration_ms=int((time.monotonic() - _turn_t0) * 1000),
2340 # Plan 188 T4: None when any sample was unpriceable — presenters
2341 # then render NO cost rather than a partial (i.e. wrong) total.
2342 turn_cost=acc.cost.result(),
2343 )
2344 if acc.cache_hit_tokens + acc.cache_miss_tokens > 0:
2345 snap = {
2346 "hit": acc.cache_hit_tokens,
2347 "miss": acc.cache_miss_tokens,
2348 "prompt": acc.prompt_tokens,
2349 }
2350 session.cache_turn_snapshots.append(snap)
2351 session.cache_turn_snapshots = session.cache_turn_snapshots[-30:]
2352 session.dirty = True
2353
2354 # Mark turn finished in thread-safe stats
2355 if turn_stats is not None:
2356 session_cache_pct = cache_hit_ratio_pct(
2357 session.stats.prompt_cache_hit_tokens,
2358 session.stats.prompt_cache_miss_tokens,
2359 )
2360 turn_stats.finish_turn(
2361 session.stats.total_tokens,
2362 iterations=acc.iterations,
2363 tool_calls=acc.tool_calls,
2364 session_cache_pct=session_cache_pct,
2365 session_cache_saved=session.stats.prompt_cache_hit_tokens,
2366 )
2367
2368 # ── Per-turn auto-save ────────────────────────────────────────────────
2369 # Each completed turn snapshots both chat log and metadata. The persistence
2370 # archives the previous version first, so a crash mid-session at most
2371 # loses the in-flight turn.
2372 try:
2373 save_chat_log(session)
2374 save_session_metadata(session)
2375 except RuntimeError as exc:
2376 # save_chat_log raises this when load_failed is set — preserve the
2377 # original chat log on disk and just save metadata.
2378 presenter.show_info(f"[auto-save: {exc}]")
2379 try:
2380 save_session_metadata(session)
2381 except Exception as exc2:
2382 presenter.show_error(f"Auto-save metadata failed: {exc2}")
2383 except Exception as exc:
2384 presenter.show_error(f"Auto-save failed: {exc}")
"tuple[dict, list]" _filter_readonly_tools(dict tool_registry, list tool_schemas, "tuple[str, ...]" mutating)
dict _run_confirmed_tier2(dict tc, dict tool_registry, presenter, str command, str program, "int | None" budget)
None run_turn("ChatSession" session, "ClientInterface" client, "Presenter" presenter, "ModelManager" model_manager, dict tool_registry, list tool_schemas, "Context | None" ctx=None, "TurnStats | None" turn_stats=None, "EnhancedPresenter | None" enhanced_presenter=None, "CancellationToken | None" cancellation=None)
str _budget_text("int | None" seconds)
bool _detect_response_repetition(list[dict] messages)
dict _safe_tool_args(dict tc)
list[str] _publish_pre_dispatch_decisions(list[dict] tool_calls, int allowed, Callable[[], dict] ev, set[int]|None stall_denied=None)
dict _resolve_external_content_confirmation(dict tc, dict result, dict tool_registry, presenter)
_T _run_bracketed(presenter, str label, "Callable[[], _T]" fn)
dict _resolve_tier2_confirmation(dict tc, dict result, dict tool_registry, presenter)
"tuple[dict, list]" _subloop_positive_allowlist(dict tool_registry, list tool_schemas)
str _prompt_version_for(list layer_ids, dict layer_hashes)
None _publish_provider_budget(dict env, str provider, str model, *, int cache_hit_tokens, int cache_miss_tokens, int prompt_tokens, int completion_tokens, datetime at, session=None)
dict _resolve_protected_write_confirmation(dict tc, dict result, dict tool_registry, presenter)
None _warn_unknown_usage_fields(session, presenter, provider_name, model, unknown_raw)
str _extract_last_user_task(list[dict] messages)
"tuple[list[str], dict[str, str]]" _prompt_layer_digests(list api_messages, tools)
_mini_report(session, "_TurnAccumulator" acc, float turn_t0)
_cost_gate(session, presenter, "_TurnAccumulator" acc, float turn_t0)
None _maybe_run_verification("ChatSession" session, "_TurnAccumulator" acc, "VerificationConfig" vcfg, "Presenter" presenter, object|None baseline)
Optional[str] _tool_error_summary(object err, object rc)
str _schema_tool_name(dict schema)
str _provider_name(client)
tuple[dict, dict] _dispatch_tool(dict tc, dict tool_registry)
None _end_turn_on_cost(session, presenter, str reason, ev)
None _fold_sub_loop_usage(session, acc, turn_stats, client, sub_result, str sub_model="", *, datetime at)