Skip to content

Commit 840e9b9

Browse files
authored
fix: local-model reliability — compaction hang, Qwen3 reasoning toggle, stuck-loop backstop (#188)
* fix: cap context-compaction output length to prevent unbounded hangs Compaction summarization had no output-length limit, so a slow or degenerate completion from a local model could run unbounded and hang the soul loop indefinitely (observed as a stuck "Compacting..." / "Bloviating..." state). Add capped_chat_provider() to cap the summary call via the correct provider-specific kwarg (max_tokens or max_output_tokens), and wire it into SimpleCompaction. Also fixes ScriptedEchoChatProvider, which was missing with_generation_kwargs entirely. * fix: send Qwen3.x's binary enable_thinking toggle instead of tiered effort Qwen3.x models expose a binary enable_thinking chat-template toggle, not the tiered low/medium/high reasoning_effort values OpenAI/Anthropic providers use. Self-hosted openai_legacy endpoints (llama.cpp/vLLM/LM Studio) were rejecting the tiered value for these models and silently promoting every configured effort level to full reasoning. Detect Qwen3.x models (mirroring the existing Kimi/GLM special-casing) and send chat_template_kwargs.enable_thinking instead. * 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. * fix: address CodeRabbit review findings on PR #188 - Add openai_codex to the max_output_tokens kwarg-override map: it builds the same OpenAIResponses provider as openai_responses, so the compaction cap was silently no-op'ing (falling back to max_tokens) for ChatGPT/Codex-backed sessions. - Add direct capped_chat_provider coverage for all provider-type -> kwarg mappings, including the openai_codex case above. - Extract the duplicated Runtime(...) rebuild in test_pythinkersoul_stuck_loop.py into a shared helper. * fix: assert on capped_chat_provider's return value, not the mutated fake _FakeChatProvider.with_generation_kwargs mutated self and returned self, so the new provider-type-mapping test only observed the fake's internal state rather than capped_chat_provider's actual return value -- a regression that discarded the capped copy would have passed unnoticed. Make the fake return a fresh instance (matching the real providers' copy-on-write contract) and assert on the returned/used provider in both the mapping test and test_compaction_caps_output_tokens.
1 parent b793056 commit 840e9b9

14 files changed

Lines changed: 333 additions & 20 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

packages/pythinker-core/src/pythinker_core/chat_provider/echo/scripted_echo.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ def with_thinking(self, effort: ThinkingEffort) -> Self:
6767
copied._scripts = deque(self._scripts)
6868
return copied
6969

70+
def with_generation_kwargs(self, **kwargs: object) -> Self:
71+
# Scripted replay carries no real request body, so generation
72+
# kwargs (e.g. an output-length cap) have nothing to attach to.
73+
copied = copy.copy(self)
74+
copied._scripts = deque(self._scripts)
75+
return copied
76+
7077

7178
class ScriptedEchoStreamedMessage(StreamedMessage):
7279
"""Streamed message for ScriptedEchoChatProvider."""

packages/pythinker-core/tests/test_scripted_echo_chat_provider.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,15 @@ async def test_scripted_echo_chat_provider_requires_dsl_content():
135135

136136
with pytest.raises(ChatProviderError):
137137
await provider.generate(system_prompt="", tools=[], history=[])
138+
139+
140+
async def test_scripted_echo_chat_provider_with_generation_kwargs_preserves_scripts():
141+
provider = ScriptedEchoChatProvider(["text: first", "text: second"])
142+
143+
capped = provider.with_generation_kwargs(max_tokens=4000)
144+
145+
assert capped is not provider
146+
first_stream = await capped.generate(system_prompt="", tools=[], history=[])
147+
assert [part async for part in first_stream] == [TextPart(text="first")]
148+
second_stream = await capped.generate(system_prompt="", tools=[], history=[])
149+
assert [part async for part in second_stream] == [TextPart(text="second")]

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/llm.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,31 @@ def model_name(self) -> str:
6161
return self.chat_provider.model_name
6262

6363

64+
# Providers whose generation kwargs use `max_output_tokens` instead of the
65+
# more common `max_tokens` (OpenAI Chat-Completions-shaped and Anthropic
66+
# providers, plus the first-party `pythinker` provider, all use `max_tokens`).
67+
_MAX_OUTPUT_TOKENS_KWARG_OVERRIDES: dict[str, str] = {
68+
"openai_responses": "max_output_tokens",
69+
"openai_codex": "max_output_tokens",
70+
"google_genai": "max_output_tokens",
71+
"gemini": "max_output_tokens",
72+
"vertexai": "max_output_tokens",
73+
}
74+
75+
76+
def capped_chat_provider(llm: LLM, max_output_tokens: int) -> ChatProvider:
77+
"""Return a copy of ``llm.chat_provider`` with its output length capped.
78+
79+
Callers needing a small, predictable cap regardless of the model's
80+
usual output budget (e.g. a context-compaction summary) can use this
81+
instead of hand-picking a provider-specific kwarg name.
82+
"""
83+
provider_config = getattr(llm, "provider_config", None)
84+
provider_type = getattr(provider_config, "type", None)
85+
kwarg = _MAX_OUTPUT_TOKENS_KWARG_OVERRIDES.get(provider_type or "", "max_tokens")
86+
return cast(Any, llm.chat_provider).with_generation_kwargs(**{kwarg: max_output_tokens})
87+
88+
6489
# Hosts that serve the genuine Anthropic API and therefore accept the
6590
# `tool_reference` / `defer_loading` beta content blocks that deferred tool
6691
# search depends on. The Claude API-key path and Anthropic OAuth both route
@@ -490,12 +515,22 @@ def create_llm(
490515
and not is_dashscope_legacy
491516
)
492517
is_glm_openai_legacy = provider.type == "openai_legacy" and _is_glm_model(model.model)
518+
# Qwen3.x exposes a binary `enable_thinking` template toggle, not tiered
519+
# reasoning effort; DashScope's own hosted endpoint already sends
520+
# `enable_thinking` below, so only apply this for other openai_legacy
521+
# routes (e.g. local llama.cpp/vLLM/LM Studio servers).
522+
is_qwen3_openai_legacy = (
523+
provider.type == "openai_legacy"
524+
and _is_qwen3_model(model.model)
525+
and not is_dashscope_legacy
526+
)
493527
if (
494528
effective_effort is not None
495529
and supports_thinking
496530
and not is_kimi_openai_legacy
497531
and not is_glm_openai_legacy
498532
and not is_dashscope_legacy
533+
and not is_qwen3_openai_legacy
499534
):
500535
# Only explicitly send thinking controls for models that advertise
501536
# reasoning. Some OpenAI-compatible non-reasoning models reject even a
@@ -526,6 +561,13 @@ def create_llm(
526561
extra_body={"enable_thinking": thinking_on}
527562
)
528563

564+
# Self-hosted Qwen3.x servers (llama.cpp/vLLM/LM Studio) take the same
565+
# `enable_thinking` toggle via the template-kwargs extra_body shape.
566+
if is_qwen3_openai_legacy and effective_effort is not None:
567+
chat_provider = cast(Any, chat_provider).with_generation_kwargs(
568+
extra_body={"chat_template_kwargs": {"enable_thinking": thinking_on}}
569+
)
570+
529571
# Apply Pythinker AI-specific ``thinking.keep`` (preserved thinking) only when
530572
# the model is actually in thinking mode; otherwise the API would see a
531573
# ``thinking.keep`` without an accompanying ``thinking.type`` it honors.
@@ -663,6 +705,14 @@ def _is_glm_model(model_name: str) -> bool:
663705
return model_name.lower().replace("_", "-").startswith("glm-")
664706

665707

708+
def _is_qwen3_model(model_name: str) -> bool:
709+
"""Qwen3.x (dense and MoE) models only support the chat template's
710+
binary `enable_thinking` toggle, not tiered reasoning effort levels.
711+
"""
712+
normalized = model_name.lower().replace("_", "-")
713+
return "qwen3" in normalized or "qwen-3" in normalized
714+
715+
666716
def _is_dashscope_endpoint(base_url: str) -> bool:
667717
"""True for any Alibaba DashScope endpoint (standard, intl, workspace)."""
668718
from urllib.parse import urlparse

src/pythinker_code/soul/compaction.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pythinker_core.tooling.empty import EmptyToolset
1010

1111
import pythinker_code.prompts as prompts
12-
from pythinker_code.llm import LLM
12+
from pythinker_code.llm import LLM, capped_chat_provider
1313
from pythinker_code.soul.api_errors import is_context_overflow_error
1414
from pythinker_code.soul.message import system
1515
from pythinker_code.utils.logging import logger
@@ -87,6 +87,11 @@ def should_prune(token_count: int, max_context_size: int, *, ratio: float) -> bo
8787
PRUNE_PLACEHOLDER = "[tool output elided to save context: {n} chars]"
8888
CAP_PLACEHOLDER = "\n[tool output capped: removed {n} chars]"
8989

90+
# A compaction summary is a short digest, not a full response — cap it well
91+
# below the model's normal output budget so a degenerate/repetitive
92+
# completion can't run unbounded and hang the soul loop.
93+
_COMPACTION_MAX_OUTPUT_TOKENS = 4000
94+
9095

