Repository navigation
[Bug]: mcode exec fails with limit_exceeded (exit 7) when auto-compaction runs, because the checkpoint consumes --max-steps #514
Description
Product or interface
CLI - headless
Version
- Source checkout at commit
5821f7c(package.jsonversion0.6.5,docs/open-source-status.mdcalls this the0.6.3source preview) - Node.js
24.9.0, pnpm9.12.0 - Affects
packages/tuiandpackages/local-runtime-v2as shipped in this repository
Platform
Multiple platforms
OS version and architecture
Originally reproduced on Windows x64. The defect is platform-independent: it is a message-classification bug in shared TypeScript, with no platform, filesystem or native dependency involved.
Issue area
Agent stuck / slow / repeated loop
Steps to reproduce
Use a local OpenAI-compatible fixture so no real provider or account is involved. The key requirement is that automatic context compaction triggers during the turn.
- Register a provider with a small context window, which lowers the auto-compaction trigger:
mcode provider add --name ctx --base-url http://127.0.0.1:<port>/v1 \
--api-format openai-completions --model fixture-model \
--context-limit 32768 --support-image --use- Run a single-turn task with
--max-steps 1:
mcode exec "IMAGE_INPUT_TEST" \
--model custom_provider:ctx/fixture-model \
--file fixture.png \
--timeout 20s --max-steps 1--file only matters because it is one way to push the request over the compaction trigger. Permission mode is irrelevant; the failure happens before the tool loop. No credentials are needed beyond the fixture key.
The same failure occurs with any prompt when the context is near the trigger, e.g. --context-limit 8192 with no attachment at all.
Expected and actual behavior
Expected: --max-steps 1 bounds how many times the agent calls the model for the user's turn. An internal compaction checkpoint is bookkeeping, not a user turn step, so the run should succeed with one model response and report the real answer.
Actual:
mcode exec limit_exceeded: The run reached its step limit. Increase --max-steps or narrow the task.
$ echo $?
7
Diagnostics confirm the first provider request of the turn is the checkpoint, and the real turn is the second request:
{"kind":"model_phase_started","operationId":"turn_...:model:1",...}
# request 1 -> "Checkpoint control: summarize the preceding conversation now. ..."
# request 2 -> the user's actual turn
Deterministic before/after matrix (local fixture, --max-steps 1):
| context limit | image attachment | checkpoint performed | result |
|---|---|---|---|
| 32768 | yes | 1 | exit 7 |
| 32768 | no | 0 | exit 0 |
| 8192 | yes | 0 | exit 7 |
| 8192 | no | 0 | exit 7 |
The 8192 rows fail even without a checkpoint because the request alone exceeds the trigger; either way the reported reason is "step limit", which points a user at --max-steps instead of at context compaction.
Same root cause, four additional user-visible effects (all reachable from the same misclassification, see below):
- A lifecycle frame can replace an answer that was already selected:
answeris cleared and the retained streamed draft is discarded, so the turn can end with no answer or with the checkpoint text. - Compaction
contentis projected to ACP clients asagent_message_chunk, so an IDE sees checkpoint prose as assistant output. - Compaction checkpoint text renders as the live assistant reply in the TUI and is written into exported Markdown transcripts.
- Chat-channel replies (Feishu/Telegram/WeChat) leak compaction and review text, because the collector only filters the terminal
kind === 'compaction'state and missescompaction_start,compaction_failedand allreview_*frames.
Root cause
Compaction and review lifecycle frames are persisted with role: 'assistant' because they share the message store with model responses. The two facts are orthogonal — role says who produced the frame, kind says which lifecycle state it is — but the repository never defined a single "is this a model response" predicate. Each consumer re-derived it:
| Location | Predicate | Consequence |
|---|---|---|
packages/tui/src/application/run-coordinator.ts |
role === 'assistant' |
lifecycle frame consumes a --max-steps slot |
packages/tui/src/application/response-usage.ts |
kind?.startsWith('compaction') |
review_* contributes usage and response identity |
packages/tui/src/headless/events.ts |
role === 'assistant' |
checkpoint text becomes agent_message output |
packages/tui/src/acp/updates.ts |
role === 'assistant' |
checkpoint text sent to IDE clients |
packages/tui/src/tui/controller/projection/turn-live-projection.ts |
none | checkpoint text rendered as assistant body |
packages/tui/src/tui/transcript/export.ts |
role only |
checkpoint text enters exported Markdown |
packages/local-runtime/src/channels/sse-collector.ts |
kind === 'compaction' |
three of eight lifecycle kinds filtered |
packages/local-runtime/src/api/conversation-message-adapter.ts |
three literal compaction kinds | review_* dropped, then re-read as model output |
--max-steps is therefore consumed by an internal auxiliary request, and the CLI reports a limit error for a run that never exceeded its intended budget.
Suggested fix
Introduce one classification module and make every producer type and consumer read it, so no alternate path can drift again. In my working tree this is @mavis/shared/turn-message-class (chosen because packages/tui does not depend on @mavis/agent-core, and @mavis/shared sits at the bottom of the dependency graph):
- lifecycle kind sets and
kind→ lifecycle-state tables, replacing three separate if/else ladders isTurnResponseMessage(message) = role === 'assistant' && !isTurnLifecycleMessage(message)MessageKindandRuntimeMessageKindderive from the shared union instead of re-listing literalsrun-coordinatorgates response steps, usage and answer selection through one composed predicate; a lifecycle frame is not a step, but still marks usage incomplete because its auxiliary call spends tokens- the other eight call sites ask the same module instead of comparing
roleorkind
A local fix in run-coordinator alone would stop the exit-7 symptom but leave items 2–4 above in place, which is why I am reporting the shared root cause rather than only the step counter.
Redacted error summary
mcode exec limit_exceeded: The run reached its step limit. Increase --max-steps or narrow the task.
Exit code 7, mapped from limit_exceeded in packages/tui/src/headless/exit-policy.ts. ACP returns stopReason: max_turn_requests for the same underlying condition.
Verification I can and cannot claim
Reproduced deterministically on Windows x64 against a local OpenAI-compatible fixture, and pnpm test:byok fails on this at IMAGE_INPUT_TEST (exit 7) on every run before the fix and passes after it.
Not verified: macOS and Linux were not run at all; no real MiniMax backend, Token Plan or BYOK provider was involved, so this is offline fixture evidence, not live-service acceptance; compaction output quality was not assessed, only whether the checkpoint request occurred; ACP was checked at the projection level only, not against a real IDE client; IM channels were checked at the pure-function level only, not against real services.
Screenshots
No response
Before submitting
- I have searched existing issues.
- I have included my version and removed sensitive information.
A working patch exists on a fork branch fix/lifecycle-frame-classification (31 files, +633/−113, four new files, no moved or renamed files, generated release/public-source.json and tsconfig.standalone.json regenerated with their scripts). I understand from CONTRIBUTING.md and the issue-template config that external PRs are not accepted; I am not attaching it as a pull request. I can paste the diff, describe any file in detail, or hand the branch to a collaborator if that is useful.
Activity