[GH-ISSUE #209] bug: task silently marked complete when the post-compaction completion returns zero tokens #121

Open
opened 2026-10-18 14:03:08 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @ThalynLabs on GitHub (Aug 1, 2026).
Original GitHub issue: https://github.com/open-webui/computer/issues/209

Summary

An agent task ends mid-work and is recorded as successfully finished whenever the completion
request issued immediately after context compaction comes back 200 OK with zero generated tokens.
No error is raised, no retry is attempted, and the chat shows a normal completed assistant message
that stops mid-sentence. The user has to type "continue" to resume, at which point the run proceeds
normally.

Environment

  • cptr 0.9.19, installed via uvx, running cptr run --headless
  • macOS 27.0.0, arm64, Python 3.11
  • Provider: OpenAI-compatible connection (api_type: chat_completions, provider_type: default)
    pointed at a local Ollama daemon, serving a cloud-hosted model (glm-5.2, 1,000,000-token
    advertised context)
  • Default CHAT_COMPACT_TOKEN_THRESHOLD (80,000), unset in the environment

What happens

Every observed stop follows the same five-step trace, with no other pattern in between:

19:37:47.559  summarizer completion returns 200
19:37:47.561  [summarize] LLM summary: 3899 chars
19:37:47.581  [task 2c0feb39] compacted: dropped 10 msgs, kept 6, summary=3899 chars
19:37:47.595  [stream] openai completions POST .../v1/chat/completions
19:37:47.753  HTTP Request: POST .../v1/chat/completions "HTTP/1.1 200 OK"      ← 158 ms
19:37:47.754  [stream] openai completions status=200
19:37:47.754  [task 2c0feb39] db save (done) begin: done=True

The 158 ms round trip is the tell, and the session-wide latency distribution is cleanly bimodal.
Across 43 completion requests to this model, exactly five took under 0.5 s — 81, 99, 127, 130 and
158 ms — and those five are precisely the five stops. Every other request in the session took
between 0.6 s and 63.4 s. Nothing was generated in the fast five.

That is confirmed in the database rather than inferred from timing. The persisted assistant message
carries the usage reported by that final response:

done = 1
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}

last_usage is reset to None at compaction (utils/chat_task.py, in the compaction block), so
this all-zero usage object is what the post-compaction response itself reported. The response
consisted of a zero usage event and a done event, and nothing else.

The stored content is whatever the task had accumulated before compaction, and in every case it
ends at the moment the model was about to act:

  • "...Now let me set up my task tracking and begin the actual verification work."
  • "...Now let me read the remaining reference documents."
  • "...I'll use run_command with ripgrep instead."
  • "...starting with recording the git state and running the mechanical checks."

Frequency

In one 100-minute session on this model: 10 compaction events across 6 tasks.

  • 5 of the 6 tasks ended with a zero-token final completion.
  • 100% of the stops occurred on the request issued immediately after a compaction, at 81 ms,
    99 ms, 130 ms, 158 ms, and 127 ms.
  • The inverse does not hold — compaction does not always stop the task. One task compacted three
    times and only stopped on the third. So the empty completion is intermittent, but it only ever
    appeared in the post-compaction slot.
  • The only task that survived to a real ending reported genuine usage
    (input_tokens: 22567, output_tokens: 370).

Where the defect is

utils/chat_task.py, the stream event loop — elif event["type"] == "done": (line 2256 in 0.9.19).
When the response carries no pending tool calls, the handler unconditionally flushes text, saves the
message with done=True, pops task state, emits CHAT_FINISHED, sets
task_completed_success = True, and returns.

There is no check that the iteration produced anything. An iteration that yielded zero text deltas,
zero output items, zero tool calls, and zero tokens is indistinguishable, at that branch, from a
model that finished its work.

An empty completion is not a completion. The current behaviour converts a transient upstream
non-answer into a permanent, silent, successful-looking truncation of an agent run — the worst of the
available outcomes, because nothing surfaces to the user and the DB records success.

