Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,30 @@ def init_agent(
agent._execution_thread_id: int | None = None # Set at run_conversation() start
agent._interrupt_thread_signal_pending = False
agent._client_lock = threading.RLock()

# Repetition-loop detection state (see agent/loop_detector.py). The config is
# parsed once here so the streaming hot path never re-reads YAML.
agent._loop_detected = False
agent._loop_detected_reason = ""
agent._loop_retry_count = 0
agent._loop_guard_total = 0 # session tally of loops caught (grep LOOP_GUARD in agent.log)
agent._larp_guard_total = 0 # session tally of LARPs caught (grep LARP_GUARD)
agent._turns_since_compaction = None # turns since last context compaction (LARP post-compaction window); None until first compaction
agent._active_loop_detector = None
try:
from agent.loop_detector import load_loop_detection_config

agent._loop_detection_cfg = load_loop_detection_config()
except Exception:
agent._loop_detection_cfg = None

agent._active_reasoning_loop_detector = None
try:
from agent.loop_detector import load_reasoning_loop_detection_config

agent._reasoning_loop_detection_cfg = load_reasoning_loop_detection_config()
except Exception:
agent._reasoning_loop_detection_cfg = None
agent._model_request_active = threading.Event()
agent._supports_active_turn_redirect = True

Expand Down
32 changes: 32 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
from agent.error_classifier import FailoverReason
from agent.errors import EmptyStreamError
from agent.loop_detector import feed_content_delta, feed_reasoning_delta
from agent.turn_context import substitute_api_content
from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint
Expand Down Expand Up @@ -2450,6 +2451,23 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted before streaming API call")

# Per-call repetition-loop detectors (None when disabled -> zero overhead).
# Fed from every streaming path below (chat-completions deltas, Anthropic
# native text/thinking blocks); on a trip they reuse the interrupt abort path.
try:
from agent.loop_detector import (
build_reasoning_loop_detector,
build_stream_loop_detector,
)

agent._loop_detected = False
agent._loop_detected_reason = ""
agent._active_loop_detector = build_stream_loop_detector(agent)
agent._active_reasoning_loop_detector = build_reasoning_loop_detector(agent)
except Exception:
agent._active_loop_detector = None
agent._active_reasoning_loop_detector = None

# Cron and other non-interactive, nested-pool contexts deadlock on the
# spawned worker thread (#62151). They also have no stream consumer, so the
# deltas this path produces go nowhere. Delegate to the non-streaming entry
Expand Down Expand Up @@ -2551,6 +2569,7 @@ def _open_bedrock_stream(next_api_kwargs: dict[str, Any]):
return raw_response.get("stream", [])

def _on_text(text):
feed_content_delta(agent, text)
_fire_first()
agent._fire_stream_delta(text)
deltas_were_sent["yes"] = True
Expand All @@ -2560,6 +2579,7 @@ def _on_tool(name):
agent._fire_tool_gen_started(name)

def _on_reasoning(text):
feed_reasoning_delta(agent, text)
_fire_first()
agent._fire_reasoning_delta(text)

Expand Down Expand Up @@ -3162,12 +3182,18 @@ def _relay_final_response() -> dict[str, Any]:
reasoning_text = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None)
if reasoning_text:
reasoning_parts.append(reasoning_text)
# Reasoning-trace loop detection. On a trip this reuses the
# interrupt/abort path; the conversation loop discards the
# looped partial and re-prompts.
feed_reasoning_delta(agent, reasoning_text)
_fire_first_delta()
agent._fire_reasoning_delta(reasoning_text)

