6Soft caps: the checkpoints that pause a long session instead of ending it.
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:
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)``.
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.
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
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.
43- :class:`MiniReport` — the five facts an operator needs to answer the prompt,
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.
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
59- A failure to write or relay an alert never changes a verdict.
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)
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
78from __future__
import annotations
80from dataclasses
import dataclass
83from oc_diagnostics
import _dbg
87 KIND_COST_HARD_CAP, KIND_COST_THRESHOLD, KIND_STALL_HALT,
88 KIND_TOOL_CALL_CHECKPOINT,
91_dbg(
"OC_CHAT.CORE",
"module",
"loaded", 4)
94def _emit(emit: Any, **fields: Any) ->
None:
95 """Write one alert through *emit*, or through the live writer when ``None``.
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.
103 (emit
or session_alerts.emit_session_alert)(**fields)
109_TOOL_CALLS_AUTO_CONTINUE: bool =
False
110_COST_PROMPTS_SUPPRESSED: bool =
False
111_STALL_PROMPTS_SUPPRESSED: bool =
False
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)
126 """True when the operator answered ``a`` at the tool-call checkpoint."""
127 return _TOOL_CALLS_AUTO_CONTINUE
131 """True when the operator answered ``a`` at a cost checkpoint.
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
139 """True when the operator answered ``a`` at a stall checkpoint."""
140 return _STALL_PROMPTS_SUPPRESSED
144 global _TOOL_CALLS_AUTO_CONTINUE
145 _TOOL_CALLS_AUTO_CONTINUE =
True
149 global _COST_PROMPTS_SUPPRESSED
150 _COST_PROMPTS_SUPPRESSED =
True
154 global _STALL_PROMPTS_SUPPRESSED
155 _STALL_PROMPTS_SUPPRESSED =
True
158@dataclass(frozen=True)
160 """What the turn has spent so far, in the five terms the ruling names."""
163 """Tool calls made so far this turn."""
165 """The most-called tools this turn as ``(name, count)``, descending."""
167 """Paths successfully written this turn, in first-write order."""
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."""
173 """Milliseconds elapsed in this turn."""
176 """Render the report as operator-facing lines (no ANSI, no markup)."""
179 tools =
", ".join(f
"{name} x{count}" for name, count
in self.top_tools)
or "none"
183 files = shown + (f
" (+{more} more)" if more > 0
else "")
186 unpriced = (f
" (+{self.unpriced_samples} unpriced sample"
187 f
"{'s' if self.unpriced_samples != 1 else ''})"
188 if self.unpriced_samples
else "")
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)}",
200 turn_tool_calls: int,
201 tool_name_counts: dict,
204 unpriced_samples: int,
206 top_n: int |
None =
None,
208 """Assemble a :class:`MiniReport`; *top_n* defaults to the shipped value."""
211 top_n = int(default(
"checkpoint_top_tools"))
212 ranked = sorted(tool_name_counts.items(), key=
lambda kv: (-kv[1], kv[0]))
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),
223def _ask(presenter, header: str, report: MiniReport, prompt: str) -> str:
224 """Show the mini report and read one key; ``""``/interrupt answer ``n``."""
226 presenter.show_info(
"\n".join([header, *report.lines()]))
229 answer = read_single_key(prompt, valid=
"yna")
230 except (EOFError, KeyboardInterrupt):
232 _dbg(
"OC_CHAT.CORE", func,
"interrupt at checkpoint -> treated as 'n'", 2)
242 is_interactive: bool,
243 session_id: str =
"",
246 """Decide whether to buy one more cap-width of tool calls this turn.
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.
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)
258 _dbg(
"OC_CHAT.CORE", func,
259 f
"tool-call checkpoint at {effective_cap} auto-continued ('a' this session)", 2)
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,
271 f
"[checkpoint] {report.turn_tool_calls} tool calls this turn reaches the "
272 f
"per-turn budget of {effective_cap}.",
274 "Continue this turn? (y/N/a) ",
278 _dbg(
"OC_CHAT.CORE", func,
279 "tool-call checkpoint set to auto-continue for this session", 2)
282 _dbg(
"OC_CHAT.CORE", func,
283 f
"tool-call budget extended past {effective_cap} by the operator", 2)
285 _dbg(
"OC_CHAT.CORE", func,
286 f
"operator declined at the {effective_cap}-call checkpoint -> deny as before",
296 is_interactive: bool,
297 session_id: str =
"",
300 """Alert on a stall and decide whether the turn may continue.
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.
306 func =
"stall_checkpoint"
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,
317 _dbg(
"OC_CHAT.CORE", func,
318 "stall checkpoint auto-continued ('a' this session)", 2)
320 if not is_interactive:
321 _dbg(
"OC_CHAT.CORE", func,
"stall halted a headless turn", 1, override=
True)
327 "Continue this turn? (y/N/a) ",
331 _dbg(
"OC_CHAT.CORE", func,
332 "stall prompts suppressed for this session", 2)
335 _dbg(
"OC_CHAT.CORE", func,
"stall checkpoint continued by operator", 2)
337 _dbg(
"OC_CHAT.CORE", func,
"operator ended the turn at a stall checkpoint",
342@dataclass(frozen=True)
344 """Whether the session may continue, and the operator-facing reason if not."""
355 *, priced_usd: float, unpriced_samples: int, cap_usd: int, env_var_name: str,
357 """The refusal text: the total, the cap, and the exact knob that raises it.
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.
365 plural = unpriced_samples != 1
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."
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)."
375 " This cap has no out-of-band raise path."
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}"
390 is_interactive: bool,
396 """Fire the hard cap and any newly crossed checkpoint; return the verdict.
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
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.
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)
410 if hard_cap_usd > 0
and priced >= float(hard_cap_usd):
412 priced_usd=priced, unpriced_samples=unpriced,
413 cap_usd=hard_cap_usd, env_var_name=env_var_name,
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,
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)
426 t
for t
in sorted(thresholds)
427 if priced >= float(t)
and not cost.has_fired(t)
436 highest = crossed[-1]
437 for threshold
in crossed:
438 cost.mark_threshold_fired(threshold)
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),
445 _dbg(
"OC_CHAT.CORE", func,
446 f
"cost checkpoint(s) crossed: {crossed} at ${priced:.2f}", 2)
454 f
"[checkpoint] Session spend has passed ${highest:.2f}.",
456 "Continue this session? (y/N/a) ",
460 _dbg(
"OC_CHAT.CORE", func,
461 "cost prompts suppressed for this session (alerts and hard cap stand)", 2)
465 _dbg(
"OC_CHAT.CORE", func,
466 f
"operator ended the turn at the ${highest:.2f} checkpoint", 1, override=
True)
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."
480 "cost_prompts_suppressed",
481 "evaluate_cost_checkpoints",
483 "reset_session_checkpoints",
485 "stall_prompts_suppressed",
486 "tool_call_checkpoint",
487 "tool_calls_auto_continue",
bool cost_prompts_suppressed()
None _set_stall_prompts_suppressed()
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)
None _set_tool_calls_auto_continue()
str hard_cap_refusal(*, float priced_usd, int unpriced_samples, int cap_usd, str env_var_name)
bool stall_prompts_suppressed()
str _ask(presenter, str header, MiniReport report, str prompt)
None _set_cost_prompts_suppressed()
None _emit(Any emit, **Any fields)
None reset_session_checkpoints()
bool tool_calls_auto_continue()
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)