Skip to content

Commit cc53fbd

Browse files
committed
feat: add identical-tool-call stuck-loop backstop independent of errors
The existing max_consecutive_failures backstop only trips when every tool call in a batch reports is_error=True, so it can't catch a degenerate loop where a tool falsely reports success on a call that never made progress (observed: a stuck agent burning 15 minutes and 142k tokens of context with no backstop firing). Add max_consecutive_identical_calls (default 10), tracked from the toolset's own identical-argument repeat streak rather than each call's reported success/failure, as a second independent backstop.
1 parent ec0348d commit cc53fbd

6 files changed

Lines changed: 141 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- Cap context-compaction summary output length so a slow/degenerate local
19+
model completion (observed hanging the soul loop indefinitely on local
20+
OpenAI-compatible backends) can no longer run unbounded.
21+
- Send Qwen3.x's binary `enable_thinking` chat-template toggle instead of a
22+
tiered `reasoning_effort` value on self-hosted openai_legacy endpoints
23+
(llama.cpp/vLLM/LM Studio), where the model only supports on/off and was
24+
silently promoting every configured effort level to full reasoning.
25+
- Add a second, independent stuck-loop backstop (`max_consecutive_identical_calls`,
26+
default 10) that stops a turn after enough consecutive tool calls with identical
27+
arguments, regardless of whether each call reports success — the existing
28+
all-error backstop can't catch a loop where a tool falsely reports success on a
29+
call that never made progress.
30+
1831
## 0.54.0 (2026-06-30)
1932

2033
- **New `Workflow` tool for deterministic multi-agent orchestration.** The default

