Summary
After a session_rotation_summary compaction, the next assistant turn sometimes emits a tool call as plain text and then fabricates the tool result, with no tool_use content part in the message. The tool never runs. The invented result is a coherent reconstruction of stale context, so it reads like real output.
Measured incidence in one workspace: 4 occurrences in 5,543 assistant messages — every one of them the first assistant turn after a rotation compaction, and nothing anywhere else.
| Malformed message |
Timestamp |
Preceding compaction |
Gap |
Digest size / nesting depth |
7a195a7e |
08-01 19:31:19 |
session_rotation_summary |
+0 s |
450 KB / 10 |
64637ef1 |
08-02 01:09:40 |
session_rotation_summary |
+1 s |
700 KB / 15 |
200f2082 |
08-02 11:54:26 |
session_rotation_summary |
+0 s |
736 KB / 16 |
f03e921e |
08-02 13:20:30 |
session_rotation_summary |
+0 s |
796 KB / 17 |
4 of 34 rotation compactions ≈ 12%. All four landed on the largest digests. None landed on a local_summary compaction (those are 9–11 KB, nesting depth 1).
Observed output
A single text part containing a call and an invented result. Verbatim from 200f2082 (identifiers shortened, paths redacted):
<invoke name="workspace_getTaskResult">
<parameter name="taskId">dd8ca5ad-…</parameter>
</invoke>
{"ok":true,"task":{"assignedAgentDefinitionId":"22a47068-…","completedAt":1785626765251,
"createdAt":1785625894360,"error":null,"instructions":"Read-only review of an OPEN pull request…
The other two fabrications:
64637ef1 produced a complete gh pr view … --json … payload — "additions":183, "deletions":10, plus a multi-paragraph invented root-cause narrative — without reading the repository.
7a195a7e produced an ls -la listing in which the same filename repeats six times, which is the degenerate-sampling tell that the output was generated rather than observed.
In all four cases the user noticed, 16–39 s later. The agent never self-detected, and there is no harness-side check.
Mechanism
assistant/compaction.rs:
if matches!(strategy, CompactionStrategy::SessionRotationSummary) {
return Ok(fallback_summary(messages)); // never calls a model
}
fallback_summary renders the window through render_content_parts, which serialises history as a text transcript including both halves of every tool interaction:
ContentPart::ToolUse { .. } => format!("[tool call: {} {}]", tool_name, truncate_json(..)),
ContentPart::ToolResult { .. } => format!("[tool result: {}]", truncate_json(..)),
That digest is stored as a MessageRole::System message, and providers/anthropic.rs::build_request_body folds every System-role message into the request's system block.
So the model begins its turn having just read up to ~800 KB of interleaved call/result transcript in system position, with no marker for where the transcript ends. It continues the document instead of replying: it writes a call in prose, and because prose contains no protocol boundary that returns control to the harness, generation does not stop — so it writes a result too, reconstructed from the stale transcript it just read.
Two explanations that the data rules out
- Not format imitation. 3 of the 4 incidents used
<invoke name="…"> / <parameter name="…">, a syntax that appears nowhere in the digest — it is the model's own text-mode prior. Only one echoed the digest's [tool call: shape. Changing the rendering format therefore would not fix this.
- Not conflicting instructions. No text-mode function-calling preamble is injected anywhere;
grep -rn "invoke name\|function_calls" src-tauri/src src is empty. The native tools parameter is the only affordance offered.
What correlates is the size and position of the digest, not its wording.
Secondary defect: rotation compaction does not compact
Each rotation digest re-embeds its predecessor verbatim, so summaries grow monotonically — 450 KB → 796 KB across 17 nestings, roughly +45 KB per cycle. Nesting depth was measured by counting occurrences of the summary preamble inside a single summary message. A ~200K-token "summary" in the system block inverts the purpose of the feature, and it is why incidence rises over a long-lived session.
Suggested fixes, in the order the evidence supports
- Harness-side detection. Before persisting an assistant message, scan its text parts for an invocation shape (
<invoke name=, <parameter name=, [tool_call:, [tool call:). On a hit, do not store it as a normal reply — discard and re-prompt with an explicit correction, or fail the run visibly — and log the hit so the rate is measurable. This is the only proposal that catches the fabricated-result case, and the only one that does not depend on getting prompt wording right.
- Do not deliver a transcript in the
system block. Send the digest as a user-role turn terminated by an explicit end-of-history boundary. Restores turn structure so the next token is a reply rather than a continuation, and removes instruction authority the transcript has not earned.
- Bound the digest and break the nesting. Never copy a prior compaction summary forward verbatim; re-summarise or truncate.
- Use the summariser model on rotation when a provider is reachable, keeping
fallback_summary for genuine failures. Model-summarised digests showed zero incidents.
- Change the
[tool call: rendering — hygiene only; demote, since it explains at most 1 of the 4 cases.
Items 1–3 are independent and can land separately. Item 1 converts a silent data-integrity failure into a visible error and is worth shipping on its own.
Detection query
Parse content_json per content part rather than pattern-matching the raw JSON — matching raw text yields false positives from tool-call arguments (a query searching for these markers matches its own SQL):
import sqlite3, json, re
con = sqlite3.connect("file:<workspace>/.clai/data.sqlite?mode=ro", uri=True)
pat = re.compile(r'<invoke name=|<parameter name=|\[tool_call:|\[tool call:')
for mid, cj in con.execute("SELECT id, content_json FROM assistant_messages WHERE role='\"assistant\"'"):
parts = json.loads(cj)
if any(p.get("type") == "text" and pat.search(p.get("text", "")) for p in parts):
print(mid, [p.get("type") for p in parts])
Messages with a text-part hit and no tool_use part are the failures.
Environment
- CLAI
v26.8.1 (b5a9564)
- Provider: Anthropic protocol adapter
- Linux, webkit2gtk
Summary
After a
session_rotation_summarycompaction, the next assistant turn sometimes emits a tool call as plain text and then fabricates the tool result, with notool_usecontent part in the message. The tool never runs. The invented result is a coherent reconstruction of stale context, so it reads like real output.Measured incidence in one workspace: 4 occurrences in 5,543 assistant messages — every one of them the first assistant turn after a rotation compaction, and nothing anywhere else.
7a195a7esession_rotation_summary64637ef1session_rotation_summary200f2082session_rotation_summaryf03e921esession_rotation_summary4 of 34 rotation compactions ≈ 12%. All four landed on the largest digests. None landed on a
local_summarycompaction (those are 9–11 KB, nesting depth 1).Observed output
A single text part containing a call and an invented result. Verbatim from
200f2082(identifiers shortened, paths redacted):The other two fabrications:
64637ef1produced a completegh pr view … --json …payload —"additions":183,"deletions":10, plus a multi-paragraph invented root-cause narrative — without reading the repository.7a195a7eproduced anls -lalisting in which the same filename repeats six times, which is the degenerate-sampling tell that the output was generated rather than observed.In all four cases the user noticed, 16–39 s later. The agent never self-detected, and there is no harness-side check.
Mechanism
assistant/compaction.rs:fallback_summaryrenders the window throughrender_content_parts, which serialises history as a text transcript including both halves of every tool interaction:That digest is stored as a
MessageRole::Systemmessage, andproviders/anthropic.rs::build_request_bodyfolds every System-role message into the request'ssystemblock.So the model begins its turn having just read up to ~800 KB of interleaved call/result transcript in system position, with no marker for where the transcript ends. It continues the document instead of replying: it writes a call in prose, and because prose contains no protocol boundary that returns control to the harness, generation does not stop — so it writes a result too, reconstructed from the stale transcript it just read.
Two explanations that the data rules out
<invoke name="…">/<parameter name="…">, a syntax that appears nowhere in the digest — it is the model's own text-mode prior. Only one echoed the digest's[tool call:shape. Changing the rendering format therefore would not fix this.grep -rn "invoke name\|function_calls" src-tauri/src srcis empty. The nativetoolsparameter is the only affordance offered.What correlates is the size and position of the digest, not its wording.
Secondary defect: rotation compaction does not compact
Each rotation digest re-embeds its predecessor verbatim, so summaries grow monotonically — 450 KB → 796 KB across 17 nestings, roughly +45 KB per cycle. Nesting depth was measured by counting occurrences of the summary preamble inside a single summary message. A ~200K-token "summary" in the system block inverts the purpose of the feature, and it is why incidence rises over a long-lived session.
Suggested fixes, in the order the evidence supports
<invoke name=,<parameter name=,[tool_call:,[tool call:). On a hit, do not store it as a normal reply — discard and re-prompt with an explicit correction, or fail the run visibly — and log the hit so the rate is measurable. This is the only proposal that catches the fabricated-result case, and the only one that does not depend on getting prompt wording right.systemblock. Send the digest as auser-role turn terminated by an explicit end-of-history boundary. Restores turn structure so the next token is a reply rather than a continuation, and removes instruction authority the transcript has not earned.fallback_summaryfor genuine failures. Model-summarised digests showed zero incidents.[tool call:rendering — hygiene only; demote, since it explains at most 1 of the 4 cases.Items 1–3 are independent and can land separately. Item 1 converts a silent data-integrity failure into a visible error and is worth shipping on its own.
Detection query
Parse
content_jsonper content part rather than pattern-matching the raw JSON — matching raw text yields false positives from tool-call arguments (a query searching for these markers matches its own SQL):Messages with a text-part hit and no
tool_usepart are the failures.Environment
v26.8.1(b5a9564)