summarize emits its summary as a role: "system" message between msgs[0] and the kept tail
(components/offload/summarize.go:247 and :300, no provider branch). That placement is
deliberate and the reasoning is sound — an operator directive added mid-conversation leaves the
cached prefix intact, where editing the top-level system block would invalidate everything
after it. The problem is only the placement, and it is a hard provider rejection.
Measured
Probed directly against the gateway (benchmark endpoint, not Context Guru), identical ~3k-token
cached system prefix, varying only messages:
| shape |
claude-sonnet-5 |
claude-opus-5 |
[user] (warm the prefix) |
write=3064 read=0 |
write=3063 read=0 |
[user] again |
write=0 read=3064 |
write=0 read=3063 |
[user, system, user] — what summarize emits |
HTTP 400 |
HTTP 400 |
[user, system, assistant, user] |
write=0 read=3064 |
write=0 read=3063 |
[user] after |
write=0 read=3064 |
write=0 read=3063 |
messages.1: role 'system' must precede an 'assistant' message or end the array;
the directive-only form (content: [] with output_config) is accepted at any position
Two conclusions. It is not a model-support restriction — both models accept a
mid-conversation system message. And the cache rationale is confirmed: in the legal shape the
prefix bills read in full, so nothing is being folded into system by the gateway.
Scope
Any turn where summarize acts and the kept tail begins with a non-assistant message emits an
invalid request. #110 narrowed this incidentally — snapping the tail off a split tool exchange
lands on the declaring assistant message — but did not close it: keep_last: 1 on a transcript
ending in a user turn still produces [user, summary, user].
Why #110 does not fix it
The obvious fix — require the kept tail to begin with an assistant message — was implemented and
reverted, because it over-reaches. On a transcript with no assistant message near the
boundary the snap walks back to the span start and summarize declines entirely; that regressed
four existing tests (TestSummarizeRestructures, TestSummarizeCountChangeLossless,
TestSummarizeModelErrorFailsOpen, TestSummarizeReusesCheckpoint, all reporting
skipped=true / calls=0). Silently not compacting is a worse failure than the one being
fixed.
So this needs a decision rather than a patch:
- Fall back to
role: "user" for the summary when the tail cannot start with an assistant
message. Always legal, no placement constraint. Changes how the model weights the summary
and gives up the operator-channel/injection-resistance property in the fallback case.
- Always use
role: "user". Simplest and uniform; gives up the property everywhere. Note
the prefix benefit is a property of where the message sits, not of its role, so this likely
keeps the cache win — worth confirming with the same probe.
- Emit the summary as the directive-only form the error message mentions (
content: [] with
output_config), accepted at any position — needs checking whether a summary can be carried
that way at all.
- Snap the tail to an assistant message when one exists, else fall back to (1). Keeps the
property in the common agent-loop case, correct in all cases, most moving parts.
(2) or (4) look best; (4) is the one that preserves the intent. Whichever is chosen, the
placement assertion written for #110 should come back — it caught the residual keep_last: 1
case immediately and is already drafted.
Acceptance
- No emitted wire contains a
role: "system" message followed by a non-assistant message.
- Verified against the probe shapes above, not only in unit tests.
- The four tests listed above still pass, i.e. summarize still acts on transcripts with no
assistant message near the boundary.
summarizeemits its summary as arole: "system"message betweenmsgs[0]and the kept tail(
components/offload/summarize.go:247and:300, no provider branch). That placement isdeliberate and the reasoning is sound — an operator directive added mid-conversation leaves the
cached prefix intact, where editing the top-level
systemblock would invalidate everythingafter it. The problem is only the placement, and it is a hard provider rejection.
Measured
Probed directly against the gateway (benchmark endpoint, not Context Guru), identical ~3k-token
cached system prefix, varying only
messages:[user](warm the prefix)write=3064 read=0write=3063 read=0[user]againwrite=0 read=3064write=0 read=3063[user, system, user]— what summarize emits[user, system, assistant, user]write=0 read=3064write=0 read=3063[user]afterwrite=0 read=3064write=0 read=3063Two conclusions. It is not a model-support restriction — both models accept a
mid-conversation system message. And the cache rationale is confirmed: in the legal shape the
prefix bills
readin full, so nothing is being folded intosystemby the gateway.Scope
Any turn where summarize acts and the kept tail begins with a non-assistant message emits an
invalid request. #110 narrowed this incidentally — snapping the tail off a split tool exchange
lands on the declaring assistant message — but did not close it:
keep_last: 1on a transcriptending in a user turn still produces
[user, summary, user].Why #110 does not fix it
The obvious fix — require the kept tail to begin with an assistant message — was implemented and
reverted, because it over-reaches. On a transcript with no assistant message near the
boundary the snap walks back to the span start and summarize declines entirely; that regressed
four existing tests (
TestSummarizeRestructures,TestSummarizeCountChangeLossless,TestSummarizeModelErrorFailsOpen,TestSummarizeReusesCheckpoint, all reportingskipped=true/calls=0). Silently not compacting is a worse failure than the one beingfixed.
So this needs a decision rather than a patch:
role: "user"for the summary when the tail cannot start with an assistantmessage. Always legal, no placement constraint. Changes how the model weights the summary
and gives up the operator-channel/injection-resistance property in the fallback case.
role: "user". Simplest and uniform; gives up the property everywhere. Notethe prefix benefit is a property of where the message sits, not of its role, so this likely
keeps the cache win — worth confirming with the same probe.
content: []withoutput_config), accepted at any position — needs checking whether a summary can be carriedthat way at all.
property in the common agent-loop case, correct in all cases, most moving parts.
(2) or (4) look best; (4) is the one that preserves the intent. Whichever is chosen, the
placement assertion written for #110 should come back — it caught the residual
keep_last: 1case immediately and is already drafted.
Acceptance
role: "system"message followed by a non-assistant message.assistant message near the boundary.