studio: tighten trailing-plan list anchor + grow tool-iter cap on demand

Codex P2 review on #5549 surfaced two related risks in the auto-continue
plumbing:

1. `_TRAILING_PLAN_LIST` was compiled with `(?ims)`. The `m` flag makes
   the terminal `\s*$` match end-of-line, so a complete answer like
   "Here's my plan:\n- a\n- b\n\nDone, that should work." still matched
   the list-block sub-pattern and tripped a spurious `Continue.` retry.
   Drop the `m` (and the unused `s`) flag and re-anchor with `\Z` so the
   list pattern only fires when the list is genuinely the last thing in
   the buffer.

2. The agent loop pre-reserved `_MAX_REPROMPTS + _MAX_CONTINUES` (= 6)
   extra iterations on top of the caller's `max_tool_iterations`
   unconditionally. That weakens the caller-provided budget: a turn
   that never trips the reprompt or continue path could still run up
   to N+6 full iterations and execute their tool calls.

   Switch the bound to a dynamic cap that grows only as reprompts /
   continues are actually consumed: `iteration < max_tool_iterations +
   _reprompt_count + _continue_count`. With both counters at zero the
   loop honors the caller cap exactly; once a continue or reprompt
   fires it earns its own slot back.

   Implemented with `itertools.count()` so the existing `continue`
   statements in the loop body keep their semantics.

Regex behaviour pinned by `scripts/r6_trailing_plan_regex_test.py`
(updated separately for the new list-tail case).
This commit is contained in:
danielhanchen 2026-05-18 23:38:47 +00:00
commit fd53dab931

View file

@ -10,6 +10,7 @@ through its OpenAI-compatible /v1/chat/completions endpoint.
import atexit
import contextlib
import itertools as _itertools
import json
import os
import re
@ -72,13 +73,16 @@ _TRAILING_PLAN_INTENT = re.compile(
r")[^.!?\n]*[.!?]?\s*$"
)
_TRAILING_PLAN_LIST = re.compile(
r"(?ims)"
# No `m` flag: terminal `\s*$` must match end-of-string, not end-of-line.
# With `m` an answer like "1. one\n2. two\n\nDone." would still match on
# the list block and trigger a spurious auto-continue.
r"(?i)"
r"(?:let me|i[']ll|i will|i[']m going to|i am going to|"
r"here[']?s (?:my |the |a )?(?:plan|approach|steps?)|"
r"as follows|the (?:plan|steps?) (?:is|are))"
r"[^:\n]{0,160}:\s*\n"
r"(?:\s*(?:[-*•]|\d+\.)\s+[^\n]+\n?)+"
r"\s*$"
r"\s*\Z"
)
_TRAILING_PLAN_COLON = re.compile(
r"(?i)(?:let me|i[']ll|i will|i[']m going to|i am going to|"
@ -4070,11 +4074,19 @@ class LlamaCppBackend:
# not steal the tool-coercive re-prompt budget.
_continue_count = 0
# Reserve extra iterations for re-prompts and continues so they
# don't consume the caller's tool-call budget. Only add the
# extra slots when tool iterations are actually allowed.
_extra = _MAX_REPROMPTS + _MAX_CONTINUES if max_tool_iterations > 0 else 0
for iteration in range(max_tool_iterations + _extra):
# Grant headroom for re-prompts and continues only as they're
# actually consumed, so the caller's tool-iteration cap is
# respected end-to-end. Each consumed re-prompt or continue
# raises the effective cap by exactly one slot, so the total
# never exceeds max_tool_iterations + reprompts + continues
# (bounded above by max_tool_iterations + _MAX_REPROMPTS +
# _MAX_CONTINUES). itertools.count keeps `continue` semantics
# intact in the loop body below.
for iteration in _itertools.count():
if iteration >= (
max_tool_iterations + _reprompt_count + _continue_count
):
break
if cancel_event is not None and cancel_event.is_set():
return