From fd53dab93146f8b6e75c5ce17948da2deb0932a0 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 18 May 2026 23:38:47 +0000 Subject: [PATCH] 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). --- studio/backend/core/inference/llama_cpp.py | 26 ++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 1356232a38..e6cd877896 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -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