Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
5 changes: 5 additions & 0 deletions src/pythinker_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/pythinker_code/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions src/pythinker_code/soul/compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
41 changes: 36 additions & 5 deletions src/pythinker_code/soul/pythinkersoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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."
)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/pythinker_code/soul/toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
69 changes: 67 additions & 2 deletions tests/core/test_compaction_overflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tests/core/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion tests/core/test_model_switch_carryover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Loading
Loading