9196
def prune_stale_tool_outputs(
9297
messages: Sequence[Message], *, protect_last: int, min_chars: int
@@ -270,16 +275,15 @@ async def _summarize_to_message(
270275
half is dropped and the request retried; ``(None, None)`` means even
271276
a single message did not fit.
272277
273-
NOTE: the summary length is bounded by the chat provider's
274-
construction-time max output tokens (LLM default_max_tokens, or
275-
PYTHINKER_MODEL_MAX_TOKENS). A tighter per-call cap would require a
276-
max-tokens parameter on ``ChatProvider.generate`` (and
277-
``pythinker_core.step``), which neither exposes today.
278+
The summary's output length is capped at ``_COMPACTION_MAX_OUTPUT_TOKENS``
279+
via a per-call generation kwarg (see ``capped_chat_provider``), so a
280+
slow/degenerate completion can't run unbounded.
278281
"""
282+
capped_provider = capped_chat_provider(llm, _COMPACTION_MAX_OUTPUT_TOKENS)
279283
while True:
280284
try:
281285
result = await pythinker_core.step(
282-
chat_provider=llm.chat_provider,
286+
chat_provider=capped_provider,
283287
system_prompt="You are a helpful assistant that compacts conversation context.",
284288
toolset=EmptyToolset(),
285289
history=[compact_message],

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_compaction_overflow.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from pythinker_core.chat_provider import APIStatusError
1919
from pythinker_core.message import Message
2020

21-
from pythinker_code.llm import LLM
21+
from pythinker_code.llm import LLM, capped_chat_provider
2222
from pythinker_code.soul.compaction import SimpleCompaction
2323
from pythinker_code.wire.types import TextPart
2424

@@ -31,8 +31,59 @@ def _history(n_pairs: int = 4) -> list[Message]:
3131
return messages
3232

3333

34+
class _FakeChatProvider:
35+
"""Minimal provider double recording `with_generation_kwargs` calls.
36+
37+
Returns a fresh instance rather than mutating in place, matching the real
38+
providers' copy-on-write contract — so tests must assert on the returned
39+
provider, not the original, to actually exercise that contract.
40+
"""
41+
42+
def __init__(self, generation_kwargs: dict[str, object] | None = None) -> None:
43+
self.generation_kwargs: dict[str, object] = generation_kwargs or {}
44+
45+
def with_generation_kwargs(self, **kwargs: object) -> _FakeChatProvider:
46+
return _FakeChatProvider(kwargs)
47+
48+
3449
def _fake_llm() -> LLM:
35-
return cast(LLM, SimpleNamespace(chat_provider=None))
50+
return cast(LLM, SimpleNamespace(chat_provider=_FakeChatProvider(), provider_config=None))
51+
52+
53+
def _fake_llm_with_provider_type(provider_type: str) -> LLM:
54+
return cast(
55+
LLM,
56+
SimpleNamespace(
57+
chat_provider=_FakeChatProvider(),
58+
provider_config=SimpleNamespace(type=provider_type),
59+
),
60+
)
61+
62+
63+
@pytest.mark.parametrize(
64+
("provider_type", "expected_kwarg"),
65+
[
66+
("openai_legacy", "max_tokens"),
67+
("anthropic", "max_tokens"),
68+
("pythinker", "max_tokens"),
69+
("openai_responses", "max_output_tokens"),
70+
# ChatGPT/Codex sessions build the same OpenAIResponses provider as
71+
# "openai_responses" (see create_llm's "openai_codex" case), so they
72+
# take the same max_output_tokens kwarg, not the max_tokens default.
73+
("openai_codex", "max_output_tokens"),
74+
("google_genai", "max_output_tokens"),
75+
("gemini", "max_output_tokens"),
76+
("vertexai", "max_output_tokens"),
77+
],
78+
)
79+
def test_capped_chat_provider_picks_kwarg_by_provider_type(
80+
provider_type: str, expected_kwarg: str
81+
) -> None:
82+
llm = _fake_llm_with_provider_type(provider_type)
83+
84+
capped_provider = cast(_FakeChatProvider, capped_chat_provider(llm, 4000))
85+
86+
assert capped_provider.generation_kwargs == {expected_kwarg: 4000}
3687

3788

3889
def _overflow_error() -> APIStatusError:
@@ -50,9 +101,11 @@ class _FakeStep:
50101
def __init__(self, failures_before_success: int) -> None:
51102
self.failures_before_success = failures_before_success
52103
self.histories: list[list[Message]] = []
104+
self.chat_providers: list[object] = []
53105

54106
async def __call__(self, *, chat_provider, system_prompt, toolset, history):
55107
self.histories.append(list(history))
108+
self.chat_providers.append(chat_provider)
56109
if len(self.histories) <= self.failures_before_success:
57110
raise _overflow_error()
58111
return _summary_result()
@@ -89,6 +142,18 @@ async def test_exhausted_retries_fall_back_to_tail_with_note(monkeypatch) -> Non
89142
assert "reply 3" in joined
90143

91144

145+
@pytest.mark.asyncio
146+
async def test_compaction_caps_output_tokens(monkeypatch) -> None:
147+
fake_step = _FakeStep(failures_before_success=0)
148+
monkeypatch.setattr(pythinker_core, "step", fake_step)
149+
150+
llm = _fake_llm()
151+
await SimpleCompaction(max_preserved_messages=2).compact(_history(), llm=llm)
152+
153+
used_provider = cast(_FakeChatProvider, fake_step.chat_providers[0])
154+
assert used_provider.generation_kwargs == {"max_tokens": 4000}
155+
156+
92157
@pytest.mark.asyncio
93158
async def test_non_overflow_error_propagates(monkeypatch) -> None:
94159
async def _step_raises(**kwargs):

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,

0 commit comments

Comments
 (0)