# Accumulate text content — fire callback only when no tool calls
if delta and delta.content:
content_parts.append(delta.content)
# Repetition-loop detection (content channel).
feed_content_delta(agent, delta.content)
if not tool_calls_acc:
_fire_first_delta()
agent._fire_stream_delta(delta.content)
Expand Down Expand Up @@ -3575,13 +3601,19 @@ def _accept_anthropic_event(_event: Any) -> bool:
delta_type = getattr(delta, "type", None)
if delta_type == "text_delta":
text = getattr(delta, "text", "")
# Loop detection runs on the native Anthropic text
# channel too — fed before the display-suppression
# check so a tool-use turn is still guarded.
if text:
feed_content_delta(agent, text)
if text and not has_tool_use:
_fire_first_delta()
agent._fire_stream_delta(text)
deltas_were_sent["yes"] = True
elif delta_type == "thinking_delta":
thinking_text = getattr(delta, "thinking", "")
if thinking_text:
feed_reasoning_delta(agent, thinking_text)
_fire_first_delta()
agent._fire_reasoning_delta(thinking_text)
if not agent._interrupt_requested:
Expand Down
12 changes: 10 additions & 2 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3146,6 +3146,7 @@ def _bullets(items: list[str], limit: int = 8) -> str:
Recovered from a deterministic fallback because the LLM context summarizer was unavailable. Continue from the protected recent messages after this summary and use current file/system state for exact details.{previous_summary_note}

