Skip to content

Commit 1f0f927

Browse files
committed
fix(agent): add holding pattern supervisor detection to prevent conversational stalls
1 parent df2fad2 commit 1f0f927

1 file changed

Lines changed: 38 additions & 16 deletions

File tree

src/agent_loop.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3262,6 +3262,12 @@ async def stream_agent_loop(
32623262
r"\b[^.\n]{0,140}",
32633263
re.IGNORECASE,
32643264
)
3265+
_HOLDING_RE = re.compile(
3266+
r"(?:please\s+)?(?:hold\s+on|wait)\b|one\s+moment\b|just\s+a\s+(?:moment|second|minute)\b|"
3267+
r"\bi\s+am\s+(?:now\s+)?(?:reading|fetching|searching|investigating|scanning|processing)\b|"
3268+
r"\bi'?m\s+(?:now\s+)?(?:reading|fetching|searching|investigating|scanning|processing)\b",
3269+
re.IGNORECASE,
3270+
)
32653271
_awaiting_user = False # set by ask_user → end the turn and wait for a choice
32663272

32673273
# Document streaming state (persists across rounds)
@@ -3819,32 +3825,44 @@ async def stream_agent_loop(
38193825
# tool doesn't pin us in a forever loop.
38203826
_intent_text = _strip_think_blocks(cleaned_round).strip()
38213827
_intent_match = _INTENT_RE.search(_intent_text) if _intent_text else None
3828+
_has_holding = bool(_intent_text and _HOLDING_RE.search(_intent_text))
38223829
# Only nudge when the round REALLY looks like an unfinished
38233830
# promise: short response (<400 chars), no fenced code/answer,
38243831
# and an action-intent phrase was matched. Long answers that
38253832
# happen to contain "let me know" are not stalls.
3833+
# However, if a holding/waiting pattern is matched, we bypass
3834+
# the length restriction because "please hold" or "I am reading"
3835+
# without tools always stalls the run regardless of response length.
38263836
_looks_like_promise = (
38273837
not guide_only
3828-
and _intent_match is not None
3829-
and len(_intent_text) < 400
3838+
and (_intent_match is not None or _has_holding)
3839+
and (len(_intent_text) < 400 or _has_holding)
38303840
and "```" not in _intent_text
38313841
)
38323842
if _looks_like_promise and _intent_nudge_count < _MAX_INTENT_NUDGES:
38333843
_intent_nudge_count += 1
3834-
_matched_phrase = _intent_match.group(0).strip()
3835-
logger.info(f"[agent] intent-without-action nudge #{_intent_nudge_count} on round {round_num}: {_matched_phrase!r}")
3836-
_lower_phrase = _matched_phrase.lower()
3837-
_cookbook_log_hint = ""
3838-
if any(_word in _lower_phrase for _word in ("log", "logs", "output", "tail", "status")):
3839-
_cookbook_log_hint = (
3840-
" If this is about a Cookbook/model serve, the concrete calls are: "
3841-
"`list_served_models` first, then `tail_serve_output` with the "
3842-
"session_id from the serve/list result. Never answer with "
3843-
"\"check logs\" when those tools are available."
3844+
if _has_holding:
3845+
logger.info(f"[agent] intent-without-action holding pattern nudge #{_intent_nudge_count} on round {round_num}")
3846+
_nudge_msg = (
3847+
"You wrote a holding/waiting pattern or stated that you are currently performing "
3848+
"an action (e.g. reading, searching, wait, hold on) but ended the turn without making any tool calls. "
3849+
"In this environment, you must emit the tool calls (such as `read_email` with the relevant UIDs) "
3850+
"directly in the same turn so they can execute. You cannot 'hold' or 'wait' for a future turn "
3851+
"without calling a tool first. Call the actual tools now to continue the task."
38443852
)
3845-
messages.append({
3846-
"role": "system",
3847-
"content": (
3853+
else:
3854+
_matched_phrase = _intent_match.group(0).strip()
3855+
logger.info(f"[agent] intent-without-action nudge #{_intent_nudge_count} on round {round_num}: {_matched_phrase!r}")
3856+
_lower_phrase = _matched_phrase.lower()
3857+
_cookbook_log_hint = ""
3858+
if any(_word in _lower_phrase for _word in ("log", "logs", "output", "tail", "status")):
3859+
_cookbook_log_hint = (
3860+
" If this is about a Cookbook/model serve, the concrete calls are: "
3861+
"`list_served_models` first, then `tail_serve_output` with the "
3862+
"session_id from the serve/list result. Never answer with "
3863+
"\"check logs\" when those tools are available."
3864+
)
3865+
_nudge_msg = (
38483866
f"You just wrote: \"{_matched_phrase}\" — but ended the "
38493867
"turn without making the actual tool call. The user can "
38503868
"see you announced the action but didn't run it, which "
@@ -3853,7 +3871,11 @@ async def stream_agent_loop(
38533871
f"{_cookbook_log_hint}"
38543872
"If you decided not to do it after all, say so plainly in "
38553873
"one sentence instead of restating the plan."
3856-
),
3874+
)
3875+
3876+
messages.append({
3877+
"role": "system",
3878+
"content": _nudge_msg,
38573879
})
38583880
# Visible signal in the stream so the user knows we caught it.
38593881
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'

0 commit comments

Comments
 (0)