diff --git a/CHANGELOG.md b/CHANGELOG.md index 13206e33..d625fc42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Cap context-compaction summary output length so a slow/degenerate local + model completion (observed hanging the soul loop indefinitely on local + OpenAI-compatible backends) can no longer run unbounded. +- Send Qwen3.x's binary `enable_thinking` chat-template toggle instead of a + tiered `reasoning_effort` value on self-hosted openai_legacy endpoints + (llama.cpp/vLLM/LM Studio), where the model only supports on/off and was + silently promoting every configured effort level to full reasoning. +- Add a second, independent stuck-loop backstop (`max_consecutive_identical_calls`, + default 10) that stops a turn after enough consecutive tool calls with identical + arguments, regardless of whether each call reports success — the existing + all-error backstop can't catch a loop where a tool falsely reports success on a + call that never made progress. + ## 0.54.0 (2026-06-30) - **New `Workflow` tool for deterministic multi-agent orchestration.** The default diff --git a/packages/pythinker-core/src/pythinker_core/chat_provider/echo/scripted_echo.py b/packages/pythinker-core/src/pythinker_core/chat_provider/echo/scripted_echo.py index d77b13b1..e84b104c 100644 --- a/packages/pythinker-core/src/pythinker_core/chat_provider/echo/scripted_echo.py +++ b/packages/pythinker-core/src/pythinker_core/chat_provider/echo/scripted_echo.py @@ -67,6 +67,13 @@ def with_thinking(self, effort: ThinkingEffort) -> Self: copied._scripts = deque(self._scripts) return copied + def with_generation_kwargs(self, **kwargs: object) -> Self: + # Scripted replay carries no real request body, so generation + # kwargs (e.g. an output-length cap) have nothing to attach to. + copied = copy.copy(self) + copied._scripts = deque(self._scripts) + return copied + class ScriptedEchoStreamedMessage(StreamedMessage): """Streamed message for ScriptedEchoChatProvider.""" diff --git a/packages/pythinker-core/tests/test_scripted_echo_chat_provider.py b/packages/pythinker-core/tests/test_scripted_echo_chat_provider.py index 6b196ec1..b765fa11 100644 --- a/packages/pythinker-core/tests/test_scripted_echo_chat_provider.py +++ b/packages/pythinker-core/tests/test_scripted_echo_chat_provider.py @@ -135,3 +135,15 @@ async def test_scripted_echo_chat_provider_requires_dsl_content(): with pytest.raises(ChatProviderError): await provider.generate(system_prompt="", tools=[], history=[]) + + +async def test_scripted_echo_chat_provider_with_generation_kwargs_preserves_scripts(): + provider = ScriptedEchoChatProvider(["text: first", "text: second"]) + + capped = provider.with_generation_kwargs(max_tokens=4000) + + assert capped is not provider + first_stream = await capped.generate(system_prompt="", tools=[], history=[]) + assert [part async for part in first_stream] == [TextPart(text="first")] + second_stream = await capped.generate(system_prompt="", tools=[], history=[]) + assert [part async for part in second_stream] == [TextPart(text="second")] diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index eb7b0557..e2426b47 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -586,6 +586,11 @@ class LoopControl(BaseModel): call failed (a degenerate stuck loop), instead of continuing to ``max_steps_per_turn``. The turn ends with a ``stuck`` outcome and a handoff summary of what was tried. ``0`` disables the backstop. Default: 8.""" + max_consecutive_identical_calls: int = Field(default=10, ge=0) + """Yield to the user after this many consecutive tool calls with identical + arguments, even if each call reports success. Tracked independently of + ``max_consecutive_failures`` so a tool that falsely reports success on a call + that made no progress can't defeat the backstop. ``0`` disables it. Default: 10.""" max_truncation_recoveries: int = Field(default=3, ge=0) """When a model response is cut off by the output-token limit and makes no tool call, nudge the model to continue at most this many times per turn before surfacing the diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index ebbe8ba1..f149d08b 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -61,6 +61,31 @@ def model_name(self) -> str: return self.chat_provider.model_name +# Providers whose generation kwargs use `max_output_tokens` instead of the +# more common `max_tokens` (OpenAI Chat-Completions-shaped and Anthropic +# providers, plus the first-party `pythinker` provider, all use `max_tokens`). +_MAX_OUTPUT_TOKENS_KWARG_OVERRIDES: dict[str, str] = { + "openai_responses": "max_output_tokens", + "openai_codex": "max_output_tokens", + "google_genai": "max_output_tokens", + "gemini": "max_output_tokens", + "vertexai": "max_output_tokens", +} + + +def capped_chat_provider(llm: LLM, max_output_tokens: int) -> ChatProvider: + """Return a copy of ``llm.chat_provider`` with its output length capped. + + Callers needing a small, predictable cap regardless of the model's + usual output budget (e.g. a context-compaction summary) can use this + instead of hand-picking a provider-specific kwarg name. + """ + provider_config = getattr(llm, "provider_config", None) + provider_type = getattr(provider_config, "type", None) + kwarg = _MAX_OUTPUT_TOKENS_KWARG_OVERRIDES.get(provider_type or "", "max_tokens") + return cast(Any, llm.chat_provider).with_generation_kwargs(**{kwarg: max_output_tokens}) + + # Hosts that serve the genuine Anthropic API and therefore accept the # `tool_reference` / `defer_loading` beta content blocks that deferred tool # search depends on. The Claude API-key path and Anthropic OAuth both route @@ -490,12 +515,22 @@ def create_llm( and not is_dashscope_legacy ) is_glm_openai_legacy = provider.type == "openai_legacy" and _is_glm_model(model.model) + # Qwen3.x exposes a binary `enable_thinking` template toggle, not tiered + # reasoning effort; DashScope's own hosted endpoint already sends + # `enable_thinking` below, so only apply this for other openai_legacy + # routes (e.g. local llama.cpp/vLLM/LM Studio servers). + is_qwen3_openai_legacy = ( + provider.type == "openai_legacy" + and _is_qwen3_model(model.model) + and not is_dashscope_legacy + ) if ( effective_effort is not None and supports_thinking and not is_kimi_openai_legacy and not is_glm_openai_legacy and not is_dashscope_legacy + and not is_qwen3_openai_legacy ): # Only explicitly send thinking controls for models that advertise # reasoning. Some OpenAI-compatible non-reasoning models reject even a @@ -526,6 +561,13 @@ def create_llm( extra_body={"enable_thinking": thinking_on} ) + # Self-hosted Qwen3.x servers (llama.cpp/vLLM/LM Studio) take the same + # `enable_thinking` toggle via the template-kwargs extra_body shape. + if is_qwen3_openai_legacy and effective_effort is not None: + chat_provider = cast(Any, chat_provider).with_generation_kwargs( + extra_body={"chat_template_kwargs": {"enable_thinking": thinking_on}} + ) + # Apply Pythinker AI-specific ``thinking.keep`` (preserved thinking) only when # the model is actually in thinking mode; otherwise the API would see a # ``thinking.keep`` without an accompanying ``thinking.type`` it honors. @@ -663,6 +705,14 @@ def _is_glm_model(model_name: str) -> bool: return model_name.lower().replace("_", "-").startswith("glm-") +def _is_qwen3_model(model_name: str) -> bool: + """Qwen3.x (dense and MoE) models only support the chat template's + binary `enable_thinking` toggle, not tiered reasoning effort levels. + """ + normalized = model_name.lower().replace("_", "-") + return "qwen3" in normalized or "qwen-3" in normalized + + def _is_dashscope_endpoint(base_url: str) -> bool: """True for any Alibaba DashScope endpoint (standard, intl, workspace).""" from urllib.parse import urlparse diff --git a/src/pythinker_code/soul/compaction.py b/src/pythinker_code/soul/compaction.py index 5b4b393e..3e0975f4 100644 --- a/src/pythinker_code/soul/compaction.py +++ b/src/pythinker_code/soul/compaction.py @@ -9,7 +9,7 @@ from pythinker_core.tooling.empty import EmptyToolset import pythinker_code.prompts as prompts -from pythinker_code.llm import LLM +from pythinker_code.llm import LLM, capped_chat_provider from pythinker_code.soul.api_errors import is_context_overflow_error from pythinker_code.soul.message import system from pythinker_code.utils.logging import logger @@ -87,6 +87,11 @@ def should_prune(token_count: int, max_context_size: int, *, ratio: float) -> bo PRUNE_PLACEHOLDER = "[tool output elided to save context: {n} chars]" CAP_PLACEHOLDER = "\n[tool output capped: removed {n} chars]" +# A compaction summary is a short digest, not a full response — cap it well +# below the model's normal output budget so a degenerate/repetitive +# completion can't run unbounded and hang the soul loop. +_COMPACTION_MAX_OUTPUT_TOKENS = 4000 + def prune_stale_tool_outputs( messages: Sequence[Message], *, protect_last: int, min_chars: int @@ -270,16 +275,15 @@ async def _summarize_to_message( half is dropped and the request retried; ``(None, None)`` means even a single message did not fit. - NOTE: the summary length is bounded by the chat provider's - construction-time max output tokens (LLM default_max_tokens, or - PYTHINKER_MODEL_MAX_TOKENS). A tighter per-call cap would require a - max-tokens parameter on ``ChatProvider.generate`` (and - ``pythinker_core.step``), which neither exposes today. + The summary's output length is capped at ``_COMPACTION_MAX_OUTPUT_TOKENS`` + via a per-call generation kwarg (see ``capped_chat_provider``), so a + slow/degenerate completion can't run unbounded. """ + capped_provider = capped_chat_provider(llm, _COMPACTION_MAX_OUTPUT_TOKENS) while True: try: result = await pythinker_core.step( - chat_provider=llm.chat_provider, + chat_provider=capped_provider, system_prompt="You are a helpful assistant that compacts conversation context.", toolset=EmptyToolset(), history=[compact_message], diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index d0553ae0..ea65f1ff 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -388,12 +388,17 @@ def _should_nudge_truncation( def _stuck_summary_message( - failures: int, tool_calls: Sequence[ToolCall], tool_results: Sequence[ToolResult] + count: int, + tool_calls: Sequence[ToolCall], + tool_results: Sequence[ToolResult], + *, + reason: str = "steps each had every tool call fail", ) -> Message: """Build a concise handoff message when the loop yields on a degenerate stuck loop. - Surfaces a count of consecutive all-error steps and a brief of what the last - step tried, so the human can take over without reconstructing state. + Surfaces a count (consecutive all-error steps, or consecutive identical calls, + per ``reason``) and a brief of what the last step tried, so the human can take + over without reconstructing state. """ calls_by_id = {call.id: call for call in tool_calls} tried: list[str] = [] @@ -409,8 +414,8 @@ def _stuck_summary_message( brief = brief[:200] + "…" tried.append(f"- {name}: {brief}") text = ( - f"I appear to be stuck — the last {failures} steps each had every tool call " - "fail, so I'm stopping and handing control back to you rather than continuing.\n\n" + f"I appear to be stuck — the last {count} {reason}, so I'm stopping and " + "handing control back to you rather than continuing.\n\n" "What I last tried:\n" + "\n".join(tried) + "\n\n" "You can adjust the request, fix the underlying issue, or tell me how to proceed." ) @@ -2197,6 +2202,32 @@ async def _pythinker_core_step_with_retry() -> StepResult: return StepOutcome(stop_reason="stuck", assistant_message=summary) else: self._consecutive_failures = 0 + + # Second, independent backstop: the same tool call repeated with + # identical arguments enough times in a row, regardless of whether + # each call reports success — catches a tool that falsely reports + # success on a call that never made progress, which the all-error + # check above can't see. + repeat_threshold = self._loop_control.max_consecutive_identical_calls + if repeat_threshold and isinstance(self._agent.toolset, PythinkerToolset): + repeat_count = self._agent.toolset.consecutive_repeat_count + if repeat_count >= repeat_threshold: + from pythinker_code.telemetry import track + + summary = _stuck_summary_message( + repeat_count, + result.tool_calls, + results, + reason="tool calls were identical", + ) + await self._context.append_message(summary) + wire_send(TextPart(text=summary.extract_text(" "))) + track( + "agent_stuck_repeat", + consecutive_repeat_calls=repeat_count, + model=self._runtime.llm.model_name, + ) + return StepOutcome(stop_reason="stuck", assistant_message=summary) return None # A tool-call-free message normally ends the turn. If it is only a diff --git a/src/pythinker_code/soul/toolset.py b/src/pythinker_code/soul/toolset.py index 83cb5858..a9c4a2fb 100644 --- a/src/pythinker_code/soul/toolset.py +++ b/src/pythinker_code/soul/toolset.py @@ -818,6 +818,16 @@ def dedup_triggered(self) -> bool: """Whether a cross-step duplicate was blocked in the current step.""" return self._dedup_triggered + @property + def consecutive_repeat_count(self) -> int: + """Length of the current streak of identical-argument tool calls. + + Tracked independently of each call's reported success/failure, so it + still catches a degenerate loop even if a tool falsely reports success + on a call that made no progress. + """ + return self._consecutive_count + def handle(self, tool_call: ToolCall) -> HandleResult: token = current_tool_call.set(tool_call) try: diff --git a/tests/core/test_compaction_overflow.py b/tests/core/test_compaction_overflow.py index e2c9bdad..5aafed33 100644 --- a/tests/core/test_compaction_overflow.py +++ b/tests/core/test_compaction_overflow.py @@ -18,7 +18,7 @@ from pythinker_core.chat_provider import APIStatusError from pythinker_core.message import Message -from pythinker_code.llm import LLM +from pythinker_code.llm import LLM, capped_chat_provider from pythinker_code.soul.compaction import SimpleCompaction from pythinker_code.wire.types import TextPart @@ -31,8 +31,59 @@ def _history(n_pairs: int = 4) -> list[Message]: return messages +class _FakeChatProvider: + """Minimal provider double recording `with_generation_kwargs` calls. + + Returns a fresh instance rather than mutating in place, matching the real + providers' copy-on-write contract — so tests must assert on the returned + provider, not the original, to actually exercise that contract. + """ + + def __init__(self, generation_kwargs: dict[str, object] | None = None) -> None: + self.generation_kwargs: dict[str, object] = generation_kwargs or {} + + def with_generation_kwargs(self, **kwargs: object) -> _FakeChatProvider: + return _FakeChatProvider(kwargs) + + def _fake_llm() -> LLM: - return cast(LLM, SimpleNamespace(chat_provider=None)) + return cast(LLM, SimpleNamespace(chat_provider=_FakeChatProvider(), provider_config=None)) + + +def _fake_llm_with_provider_type(provider_type: str) -> LLM: + return cast( + LLM, + SimpleNamespace( + chat_provider=_FakeChatProvider(), + provider_config=SimpleNamespace(type=provider_type), + ), + ) + + +@pytest.mark.parametrize( + ("provider_type", "expected_kwarg"), + [ + ("openai_legacy", "max_tokens"), + ("anthropic", "max_tokens"), + ("pythinker", "max_tokens"), + ("openai_responses", "max_output_tokens"), + # ChatGPT/Codex sessions build the same OpenAIResponses provider as + # "openai_responses" (see create_llm's "openai_codex" case), so they + # take the same max_output_tokens kwarg, not the max_tokens default. + ("openai_codex", "max_output_tokens"), + ("google_genai", "max_output_tokens"), + ("gemini", "max_output_tokens"), + ("vertexai", "max_output_tokens"), + ], +) +def test_capped_chat_provider_picks_kwarg_by_provider_type( + provider_type: str, expected_kwarg: str +) -> None: + llm = _fake_llm_with_provider_type(provider_type) + + capped_provider = cast(_FakeChatProvider, capped_chat_provider(llm, 4000)) + + assert capped_provider.generation_kwargs == {expected_kwarg: 4000} def _overflow_error() -> APIStatusError: @@ -50,9 +101,11 @@ class _FakeStep: def __init__(self, failures_before_success: int) -> None: self.failures_before_success = failures_before_success self.histories: list[list[Message]] = [] + self.chat_providers: list[object] = [] async def __call__(self, *, chat_provider, system_prompt, toolset, history): self.histories.append(list(history)) + self.chat_providers.append(chat_provider) if len(self.histories) <= self.failures_before_success: raise _overflow_error() return _summary_result() @@ -89,6 +142,18 @@ async def test_exhausted_retries_fall_back_to_tail_with_note(monkeypatch) -> Non assert "reply 3" in joined +@pytest.mark.asyncio +async def test_compaction_caps_output_tokens(monkeypatch) -> None: + fake_step = _FakeStep(failures_before_success=0) + monkeypatch.setattr(pythinker_core, "step", fake_step) + + llm = _fake_llm() + await SimpleCompaction(max_preserved_messages=2).compact(_history(), llm=llm) + + used_provider = cast(_FakeChatProvider, fake_step.chat_providers[0]) + assert used_provider.generation_kwargs == {"max_tokens": 4000} + + @pytest.mark.asyncio async def test_non_overflow_error_propagates(monkeypatch) -> None: async def _step_raises(**kwargs): diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 9d56e87e..ccbd734d 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -50,6 +50,7 @@ def test_default_config_dump(): "loop_control": { "max_steps_per_turn": 1000, "max_consecutive_failures": 8, + "max_consecutive_identical_calls": 10, "max_truncation_recoveries": 3, "max_compaction_failures": 1, "max_session_cost_usd": None, diff --git a/tests/core/test_model_switch_carryover.py b/tests/core/test_model_switch_carryover.py index 12d75cb7..1161c9fb 100644 --- a/tests/core/test_model_switch_carryover.py +++ b/tests/core/test_model_switch_carryover.py @@ -23,8 +23,15 @@ from pythinker_code.wire.types import TextPart +class _FakeChatProvider: + """Minimal provider double supporting `with_generation_kwargs`.""" + + def with_generation_kwargs(self, **kwargs: object) -> _FakeChatProvider: + return self + + def _fake_llm() -> LLM: - return cast(LLM, SimpleNamespace(chat_provider=None)) + return cast(LLM, SimpleNamespace(chat_provider=_FakeChatProvider(), provider_config=None)) def _history(n_pairs: int = 3) -> list[Message]: diff --git a/tests/core/test_notifications.py b/tests/core/test_notifications.py index 907ff522..f10ed18c 100644 --- a/tests/core/test_notifications.py +++ b/tests/core/test_notifications.py @@ -75,6 +75,9 @@ async def generate( def with_thinking(self, effort: ThinkingEffort) -> Self: return self + def with_generation_kwargs(self, **kwargs: object) -> Self: + return self + def _runtime_with_llm(runtime: Runtime, llm: LLM) -> Runtime: return Runtime( diff --git a/tests/core/test_openai_provider.py b/tests/core/test_openai_provider.py index 85738f9e..33b538c0 100644 --- a/tests/core/test_openai_provider.py +++ b/tests/core/test_openai_provider.py @@ -188,6 +188,47 @@ def with_thinking(self, effort): # pragma: no cover - should not be called for assert llm.thinking is False +def test_create_llm_sends_qwen3_enable_thinking_toggle(monkeypatch): + captured = {} + + class FakeOpenAILegacy: + def __init__(self, **kwargs): + captured.update(kwargs) + self.model_name = kwargs["model"] + + def with_generation_kwargs(self, **kwargs): + captured["generation_kwargs"] = kwargs + return self + + def with_thinking(self, effort): # pragma: no cover - should not be called for Qwen3 + captured["thinking"] = effort + return self + + monkeypatch.setattr( + "pythinker_core.contrib.chat_provider.openai_legacy.OpenAILegacy", FakeOpenAILegacy + ) + + provider = LLMProvider( + type="openai_legacy", + base_url="http://localhost:1234/v1", + api_key=SecretStr("lm-studio"), + ) + model = LLMModel( + provider="managed:lm-studio", + model="qwen3-30b-a3b-instruct", + max_context_size=262_000, + ) + + llm = create_llm(provider, model, thinking=False) + + assert llm is not None + assert captured["generation_kwargs"] == { + "extra_body": {"chat_template_kwargs": {"enable_thinking": False}} + } + assert "thinking" not in captured + assert llm.thinking is False + + def test_clone_llm_preserves_thinking_state_for_kimi_model_override(monkeypatch): captured = {} diff --git a/tests/core/test_pythinkersoul_stuck_loop.py b/tests/core/test_pythinkersoul_stuck_loop.py index 3df3fa81..73898079 100644 --- a/tests/core/test_pythinkersoul_stuck_loop.py +++ b/tests/core/test_pythinkersoul_stuck_loop.py @@ -26,6 +26,7 @@ from pythinker_code.soul.agent import Agent, BuiltinSystemPromptArgs, Runtime from pythinker_code.soul.context import Context from pythinker_code.soul.pythinkersoul import PythinkerSoul, TurnStopReason +from pythinker_code.soul.toolset import PythinkerToolset from pythinker_code.utils.aioqueue import QueueShutDown from pythinker_code.wire import Wire @@ -127,11 +128,8 @@ async def __call__(self, params: _NoParams) -> ToolReturnValue: return ToolOk(output="ok", message="ok") -def _make_soul( - runtime: Runtime, provider: _ScriptedToolCallProvider, tmp_path: Path -) -> tuple[Context, PythinkerSoul]: - llm = LLM(chat_provider=provider, max_context_size=100_000, capabilities=set()) - runtime = Runtime( +def _rebuild_runtime_with_llm(runtime: Runtime, llm: LLM) -> Runtime: + return Runtime( config=runtime.config, llm=llm, session=runtime.session, @@ -148,6 +146,13 @@ def _make_soul( skills_dirs=runtime.skills_dirs, role=runtime.role, ) + + +def _make_soul( + runtime: Runtime, provider: _ScriptedToolCallProvider, tmp_path: Path +) -> tuple[Context, PythinkerSoul]: + llm = LLM(chat_provider=provider, max_context_size=100_000, capabilities=set()) + runtime = _rebuild_runtime_with_llm(runtime, llm) agent = Agent( name="Stuck Test Agent", system_prompt="Stuck test prompt.", @@ -159,6 +164,27 @@ def _make_soul( return context, soul +def _make_soul_with_pythinker_toolset( + runtime: Runtime, provider: _ScriptedToolCallProvider, tmp_path: Path +) -> tuple[Context, PythinkerSoul]: + """Like `_make_soul`, but with a real `PythinkerToolset` — required to exercise the + identical-call repeat backstop, which is tracked on `PythinkerToolset` specifically.""" + llm = LLM(chat_provider=provider, max_context_size=100_000, capabilities=set()) + runtime = _rebuild_runtime_with_llm(runtime, llm) + toolset = PythinkerToolset() + toolset.add(_BoomTool()) + toolset.add(_OkTool()) + agent = Agent( + name="Stuck Test Agent", + system_prompt="Stuck test prompt.", + toolset=toolset, + runtime=runtime, + ) + context = Context(file_backend=tmp_path / "history.jsonl") + soul = PythinkerSoul(agent, context=context) + return context, soul + + async def _drain_ui_messages(wire: Wire) -> None: wire_ui = wire.ui_side(merge=True) while True: @@ -506,6 +532,44 @@ async def test_max_consecutive_failures_zero_disables_backstop( assert record_turn.call_args.kwargs["stop_reason"] == "no_tool_calls" +@pytest.mark.asyncio +async def test_consecutive_identical_calls_yield_stuck_outcome( + runtime: Runtime, tmp_path: Path +) -> None: + """N consecutive identical-argument tool calls stop the turn with `stuck`, even + though every call reports success — this backstop is independent of the + all-error check above, so it still catches a tool that falsely reports success + on a call that made no progress.""" + runtime.config.loop_control.max_consecutive_identical_calls = 3 + runtime.config.loop_control.max_steps_per_turn = 50 + provider = _ScriptedToolCallProvider(["Ok"] * 10) + context, soul = _make_soul_with_pythinker_toolset(runtime, provider, tmp_path) + + with patch("pythinker_code.telemetry.metrics.record_turn") as record_turn: + await run_soul(soul, "go", _drain_ui_messages, asyncio.Event()) + + assert provider.generate_attempts == 3 + assert record_turn.call_args.kwargs["stop_reason"] == "stuck" + assert "identical" in context.history[-1].extract_text(" ").lower() + + +@pytest.mark.asyncio +async def test_max_consecutive_identical_calls_zero_disables_backstop( + runtime: Runtime, tmp_path: Path +) -> None: + """A threshold of 0 disables the identical-call backstop entirely.""" + runtime.config.loop_control.max_consecutive_identical_calls = 0 + runtime.config.loop_control.max_steps_per_turn = 50 + provider = _ScriptedToolCallProvider(["Ok", "Ok", "Ok", "Ok", None]) + context, soul = _make_soul_with_pythinker_toolset(runtime, provider, tmp_path) + + with patch("pythinker_code.telemetry.metrics.record_turn") as record_turn: + await run_soul(soul, "go", _drain_ui_messages, asyncio.Event()) + + assert provider.generate_attempts == 5 + assert record_turn.call_args.kwargs["stop_reason"] == "no_tool_calls" + + @pytest.mark.asyncio async def test_truncated_response_nudges_continuation(runtime: Runtime, tmp_path: Path) -> None: """A response cut off by the output-token limit (no tool calls) nudges the model to