Suggested fix

At the done branch, before treating the task as complete, check whether this iteration produced any
output at all — text, output items, tool calls — and whether reported usage is non-zero. If it
produced nothing:

  1. Retry the request (bounded, with backoff), rather than terminating; and
  2. If it still returns empty after the retry budget, finish the task as errored, not as done, so
    the failure is visible instead of silent.

Failing the task loudly would already be a large improvement over the current silent success, even
without the retry.

Open question — why is the post-compaction request empty?

This report is about cptr accepting the empty response, which is a defect regardless of cause. But
the trigger is worth investigating too, because the empty responses cluster in exactly one place. Two
candidates, not distinguished from outside:

  1. The upstream provider intermittently returns an empty completion, and the correlation with
    compaction is a coincidence of load or timing. Unlikely given 5 for 5.
  2. The request cptr assembles immediately after compaction is something the provider answers with
    silence — e.g. the reshaped message list, or the summary-augmented system prompt, or the interaction
    between the two.

Setting CPTR_LOG_UPSTREAM_REQUESTS=true and capturing the exact post-compaction request body would
settle this quickly; that log was not enabled when these traces were captured.

Workaround in use

Raising the compaction threshold for the affected model reduces how often the condition can fire, via
per-model config:

{ "<prefixed-model-id>": { "params": { "compact_token_threshold": 300000 } } }

This is mitigation only. It lowers the frequency of the trigger; it does not stop cptr from accepting
an empty completion as a finished task.

Secondary observation (separate, lower priority)

Twice in the same session the summarizer failed and fell back:

[summarize] LLM call failed, using naive fallback

Both fallbacks produced a truncated 2000-character summary where real summaries ran 2100–3900
characters — a measurably worse handoff into the compacted context. This is not the cause of the
stops
: one of the two fallback compactions continued generating normally 56 seconds later. Noting
it only because a silent degradation of summary quality is hard to spot from the UI.

