oc_chat
Loading...
Searching...
No Matches
session_checkpoints.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/session_checkpoints.py
4
5"""
6Soft caps: the checkpoints that pause a long session instead of ending it.
7
8Purpose
9-------
10CAPS-01 (plan 237). A per-turn tool-call count is a poor runaway detector — it
11fired constantly during real work and made the long, semi-autonomous sessions
12this product exists for impossible. The operator's ruling: the COUNT becomes a
13checkpoint with a higher default, and the bound that actually matters becomes
14MONEY. This module holds both checkpoints and nothing else:
15
16- :func:`tool_call_checkpoint` — at the per-turn tool-call cap, on a real TTY,
17 show a mini report and ask ``Continue this turn? (y/N/a)``. ``y`` buys one
18 more cap-width; ``a`` stops asking for the rest of the session; ``N``, Enter
19 or an interrupt fall through to the EXISTING per-call denial, byte for byte.
20- :func:`evaluate_cost_checkpoints` — after each priced sample, fire the hard
21 cap first and then any newly crossed dollar checkpoint, each of which writes
22 a ``session-alerts`` lane event, notifies the operator best-effort, and (on a
23 TTY) asks ``Continue this session? (y/N/a)``.
24
25Headless is deliberately unchanged. With no TTY there is no prompt, the
26tool-call cap stays the hard deny it has always been (fail-closed, F38/F52),
27and the cost path alerts and logs but never blocks — the session runs to the
28hard cap, which is the one bound that stops it either way.
29
30The two "don't ask again" latches are PROCESS-scoped, exactly like the F67
31external-content trust set and the plan-235 Tier-2 grant, and drop at the same
32two session seams (:meth:`ChatSession.clear` and
33``session._reset_trust_before_exposure``). They are deliberately NOT persisted:
34a suppression that survived a reload would be a standing authority nobody
35granted twice.
36
37The two latches are also deliberately INDEPENDENT. ``a`` at the tool-call
38checkpoint silences the tool-call prompt alone — never a cost prompt, never the
39alerts, and never the hard cap. That separation is the ruling's exact shape.
40
41Responsibilities
42----------------
43- :class:`MiniReport` — the five facts an operator needs to answer the prompt,
44 and their rendering.
45- :func:`tool_call_checkpoint` / :func:`evaluate_cost_checkpoints` — the two
46 decisions, returning a verdict rather than acting on the loop themselves.
47- :func:`reset_session_checkpoints` and the latch accessors.
48
49Contracts
50---------
51- No prompt is ever shown when *is_interactive* is false. The caller passes the
52 answer from the ONE TTY seam (``react_loop._stdin_is_interactive``) so this
53 module has no hidden global to disagree with it.
54- A verdict never mutates the loop. ``tool_call_checkpoint`` returns whether to
55 extend the budget; ``evaluate_cost_checkpoints`` returns whether to stop. The
56 react-loop seam owns every side effect on ``session.messages``.
57- An interrupt at either prompt is the CONSERVATIVE answer (deny / stop), never
58 a silent continue.
59- A failure to write or relay an alert never changes a verdict.
60
61Dependencies
62------------
63- oc_diagnostics (structured debug logging via _dbg)
64- oc_chat.core.session_alerts (the credential-free lane writer)
65- oc_chat.core.defaults (lazy; shipped top-tools count for the mini report)
66- oc_chat.core.formatting (lazy; format_duration for the mini report lines)
67- oc_ui.terminal.input (lazy; read_single_key for the checkpoint prompt)
68
69Diagnostics
70-----------
71Domain: OC_CHAT.CORE
72Levels:
73 L1 — a checkpoint stopped a turn or a session (override: operator-facing)
74 L2 — a checkpoint fired; a latch was set
75 L3 — per-decision detail
76 L4 — module load
77"""
78from __future__ import annotations
79
80from dataclasses import dataclass
81from typing import Any
82
83from oc_diagnostics import _dbg
84
85from oc_chat.core import session_alerts
87 KIND_COST_HARD_CAP, KIND_COST_THRESHOLD, KIND_STALL_HALT,
88 KIND_TOOL_CALL_CHECKPOINT,
89)
90
91_dbg("OC_CHAT.CORE", "module", "loaded", 4)
92
93
94def _emit(emit: Any, **fields: Any) -> None:
95 """Write one alert through *emit*, or through the live writer when ``None``.
96
97 Resolved HERE rather than as a default argument on purpose. A default is
98 bound once at function definition, so ``monkeypatch.setattr(module,
99 "emit_session_alert", ...)`` would silently fail to take and a test would
100 write into the real comms bus while believing it had faked it — which is
101 exactly what happened while this plan was being written.
102 """
103 (emit or session_alerts.emit_session_alert)(**fields)
104
105
106#: The two process-scoped "don't ask again this session" latches. Module state,
107#: not session state, for the same reason the F67 trust set is: they must not
108#: survive a save/load, and every seam that drops session trust drops these.
109_TOOL_CALLS_AUTO_CONTINUE: bool = False
110_COST_PROMPTS_SUPPRESSED: bool = False
111_STALL_PROMPTS_SUPPRESSED: bool = False
112
113
115 """Drop both "don't ask again" latches (the session-change seam)."""
116 func = "reset_session_checkpoints"
117 global _TOOL_CALLS_AUTO_CONTINUE, _COST_PROMPTS_SUPPRESSED
118 global _STALL_PROMPTS_SUPPRESSED
119 _TOOL_CALLS_AUTO_CONTINUE = False
120 _COST_PROMPTS_SUPPRESSED = False
121 _STALL_PROMPTS_SUPPRESSED = False
122 _dbg("OC_CHAT.CORE", func, "checkpoint latches cleared", 3)
123
124
126 """True when the operator answered ``a`` at the tool-call checkpoint."""
127 return _TOOL_CALLS_AUTO_CONTINUE
128
129
131 """True when the operator answered ``a`` at a cost checkpoint.
132
133 Suppresses further cost PROMPTS only. Alerts still fire and the hard cap
134 still stops the session — that is the point of a hard cap."""
135 return _COST_PROMPTS_SUPPRESSED
136
137
139 """True when the operator answered ``a`` at a stall checkpoint."""
140 return _STALL_PROMPTS_SUPPRESSED
141
142
144 global _TOOL_CALLS_AUTO_CONTINUE
145 _TOOL_CALLS_AUTO_CONTINUE = True
146
147
149 global _COST_PROMPTS_SUPPRESSED
150 _COST_PROMPTS_SUPPRESSED = True
151
152
154 global _STALL_PROMPTS_SUPPRESSED
155 _STALL_PROMPTS_SUPPRESSED = True
156
157
158@dataclass(frozen=True)
160 """What the turn has spent so far, in the five terms the ruling names."""
161
162 turn_tool_calls: int
163 """Tool calls made so far this turn."""
164 top_tools: tuple
165 """The most-called tools this turn as ``(name, count)``, descending."""
166 files_written: tuple
167 """Paths successfully written this turn, in first-write order."""
168 priced_usd: float
169 """Session spend that could be priced, in USD."""
170 unpriced_samples: int
171 """Samples that could NOT be priced — counted, never treated as zero."""
172 elapsed_ms: int
173 """Milliseconds elapsed in this turn."""
174
175 def lines(self) -> list:
176 """Render the report as operator-facing lines (no ANSI, no markup)."""
177 from oc_chat.core.formatting import format_duration
178
179 tools = ", ".join(f"{name} x{count}" for name, count in self.top_tools) or "none"
181 shown = ", ".join(self.files_written[:5])
182 more = len(self.files_written) - 5
183 files = shown + (f" (+{more} more)" if more > 0 else "")
184 else:
185 files = "none"
186 unpriced = (f" (+{self.unpriced_samples} unpriced sample"
187 f"{'s' if self.unpriced_samples != 1 else ''})"
188 if self.unpriced_samples else "")
189 return [
190 f" tool calls this turn : {self.turn_tool_calls}",
191 f" top tools : {tools}",
192 f" files written : {files}",
193 f" session cost : ${self.priced_usd:.2f}{unpriced}",
194 f" elapsed this turn : {format_duration(self.elapsed_ms)}",
195 ]
196
197
199 *,
200 turn_tool_calls: int,
201 tool_name_counts: dict,
202 files_written,
203 priced_usd: float,
204 unpriced_samples: int,
205 elapsed_ms: int,
206 top_n: int | None = None,
207) -> MiniReport:
208 """Assemble a :class:`MiniReport`; *top_n* defaults to the shipped value."""
209 if top_n is None:
210 from oc_chat.core.defaults import default
211 top_n = int(default("checkpoint_top_tools"))
212 ranked = sorted(tool_name_counts.items(), key=lambda kv: (-kv[1], kv[0]))
213 return MiniReport(
214 turn_tool_calls=turn_tool_calls,
215 top_tools=tuple(ranked[:max(0, top_n)]),
216 files_written=tuple(files_written),
217 priced_usd=float(priced_usd),
218 unpriced_samples=int(unpriced_samples),
219 elapsed_ms=int(elapsed_ms),
220 )
221
222
223def _ask(presenter, header: str, report: MiniReport, prompt: str) -> str:
224 """Show the mini report and read one key; ``""``/interrupt answer ``n``."""
225 func = "_ask"
226 presenter.show_info("\n".join([header, *report.lines()]))
227 from oc_ui.terminal.input import read_single_key
228 try:
229 answer = read_single_key(prompt, valid="yna")
230 except (EOFError, KeyboardInterrupt):
231 # Conservative: an interrupt at a checkpoint is not consent.
232 _dbg("OC_CHAT.CORE", func, "interrupt at checkpoint -> treated as 'n'", 2)
233 return "n"
234 return answer or "n"
235
236
238 *,
239 presenter,
240 report: MiniReport,
241 effective_cap: int,
242 is_interactive: bool,
243 session_id: str = "",
244 emit: Any = None,
245) -> bool:
246 """Decide whether to buy one more cap-width of tool calls this turn.
247
248 Returns ``True`` to extend the budget, ``False`` to fall through to the
249 existing per-call denial. Headless always returns ``False`` — the cap is a
250 hard deny with no human present, exactly as before this plan.
251 """
252 func = "tool_call_checkpoint"
253 if not is_interactive:
254 _dbg("OC_CHAT.CORE", func,
255 f"no TTY at the {effective_cap}-call checkpoint -> hard deny (unchanged)", 2)
256 return False
258 _dbg("OC_CHAT.CORE", func,
259 f"tool-call checkpoint at {effective_cap} auto-continued ('a' this session)", 2)
260 return True
261 # The operator is about to be BLOCKED at a prompt: tell them wherever they
262 # are, before the terminal starts waiting. Auto-continued crossings emit
263 # nothing — they answered "stop asking", and an alert is asking.
264 _emit(
265 emit, session_id=session_id, kind=KIND_TOOL_CALL_CHECKPOINT,
266 total_usd=report.priced_usd, unpriced_samples=report.unpriced_samples,
267 turn_tool_calls=report.turn_tool_calls,
268 )
269 answer = _ask(
270 presenter,
271 f"[checkpoint] {report.turn_tool_calls} tool calls this turn reaches the "
272 f"per-turn budget of {effective_cap}.",
273 report,
274 "Continue this turn? (y/N/a) ",
275 )
276 if answer == "a":
278 _dbg("OC_CHAT.CORE", func,
279 "tool-call checkpoint set to auto-continue for this session", 2)
280 return True
281 if answer == "y":
282 _dbg("OC_CHAT.CORE", func,
283 f"tool-call budget extended past {effective_cap} by the operator", 2)
284 return True
285 _dbg("OC_CHAT.CORE", func,
286 f"operator declined at the {effective_cap}-call checkpoint -> deny as before",
287 1, override=True)
288 return False
289
290
292 *,
293 presenter,
294 report: MiniReport,
295 sentence: str,
296 is_interactive: bool,
297 session_id: str = "",
298 emit: Any = None,
299) -> bool:
300 """Alert on a stall and decide whether the turn may continue.
301
302 The offending proposal remains denied regardless of the answer. ``y`` and
303 ``a`` let the next ReAct iteration run; ``a`` suppresses only later stall
304 prompts in this process-scoped session. Headless and ``n`` stop the turn.
305 """
306 func = "stall_checkpoint"
307 _emit(
308 emit,
309 session_id=session_id,
310 kind=KIND_STALL_HALT,
311 total_usd=report.priced_usd,
312 unpriced_samples=report.unpriced_samples,
313 turn_tool_calls=report.turn_tool_calls,
314 stall_reason=sentence,
315 )
317 _dbg("OC_CHAT.CORE", func,
318 "stall checkpoint auto-continued ('a' this session)", 2)
319 return True
320 if not is_interactive:
321 _dbg("OC_CHAT.CORE", func, "stall halted a headless turn", 1, override=True)
322 return False
323 answer = _ask(
324 presenter,
325 sentence,
326 report,
327 "Continue this turn? (y/N/a) ",
328 )
329 if answer == "a":
331 _dbg("OC_CHAT.CORE", func,
332 "stall prompts suppressed for this session", 2)
333 return True
334 if answer == "y":
335 _dbg("OC_CHAT.CORE", func, "stall checkpoint continued by operator", 2)
336 return True
337 _dbg("OC_CHAT.CORE", func, "operator ended the turn at a stall checkpoint",
338 1, override=True)
339 return False
340
341
342@dataclass(frozen=True)
344 """Whether the session may continue, and the operator-facing reason if not."""
345
346 stop: bool
347 reason: str = ""
348
349
350#: Returned when nothing crossed — the overwhelmingly common case.
351_CONTINUE = CostVerdict(stop=False)
352
353
355 *, priced_usd: float, unpriced_samples: int, cap_usd: int, env_var_name: str,
356) -> str:
357 """The refusal text: the total, the cap, and the exact knob that raises it.
358
359 Named rather than vague on purpose (P4). An operator who hits this must not
360 have to read the source to find out how to continue, and must not be left
361 believing the total is the whole story when samples went unpriced.
362 """
363 unpriced = ""
364 if unpriced_samples:
365 plural = unpriced_samples != 1
366 unpriced = (
367 f" {unpriced_samples} sample{'s' if plural else ''} could not be "
368 f"priced and {'are' if plural else 'is'} NOT included in that "
369 f"total, so real spend may be higher."
370 )
371 raise_it = (
372 f" Raising it is out-of-band only: set {env_var_name}=<whole dollars> in "
373 f"the launch environment (a workspace settings file can only LOWER it)."
374 if env_var_name else
375 " This cap has no out-of-band raise path."
376 )
377 return (
378 f"[cost] Session spend ${priced_usd:.2f} has reached the ${cap_usd} hard "
379 f"cap; no further provider call will be made in this session.{unpriced}"
380 f"{raise_it}"
381 )
382
383
385 *,
386 session_id: str,
387 cost: Any,
388 presenter,
389 report: MiniReport,
390 is_interactive: bool,
391 hard_cap_usd: int,
392 thresholds: tuple,
393 env_var_name: str,
394 emit: Any = None,
395) -> CostVerdict:
396 """Fire the hard cap and any newly crossed checkpoint; return the verdict.
397
398 *cost* is the session's :class:`~oc_chat.core.session.SessionCost` fold —
399 duck-typed rather than imported, because ``session.py`` imports THIS module
400 at its reset seams.
401
402 Order is deliberate: the hard cap is evaluated first and returns
403 immediately, so a sample that vaults several checkpoints at once ends the
404 session on the cap rather than after a queue of prompts nobody will answer.
405 """
406 func = "evaluate_cost_checkpoints"
407 priced = float(getattr(cost, "priced_usd", 0.0) or 0.0)
408 unpriced = int(getattr(cost, "unpriced_samples", 0) or 0)
409
410 if hard_cap_usd > 0 and priced >= float(hard_cap_usd):
411 reason = hard_cap_refusal(
412 priced_usd=priced, unpriced_samples=unpriced,
413 cap_usd=hard_cap_usd, env_var_name=env_var_name,
414 )
415 _emit(
416 emit, session_id=session_id, kind=KIND_COST_HARD_CAP, total_usd=priced,
417 unpriced_samples=unpriced, threshold_usd=float(hard_cap_usd),
418 turn_tool_calls=report.turn_tool_calls,
419 )
420 _dbg("OC_CHAT.CORE", func,
421 f"SECURITY_ERROR: session hard cap reached (${priced:.2f} >= "
422 f"${hard_cap_usd}); refusing further provider calls", 1, override=True)
423 return CostVerdict(stop=True, reason=reason)
424
425 crossed = [
426 t for t in sorted(thresholds)
427 if priced >= float(t) and not cost.has_fired(t)
428 ]
429 if not crossed:
430 return _CONTINUE
431
432 # One alert per checkpoint passed — each is a distinct fact the record
433 # should carry. ONE notification, for the highest, because three phone
434 # messages for one sample is noise, and three 15-second subprocesses on the
435 # turn's critical path is worse.
436 highest = crossed[-1]
437 for threshold in crossed:
438 cost.mark_threshold_fired(threshold)
439 _emit(
440 emit, session_id=session_id, kind=KIND_COST_THRESHOLD, total_usd=priced,
441 unpriced_samples=unpriced, threshold_usd=float(threshold),
442 turn_tool_calls=report.turn_tool_calls,
443 notify=(threshold == highest),
444 )
445 _dbg("OC_CHAT.CORE", func,
446 f"cost checkpoint(s) crossed: {crossed} at ${priced:.2f}", 2)
447
448 if not is_interactive or cost_prompts_suppressed():
449 # Headless, or "don't ask again": alerted and logged, never blocked.
450 return _CONTINUE
451
452 answer = _ask(
453 presenter,
454 f"[checkpoint] Session spend has passed ${highest:.2f}.",
455 report,
456 "Continue this session? (y/N/a) ",
457 )
458 if answer == "a":
460 _dbg("OC_CHAT.CORE", func,
461 "cost prompts suppressed for this session (alerts and hard cap stand)", 2)
462 return _CONTINUE
463 if answer == "y":
464 return _CONTINUE
465 _dbg("OC_CHAT.CORE", func,
466 f"operator ended the turn at the ${highest:.2f} checkpoint", 1, override=True)
467 return CostVerdict(
468 stop=True,
469 reason=(
470 f"[cost] You ended this turn at the ${highest:.2f} checkpoint; session "
471 f"spend is ${priced:.2f}. Nothing further was sent to the provider."
472 ),
473 )
474
475
476__all__ = [
477 "CostVerdict",
478 "MiniReport",
479 "build_mini_report",
480 "cost_prompts_suppressed",
481 "evaluate_cost_checkpoints",
482 "hard_cap_refusal",
483 "reset_session_checkpoints",
484 "stall_checkpoint",
485 "stall_prompts_suppressed",
486 "tool_call_checkpoint",
487 "tool_calls_auto_continue",
488]
bool tool_call_checkpoint(*, presenter, MiniReport report, int effective_cap, bool is_interactive, str session_id="", Any emit=None)
CostVerdict evaluate_cost_checkpoints(*, str session_id, Any cost, presenter, MiniReport report, bool is_interactive, int hard_cap_usd, tuple thresholds, str env_var_name, Any emit=None)
str hard_cap_refusal(*, float priced_usd, int unpriced_samples, int cap_usd, str env_var_name)
str _ask(presenter, str header, MiniReport report, str prompt)
None _emit(Any emit, **Any fields)
bool stall_checkpoint(*, presenter, MiniReport report, str sentence, bool is_interactive, str session_id="", Any emit=None)
MiniReport build_mini_report(*, int turn_tool_calls, dict tool_name_counts, files_written, float priced_usd, int unpriced_samples, int elapsed_ms, int|None top_n=None)