src/pythinker_code/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,11 @@ class LoopControl(BaseModel):
586586
call failed (a degenerate stuck loop), instead of continuing to
587587
``max_steps_per_turn``. The turn ends with a ``stuck`` outcome and a handoff
588588
summary of what was tried. ``0`` disables the backstop. Default: 8."""
589+
max_consecutive_identical_calls: int = Field(default=10, ge=0)
590+
"""Yield to the user after this many consecutive tool calls with identical
591+
arguments, even if each call reports success. Tracked independently of
592+
``max_consecutive_failures`` so a tool that falsely reports success on a call
593+
that made no progress can't defeat the backstop. ``0`` disables it. Default: 10."""
589594
max_truncation_recoveries: int = Field(default=3, ge=0)
590595
"""When a model response is cut off by the output-token limit and makes no tool call,
591596
nudge the model to continue at most this many times per turn before surfacing the

src/pythinker_code/soul/pythinkersoul.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -388,12 +388,17 @@ def _should_nudge_truncation(
388388

389389

390390
def _stuck_summary_message(
391-
failures: int, tool_calls: Sequence[ToolCall], tool_results: Sequence[ToolResult]
391+
count: int,
392+
tool_calls: Sequence[ToolCall],
393+
tool_results: Sequence[ToolResult],
394+
*,
395+
reason: str = "steps each had every tool call fail",
392396
) -> Message:
393397
"""Build a concise handoff message when the loop yields on a degenerate stuck loop.
394398
395-
Surfaces a count of consecutive all-error steps and a brief of what the last
396-
step tried, so the human can take over without reconstructing state.
399+
Surfaces a count (consecutive all-error steps, or consecutive identical calls,
400+
per ``reason``) and a brief of what the last step tried, so the human can take
401+
over without reconstructing state.
397402
"""
398403
calls_by_id = {call.id: call for call in tool_calls}
399404
tried: list[str] = []
@@ -409,8 +414,8 @@ def _stuck_summary_message(
409414
brief = brief[:200] + "…"
410415
tried.append(f"- {name}: {brief}")
411416
text = (
412-
f"I appear to be stuck — the last {failures} steps each had every tool call "
413-
"fail, so I'm stopping and handing control back to you rather than continuing.\n\n"
417+
f"I appear to be stuck — the last {count} {reason}, so I'm stopping and "
418+
"handing control back to you rather than continuing.\n\n"
414419
"What I last tried:\n" + "\n".join(tried) + "\n\n"
415420
"You can adjust the request, fix the underlying issue, or tell me how to proceed."
416421
)
@@ -2197,6 +2202,32 @@ async def _pythinker_core_step_with_retry() -> StepResult:
21972202
return StepOutcome(stop_reason="stuck", assistant_message=summary)
21982203
else:
21992204
self._consecutive_failures = 0
2205+
2206+
# Second, independent backstop: the same tool call repeated with
2207+
# identical arguments enough times in a row, regardless of whether
2208+
# each call reports success — catches a tool that falsely reports
2209+
# success on a call that never made progress, which the all-error
2210+
# check above can't see.
2211+
repeat_threshold = self._loop_control.max_consecutive_identical_calls
2212+
if repeat_threshold and isinstance(self._agent.toolset, PythinkerToolset):
2213+
repeat_count = self._agent.toolset.consecutive_repeat_count
2214+
if repeat_count >= repeat_threshold:
2215+
from pythinker_code.telemetry import track
2216+
2217+
summary = _stuck_summary_message(
2218+
repeat_count,
2219+
result.tool_calls,
2220+
results,
2221+
reason="tool calls were identical",
2222+
)
2223+
await self._context.append_message(summary)
2224+
wire_send(TextPart(text=summary.extract_text(" ")))
2225+
track(
2226+
"agent_stuck_repeat",
2227+
consecutive_repeat_calls=repeat_count,
2228+
model=self._runtime.llm.model_name,
2229+
)
2230+
return StepOutcome(stop_reason="stuck", assistant_message=summary)
22002231
return None
22012232

22022233
# A tool-call-free message normally ends the turn. If it is only a

src/pythinker_code/soul/toolset.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,16 @@ def dedup_triggered(self) -> bool:
818818
"""Whether a cross-step duplicate was blocked in the current step."""
819819
return self._dedup_triggered
820820

821+
@property
822+
def consecutive_repeat_count(self) -> int:
823+
"""Length of the current streak of identical-argument tool calls.
824+
825+
Tracked independently of each call's reported success/failure, so it
826+
still catches a degenerate loop even if a tool falsely reports success
827+
on a call that made no progress.
828+
"""
829+
return self._consecutive_count
830+
821831
def handle(self, tool_call: ToolCall) -> HandleResult:
822832
token = current_tool_call.set(tool_call)
823833
try:

tests/core/test_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def test_default_config_dump():
5050
"loop_control": {
5151
"max_steps_per_turn": 1000,
5252
"max_consecutive_failures": 8,
53+
"max_consecutive_identical_calls": 10,
5354
"max_truncation_recoveries": 3,
5455
"max_compaction_failures": 1,
5556
"max_session_cost_usd": None,

tests/core/test_pythinkersoul_stuck_loop.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from pythinker_code.soul.agent import Agent, BuiltinSystemPromptArgs, Runtime
2727
from pythinker_code.soul.context import Context
2828
from pythinker_code.soul.pythinkersoul import PythinkerSoul, TurnStopReason
29+
from pythinker_code.soul.toolset import PythinkerToolset
2930
from pythinker_code.utils.aioqueue import QueueShutDown
3031
from pythinker_code.wire import Wire
3132

@@ -159,6 +160,43 @@ def _make_soul(
159160
return context, soul
160161

161162

163+
def _make_soul_with_pythinker_toolset(
164+
runtime: Runtime, provider: _ScriptedToolCallProvider, tmp_path: Path
165+
) -> tuple[Context, PythinkerSoul]:
166+
"""Like `_make_soul`, but with a real `PythinkerToolset` — required to exercise the
167+
identical-call repeat backstop, which is tracked on `PythinkerToolset` specifically."""
168+
llm = LLM(chat_provider=provider, max_context_size=100_000, capabilities=set())
169+
runtime = Runtime(
170+
config=runtime.config,
171+
llm=llm,
172+
session=runtime.session,
173+
builtin_args=runtime.builtin_args,
174+
denwa_renji=runtime.denwa_renji,
175+
approval=runtime.approval,
176+
labor_market=runtime.labor_market,
177+
environment=runtime.environment,
178+
notifications=runtime.notifications,
179+
background_tasks=runtime.background_tasks,
180+
skills=runtime.skills,
181+
oauth=runtime.oauth,
182+
additional_dirs=runtime.additional_dirs,
183+
skills_dirs=runtime.skills_dirs,
184+
role=runtime.role,
185+
)
186+
toolset = PythinkerToolset()
187+
toolset.add(_BoomTool())
188+
toolset.add(_OkTool())
189+
agent = Agent(
190+
name="Stuck Test Agent",
191+
system_prompt="Stuck test prompt.",
192+
toolset=toolset,
193+
runtime=runtime,
194+
)
195+
context = Context(file_backend=tmp_path / "history.jsonl")
196+
soul = PythinkerSoul(agent, context=context)
197+
return context, soul
198+
199+
162200
async def _drain_ui_messages(wire: Wire) -> None:
163201
wire_ui = wire.ui_side(merge=True)
164202
while True:
@@ -506,6 +544,44 @@ async def test_max_consecutive_failures_zero_disables_backstop(
506544
assert record_turn.call_args.kwargs["stop_reason"] == "no_tool_calls"
507545

508546

547+
@pytest.mark.asyncio
548+
async def test_consecutive_identical_calls_yield_stuck_outcome(
549+
runtime: Runtime, tmp_path: Path
550+
) -> None:
551+
"""N consecutive identical-argument tool calls stop the turn with `stuck`, even
552+
though every call reports success — this backstop is independent of the
553+
all-error check above, so it still catches a tool that falsely reports success
554+
on a call that made no progress."""
555+
runtime.config.loop_control.max_consecutive_identical_calls = 3
556+
runtime.config.loop_control.max_steps_per_turn = 50
557+
provider = _ScriptedToolCallProvider(["Ok"] * 10)
558+
context, soul = _make_soul_with_pythinker_toolset(runtime, provider, tmp_path)
559+
560+
with patch("pythinker_code.telemetry.metrics.record_turn") as record_turn:
561+
await run_soul(soul, "go", _drain_ui_messages, asyncio.Event())
562+
563+
assert provider.generate_attempts == 3
564+
assert record_turn.call_args.kwargs["stop_reason"] == "stuck"
565+
assert "identical" in context.history[-1].extract_text(" ").lower()
566+
567+
568+
@pytest.mark.asyncio
569+
async def test_max_consecutive_identical_calls_zero_disables_backstop(
570+
runtime: Runtime, tmp_path: Path
571+
) -> None:
572+
"""A threshold of 0 disables the identical-call backstop entirely."""
573+
runtime.config.loop_control.max_consecutive_identical_calls = 0
574+
runtime.config.loop_control.max_steps_per_turn = 50
575+
provider = _ScriptedToolCallProvider(["Ok", "Ok", "Ok", "Ok", None])
576+
context, soul = _make_soul_with_pythinker_toolset(runtime, provider, tmp_path)
577+
578+
with patch("pythinker_code.telemetry.metrics.record_turn") as record_turn:
579+
await run_soul(soul, "go", _drain_ui_messages, asyncio.Event())
580+
581+
assert provider.generate_attempts == 5
582+
assert record_turn.call_args.kwargs["stop_reason"] == "no_tool_calls"
583+
584+
509585
@pytest.mark.asyncio
510586
async def test_truncated_response_nudges_continuation(runtime: Runtime, tmp_path: Path) -> None:
511587
"""A response cut off by the output-token limit (no tool calls) nudges the model to

0 commit comments

Comments
 (0)