Originally created by @ThalynLabs on GitHub (Aug 1, 2026). Original GitHub issue: https://github.com/open-webui/computer/issues/209 ### Summary An agent task ends mid-work and is recorded as **successfully finished** whenever the completion request issued immediately after context compaction comes back `200 OK` with zero generated tokens. No error is raised, no retry is attempted, and the chat shows a normal completed assistant message that stops mid-sentence. The user has to type "continue" to resume, at which point the run proceeds normally. ### Environment - cptr `0.9.19`, installed via `uvx`, running `cptr run --headless` - macOS 27.0.0, arm64, Python 3.11 - Provider: OpenAI-compatible connection (`api_type: chat_completions`, `provider_type: default`) pointed at a local Ollama daemon, serving a cloud-hosted model (`glm-5.2`, 1,000,000-token advertised context) - Default `CHAT_COMPACT_TOKEN_THRESHOLD` (80,000), unset in the environment ### What happens Every observed stop follows the same five-step trace, with no other pattern in between: ``` 19:37:47.559 summarizer completion returns 200 19:37:47.561 [summarize] LLM summary: 3899 chars 19:37:47.581 [task 2c0feb39] compacted: dropped 10 msgs, kept 6, summary=3899 chars 19:37:47.595 [stream] openai completions POST .../v1/chat/completions 19:37:47.753 HTTP Request: POST .../v1/chat/completions "HTTP/1.1 200 OK" ← 158 ms 19:37:47.754 [stream] openai completions status=200 19:37:47.754 [task 2c0feb39] db save (done) begin: done=True ``` The 158 ms round trip is the tell, and the session-wide latency distribution is cleanly bimodal. Across 43 completion requests to this model, exactly five took under 0.5 s — 81, 99, 127, 130 and 158 ms — and those five are precisely the five stops. Every other request in the session took between 0.6 s and 63.4 s. Nothing was generated in the fast five. That is confirmed in the database rather than inferred from timing. The persisted assistant message carries the usage reported by that final response: ``` done = 1 usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} ``` `last_usage` is reset to `None` at compaction (`utils/chat_task.py`, in the compaction block), so this all-zero usage object is what the post-compaction response itself reported. The response consisted of a zero usage event and a `done` event, and nothing else. The stored `content` is whatever the task had accumulated **before** compaction, and in every case it ends at the moment the model was about to act: - `"...Now let me set up my task tracking and begin the actual verification work."` - `"...Now let me read the remaining reference documents."` - `"...I'll use run_command with ripgrep instead."` - `"...starting with recording the git state and running the mechanical checks."` ### Frequency In one 100-minute session on this model: 10 compaction events across 6 tasks. - 5 of the 6 tasks ended with a zero-token final completion. - **100% of the stops occurred on the request issued immediately after a compaction**, at 81 ms, 99 ms, 130 ms, 158 ms, and 127 ms. - The inverse does not hold — compaction does not always stop the task. One task compacted three times and only stopped on the third. So the empty completion is intermittent, but it only ever appeared in the post-compaction slot. - The only task that survived to a real ending reported genuine usage (`input_tokens: 22567, output_tokens: 370`). ### Where the defect is `utils/chat_task.py`, the stream event loop — `elif event["type"] == "done":` (line 2256 in 0.9.19). When the response carries no pending tool calls, the handler unconditionally flushes text, saves the message with `done=True`, pops task state, emits `CHAT_FINISHED`, sets `task_completed_success = True`, and returns. There is no check that the iteration produced anything. An iteration that yielded zero text deltas, zero output items, zero tool calls, and zero tokens is indistinguishable, at that branch, from a model that finished its work. An empty completion is not a completion. The current behaviour converts a transient upstream non-answer into a permanent, silent, successful-looking truncation of an agent run — the worst of the available outcomes, because nothing surfaces to the user and the DB records success. ### Suggested fix At the `done` branch, before treating the task as complete, check whether this iteration produced any output at all — text, output items, tool calls — and whether reported usage is non-zero. If it produced nothing: 1. Retry the request (bounded, with backoff), rather than terminating; and 2. If it still returns empty after the retry budget, finish the task as **errored**, not as done, so the failure is visible instead of silent. Failing the task loudly would already be a large improvement over the current silent success, even without the retry. ### Open question — why is the post-compaction request empty? This report is about cptr accepting the empty response, which is a defect regardless of cause. But the trigger is worth investigating too, because the empty responses cluster in exactly one place. Two candidates, not distinguished from outside: 1. The upstream provider intermittently returns an empty completion, and the correlation with compaction is a coincidence of load or timing. Unlikely given 5 for 5. 2. The request cptr assembles immediately after compaction is something the provider answers with silence — e.g. the reshaped message list, or the summary-augmented system prompt, or the interaction between the two. Setting `CPTR_LOG_UPSTREAM_REQUESTS=true` and capturing the exact post-compaction request body would settle this quickly; that log was not enabled when these traces were captured. ### Workaround in use Raising the compaction threshold for the affected model reduces how often the condition can fire, via per-model config: ```json { "<prefixed-model-id>": { "params": { "compact_token_threshold": 300000 } } } ``` This is mitigation only. It lowers the frequency of the trigger; it does not stop cptr from accepting an empty completion as a finished task. ### Secondary observation (separate, lower priority) Twice in the same session the summarizer failed and fell back: ``` [summarize] LLM call failed, using naive fallback ``` Both fallbacks produced a truncated 2000-character summary where real summaries ran 2100–3900 characters — a measurably worse handoff into the compacted context. **This is not the cause of the stops**: one of the two fallback compactions continued generating normally 56 seconds later. Noting it only because a silent degradation of summary quality is hard to spot from the UI.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
open-webui/computer#121
No description provided.