## Constraints & Preferences
- Execution rule: perform actions with tool calls. Do not report an action as done, or say you are doing/dispatching it, unless a tool call performed it this turn.
- This fallback was generated locally without an LLM summary call.
- Secrets and credentials were redacted before preservation.
- The summary may be incomplete; prefer verifying current files, git state, processes, and test results instead of assuming omitted details.
Expand Down Expand Up @@ -3380,7 +3381,11 @@ def _generate_summary(
If no outstanding task exists, write "None."]"""
_goal_instructions = "[What the user is trying to accomplish overall]"
_constraints_instructions = (
"[User preferences, coding style, constraints, important decisions]"
"[User preferences, coding style, constraints, important decisions. "
"ALWAYS include, as the first bullet, this exact line: \"Execution "
"rule: perform actions with tool calls — never report an action as "
"done, or announce you are doing/dispatching it, unless a tool call "
'performs it this turn."]'
)
_resolved_questions_instructions = (
"[Questions the user asked that were ALREADY answered — include the "
Expand Down Expand Up @@ -3411,7 +3416,10 @@ def _generate_summary(
)
_constraints_instructions = (
"[Runtime, configuration, and technical constraints only. Do not "
"invent user preferences.]"
"invent user preferences. ALWAYS include, as the first bullet, this "
'exact line: "Execution rule: perform actions with tool calls — never '
"report an action as done, or announce you are doing/dispatching it, "
'unless a tool call performs it this turn."]'
)
_resolved_questions_instructions = (
"[Write exactly: None. No user-authored questions exist.]"
Expand Down
4 changes: 4 additions & 0 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -2321,6 +2321,10 @@ def _release_lock() -> None:
# rewrite on the same id) when compaction happened in place. See #38763.
agent._last_compression_attempt_in_place = compacted_in_place
agent._last_compaction_in_place = compacted_in_place
# LARPing spikes right after compaction (the summary reads as completed-
# action prose with the tool calls stripped, and the model imitates it).
# Mark the window so larp_detection.post_compaction_window can react.
agent._turns_since_compaction = 0

# Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
Expand Down
125 changes: 124 additions & 1 deletion agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3320,6 +3320,72 @@ def _perform_api_call(next_api_kwargs):
break # Success, exit retry loop

except InterruptedError:
# A repetition-loop abort reuses the interrupt path but sets
# agent._loop_detected. Recover instead of treating it as a user
# stop: discard the looped partial (NO history poisoning) and
# retry with a nudge, bounded by max_retries.
if getattr(agent, "_loop_detected", False):
agent._loop_detected = False
agent._interrupt_requested = False
try:
agent._current_streamed_assistant_text = ""
except Exception:
pass
try:
agent._reset_stream_delivery_tracking()
except Exception:
pass
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
_ld_cfg = getattr(agent, "_loop_detection_cfg", None)
_ld_max = getattr(_ld_cfg, "max_retries", 2) if _ld_cfg is not None else 2
agent._loop_retry_count = getattr(agent, "_loop_retry_count", 0) + 1
_ld_reason = getattr(agent, "_loop_detected_reason", "") or "repetition"
agent._loop_guard_total = getattr(agent, "_loop_guard_total", 0) + 1
logger.warning(
"LOOP_GUARD: tripped reason=%r retry=%d/%d total=%d session=%s",
_ld_reason, agent._loop_retry_count, _ld_max,
agent._loop_guard_total, getattr(agent, "session_id", "?"),
)
# Surface the trip as an out-of-band notice so GUI drivers
# (desktop toast, TUI status bar) show it — not just agent.log.
from agent.credits_tracker import AgentNotice
agent._emit_notice(AgentNotice(
text=f"Loop guard stopped a repetition ({_ld_reason}).",
level="warn", kind="ttl", ttl_ms=6000, key="guard.loop",
))
if agent._loop_retry_count > _ld_max:
logger.warning(
"LOOP_GUARD: exhausted after %d retries; returning fallback. session=%s",
_ld_max, getattr(agent, "session_id", "?"),
)
agent._loop_retry_count = 0
final_response = (
"I detected that I was repeating myself and could not produce a "
"clean answer. Please rephrase or narrow the request."
)
messages.append({"role": "assistant", "content": final_response})
agent._persist_session(messages, conversation_history)
break
try:
agent._vprint(
f"{agent.log_prefix}↻ Repetition loop detected ({_ld_reason}); "
f"retrying {agent._loop_retry_count}/{_ld_max}.",
force=True,
)
except Exception:
pass
# Steer the retry without breaking role alternation: the
# helper piggybacks the nudge onto the trailing user/tool
# message rather than appending a synthetic user turn (two
# user turns in a row break strict chat templates).
from agent.loop_detector import apply_loop_recovery_nudge
apply_loop_recovery_nudge(messages)
retry_count = 0
continue
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
Expand Down Expand Up @@ -6895,8 +6961,65 @@ def _perform_api_call(next_api_kwargs):
final_response = None
continue

# ── LARP guard (opt-in, default OFF) ───────────────────
# If the model claimed an action but made no matching tool call
# this turn, re-prompt instead of finalizing. A claim alongside a
# FAILED tool is honest narration of a broken tool -> passes
# through. Bounded by max_reprompts.
try:
from agent.larp_detection import (
build_larp_nudge,
larp_detection_enabled,
)

if larp_detection_enabled(agent=agent):
_larp_nudge = build_larp_nudge(
messages=messages,
final_response=final_response,
agent=agent,
attempts=getattr(agent, "_larp_reprompts", 0),
)
else:
_larp_nudge = None
except Exception:
logger.debug("LARP detection check failed", exc_info=True)
_larp_nudge = None

if _larp_nudge:
agent._larp_reprompts = getattr(agent, "_larp_reprompts", 0) + 1
final_msg["finish_reason"] = "larp_reprompt"
messages.append(final_msg)
messages.append({
"role": "user",
"content": _larp_nudge,
"_larp_reprompt_synthetic": True,
})
agent._session_messages = messages
agent._larp_guard_total = getattr(agent, "_larp_guard_total", 0) + 1
logger.warning(
"LARP_GUARD: reprompt attempt=%d total=%d nudge=%r session=%s",
agent._larp_reprompts, agent._larp_guard_total,
_larp_nudge[:120], getattr(agent, "session_id", "?"),
)
# Surface the reprompt as an out-of-band notice (see loop guard).
from agent.credits_tracker import AgentNotice
agent._emit_notice(AgentNotice(
text=f"LARP guard re-prompted the model (attempt {agent._larp_reprompts}).",
level="warn", kind="ttl", ttl_ms=6000, key="guard.larp",
))
# Same finalizer contract as verify-on-stop / kanban stop:
# clear final_response while continuing so a later budget
# exhaustion path does not treat the un-backed claim as a
# completed answer.
_pending_verification_response = final_response
_pending_verification_response_previewed = (
agent._interim_content_was_streamed(final_response or "")
)
final_response = None
continue

messages.append(final_msg)

_turn_exit_reason = f"text_response(finish_reason={finish_reason})"
if not agent.quiet_mode:
agent._safe_print(f"🎉 Conversation completed after {api_call_count} OpenAI-compatible API call(s)")
Expand Down
Loading