Skip to content

feat: wire max_rounds safety boundary into model.act() (#97)#104

Merged
VforVitorio merged 1 commit intodevfrom
feat/auto-max-rounds
Apr 7, 2026
Merged

feat: wire max_rounds safety boundary into model.act() (#97)#104
VforVitorio merged 1 commit intodevfrom
feat/auto-max-rounds

Conversation

@VforVitorio
Copy link
Copy Markdown
Owner

Summary

First of two PRs for #97 (robust auto mode). This one is the safety core — stops runaway tool loops before they burn the context window. No visual changes; follow-up PR 2 (feat/auto-mode-ux) will add the spinner color per mode, round counter, and first-time auto warning.

What changed

  • AgentSettings.max_rounds default 50→10, docstring explains the trade-off. The max_rounds field already existed but was never wired to model.act() — effectively dead code.
  • _run_turn now passes max_prediction_rounds=get_settings().agent.max_rounds to model.act() in the non-strict branch. Strict mode is untouched (uses model.respond() which has no round concept).
  • Limit-reached detection covers both SDK paths:
    • Well-behaved model: SDK forces a tool-free final round, model produces a text answer, ActResult.rounds == cap → flag set.
    • Stubborn model: model still emits tool_call on the final round, SDK raises LMStudioPredictionError("Model requested tool use on final prediction round."). _run_turn catches this specifically (matching on the "final prediction round" substring constant _MAX_ROUNDS_ERR_MARKER) and sets the flag. Any other LMStudioPredictionError re-raises. Critical: LMStudioPredictionError is a subclass of LMStudioServerError, which run() catches at the top level as a "LM Studio disconnected" signal. Without the specific catch, hitting the round limit would show the disconnect screen.
  • run() prints an inline warning when the flag is set, matching the ctx window warning style (no panel, just coloured text above the Rule separator):

    ⚠ agent stopped after N rounds — raise the limit with LMCODE_AGENT__MAX_ROUNDS=N, set agent.max_rounds in config, or pass --max-rounds N on the CLI. Type to continue the conversation.

  • CLI --max-rounds flag is now actually wired: mutates settings.agent.max_rounds for the session. Previously accepted by Typer but silently ignored. Default changed from 50 to None (fall back to config).

Tests

5 new tests in tests/test_agent/test_core.py:

  • test_run_turn_passes_max_rounds_to_act — the kwarg flows from config into the SDK call.
  • test_run_turn_max_rounds_none_when_config_zero — a non-positive max_rounds passes None to the SDK (which rejects values < 1).
  • test_run_turn_flags_limit_reached_when_rounds_equal_cap — well-behaved case sets the flag.
  • test_run_turn_flags_limit_reached_on_final_round_error — stubborn case catches the SDK error without re-raising.
  • test_run_turn_reraises_unrelated_prediction_error — pins the catch to the marker string, not to LMStudioPredictionError broadly.

Also fixed _make_mock_model to return a mock with concrete rounds: int and total_time_seconds: float — the post-act comparison getattr(result, "rounds", 0) >= cap crashes on the default MagicMock.rounds with TypeError.

Test plan

  • uv run lmcode chat --max-rounds 3, Tab to auto, ask for a task requiring ≥ 4 tool calls (e.g. "read calculator.py, greet.py and data.json then add a comment to each"). Expect the ⚠ warning after the model stops.
  • uv run lmcode chat (default 10) with normal single-step tasks — should behave identically to before.
  • LMCODE_AGENT__MAX_ROUNDS=1 uv run lmcode chat — env var override works.
  • CI green.

Follow-up — PR 2 (feat/auto-mode-ux)

Depends on this PR being merged first (uses max_rounds as the denominator for the round counter). Will add:

  • Spinner colour per permission mode (reuses existing _MODE_COLORS)
  • Round counter in the spinner label (working… · round 3/10), auto mode only
  • First-time auto warning on Tab into auto
  • /status line showing current max_rounds
  • Regression test: _always_allowed_tools survives ask → auto → strict → ask cycling

🤖 Generated with Claude Code

- AgentSettings.max_rounds default 50->10 with docstring explaining
  the trade-off and the 3 ways to override (config, env var, CLI flag)
- _run_turn passes max_prediction_rounds= to model.act() in the
  non-strict branch; strict stays on respond() which has no round
  concept
- Catch LMStudioPredictionError specifically when the message contains
  "final prediction round" and set self._last_turn_limit_reached. This
  prevents the run() top-level LMStudioServerError handler (parent
  class) from misreading the crash as an LM Studio disconnect
- Post-act check: ActResult.rounds >= cap also sets the flag (the
  well-behaved model case where the final round produced a text
  answer instead of a tool_call)
- run() prints an inline amber warning when the flag is set, matching
  the ctx window warning style
- CLI --max-rounds flag is now actually wired: mutates
  get_settings().agent.max_rounds for the session. Was previously
  accepted by Typer but silently ignored
- 5 regression tests + _make_mock_model fixed to return an
  ActResult-shaped mock with concrete rounds int (MagicMock >= int
  raises TypeError)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
VforVitorio added a commit that referenced this pull request Apr 7, 2026
…ing (#97)

Builds on PR 1's max_rounds safety core with the UX layer that makes the
new boundary visible and teachable.

- Per-mode spinner colour: the in-turn spinner tracks the active mode
  (orange=ask, blue=auto, red=strict) via a new mode_color() accessor
  in ui/status.py so the current mode is visible at a glance without
  reading the prompt line.
- Auto-mode round counter: in auto mode the spinner label appends
  " · round N/M" driven by an on_round_start callback wired into
  model.act(). Only shown in auto mode (ask blocks on user approval
  between tool calls, strict has no round concept).
- First-time auto warning: the first Tab-cycle into auto in a session
  prints an amber one-liner above the prompt via run_in_terminal,
  gated by self._auto_warned so it never repeats.
- /status surfaces the active max_rounds so users can verify which
  safety boundary is in effect after config / env / --max-rounds.

Tests cover: on_round_start kwarg wiring, _auto_warned initial state
and one-shot behaviour, mode-cycle preservation of
_always_allowed_tools (regression guard), /status max-rounds row.

Stacked on feat/auto-max-rounds — merge PR #104 first.
@VforVitorio VforVitorio merged commit 0f47418 into dev Apr 7, 2026
6 checks passed
@VforVitorio VforVitorio deleted the feat/auto-max-rounds branch April 7, 2026 10:58
VforVitorio added a commit that referenced this pull request Apr 7, 2026
When dev (containing the squash-merged #104) was merged into this
branch, git's three-way merge dropped three pieces of PR 2 work
because it saw them as conflicts with the squashed version of #104:

- self._auto_warned init flag was removed from Agent.__init__,
  causing mypy `has-type` error in _print_auto_warning
- on_round_start=_on_round_start kwarg was dropped from the
  model.act() call, breaking the round counter test
- max_prediction_rounds was re-added in its original post-keepalive
  position, duplicating the moved-up declaration and causing the
  mypy `no-redef` error at line 1267

Also restores the 5 PR 2 tests and the 4 PR 2 CHANGELOG entries
that the merge silently dropped. Local ruff + mypy + pytest green.
VforVitorio added a commit that referenced this pull request Apr 7, 2026
…ing (#97) (#105)

* feat: wire max_rounds safety boundary into model.act() (#97)

- AgentSettings.max_rounds default 50->10 with docstring explaining
  the trade-off and the 3 ways to override (config, env var, CLI flag)
- _run_turn passes max_prediction_rounds= to model.act() in the
  non-strict branch; strict stays on respond() which has no round
  concept
- Catch LMStudioPredictionError specifically when the message contains
  "final prediction round" and set self._last_turn_limit_reached. This
  prevents the run() top-level LMStudioServerError handler (parent
  class) from misreading the crash as an LM Studio disconnect
- Post-act check: ActResult.rounds >= cap also sets the flag (the
  well-behaved model case where the final round produced a text
  answer instead of a tool_call)
- run() prints an inline amber warning when the flag is set, matching
  the ctx window warning style
- CLI --max-rounds flag is now actually wired: mutates
  get_settings().agent.max_rounds for the session. Was previously
  accepted by Typer but silently ignored
- 5 regression tests + _make_mock_model fixed to return an
  ActResult-shaped mock with concrete rounds int (MagicMock >= int
  raises TypeError)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: auto-mode UX — per-mode spinner, round counter, first-time warning (#97)

Builds on PR 1's max_rounds safety core with the UX layer that makes the
new boundary visible and teachable.

- Per-mode spinner colour: the in-turn spinner tracks the active mode
  (orange=ask, blue=auto, red=strict) via a new mode_color() accessor
  in ui/status.py so the current mode is visible at a glance without
  reading the prompt line.
- Auto-mode round counter: in auto mode the spinner label appends
  " · round N/M" driven by an on_round_start callback wired into
  model.act(). Only shown in auto mode (ask blocks on user approval
  between tool calls, strict has no round concept).
- First-time auto warning: the first Tab-cycle into auto in a session
  prints an amber one-liner above the prompt via run_in_terminal,
  gated by self._auto_warned so it never repeats.
- /status surfaces the active max_rounds so users can verify which
  safety boundary is in effect after config / env / --max-rounds.

Tests cover: on_round_start kwarg wiring, _auto_warned initial state
and one-shot behaviour, mode-cycle preservation of
_always_allowed_tools (regression guard), /status max-rounds row.

Stacked on feat/auto-max-rounds — merge PR #104 first.

* fix: restore PR 2 content lost in dev→feat/auto-mode-ux merge

When dev (containing the squash-merged #104) was merged into this
branch, git's three-way merge dropped three pieces of PR 2 work
because it saw them as conflicts with the squashed version of #104:

- self._auto_warned init flag was removed from Agent.__init__,
  causing mypy `has-type` error in _print_auto_warning
- on_round_start=_on_round_start kwarg was dropped from the
  model.act() call, breaking the round counter test
- max_prediction_rounds was re-added in its original post-keepalive
  position, duplicating the moved-up declaration and causing the
  mypy `no-redef` error at line 1267

Also restores the 5 PR 2 tests and the 4 PR 2 CHANGELOG entries
that the merge silently dropped. Local ruff + mypy + pytest green.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant