Skip to content

feat(summarize): a configurable head, and an operator prompt - #252

Open
itay-nakash wants to merge 1 commit into
rossoctl:mainfrom
itay-nakash:feat/summarize-keep-first
Open

itay-nakash wants to merge 1 commit into
rossoctl:mainfrom
itay-nakash:feat/summarize-keep-first

Conversation

@itay-nakash

Copy link
Copy Markdown
Collaborator

The defect

summarizeSpan pinned a hardcoded headCount = 1, and "preserve msgs[0]" means two different things across the dialects — only one of them keeps the task:

what msgs[0] is task statement
Anthropic traffic the opening user turn (the system prompt is a top-level field the pipeline never sees) pinned
OpenAI-shaped the system prompt msgs[1] — the first message of the summarized span

So on OpenAI-shaped traffic the instruction the agent is working from was folded into the summary on every eligible turn, and no config could stop it.

It was survivable rather than obvious because conversationGoal scans by role, not by index: the task text still reaches the summarizer as the goal, so the summary is written toward a task whose verbatim statement is gone. It was reported to me as "the summarizer compacts the starting messages".

The change

keep_first (default 1, unchanged behaviour) makes the head reachable, so an OpenAI-shaped deployment sets 2 and retains [system, task]. A negative value is refused rather than clamped — reading a typo as 0 would summarize the task away, the one thing the head exists to pin.

The head rule generalises rather than moves. The old code dropped headCount to 0 when msgs[0] was an assistant message carrying tool calls, since its results lie inside the span and keeping it emits an unanswered call. That is now a retreat loop over the head's last message: it reduces to exactly the old rule at keep_first: 1, and keeps a call/result pair whole at keep_first: 2. A loop rather than one decrement, because consecutive tool-calling assistant messages are malformed input and malformed input is where a provider 400 comes from.

custom_prompt, and the frame around it is the point: a bare instruction pasted after a long trajectory reads to the model as more trajectory and gets summarized instead of followed, so it is wrapped in text stating whose instruction it is and what precedence it has. Two limits survive it — the ban on inventing anything absent from the conversation (the base prompt's entire purpose) and the <summary> output format.

summarizer1509 is a preset, not a component: summarize configured as the cache-unaware, no-reuse reference summarizer (cache_state: any, resummarize_tokens: 0, keep_first: 2) — the control arm the cache-aware trigger's payoff is measured against, and the right starting point on a backend whose prompt-cache lifetime this repo cannot derive. A preset because turn-aligned cuts, orphaned tool_result repair and now a configurable head all live in summarize already; a separate component would duplicate ~500 lines of summarizer to deliver three YAML values.

Tests

summarize_keepfirst_test.go drives the real two-turn path — the summary is commissioned off the hot path, so turn 1 forwards untouched and turn 2 splices — draining on WaitForSummaryForTest rather than sleeping. It carries a vacuity guard, because a fixture that stops reaching the span makes every head assertion pass for the wrong reason.

Four cases: the default reproduces the historical shape (task dropped); keep_first: 2 retains it; the dangling-tool-call retreat; and keep_first: 0 honoured vs negative refused. The keep_first: 2 case also re-asserts the no-system-role-away-from-index-0 contract, since a larger head is a new way to violate it.

go vet and gofmt clean; ./components/... and ./config/... pass. Two pre-existing failures in cmd/context-guru-proxy are environmental on my machine (install.sh needs bash > 3.2, and one test finds a binary already installed at ~/.local/bin) — neither package is touched by this diff.

One note, not a change

TestSummarizeEmitsNoSystemRoleAwayFromTheHead currently skips on its own fixture (summarize declined on this fixture) — the min_request_frac 0.9 default plus FracResolvable means it never reaches the acting path, so the guard against the 400 messages.1: role 'system' must precede... regression is not actually exercised. My new test pins min_request_frac: 0 / cache_state: any to get there. Worth a follow-up on that fixture; I left it alone to keep this diff to one subject.

🤖 Generated with Claude Code

THE HEAD WAS A HARDCODED 1, AND "PRESERVE msgs[0]" MEANS TWO DIFFERENT THINGS.

summarizeSpan pinned exactly one leading message. On Anthropic traffic that is
right: the system prompt is a top-level `system` field the pipeline never sees,
so msgs[0] IS the opening user turn and the task is pinned. On OpenAI-shaped
traffic msgs[0] is the system prompt and the task statement is msgs[1] — the
FIRST message of the summarized span. So the instruction the agent is working
from was folded into the summary on every eligible turn, and no config could
stop it.

It was survivable rather than obvious because conversationGoal scans by ROLE,
not by index: the task text still reaches the summarizer as the goal, so the
summary is written toward a task whose verbatim statement is gone. The reporter
saw it as "the summarizer compacts the starting messages".

`keep_first` (default 1) makes the head reachable, so an OpenAI-shaped
deployment sets 2 and retains [system, task]. The default is unchanged
deliberately: moving it would change the output of every existing deployment.

THE HEAD RULE GENERALISES RATHER THAN MOVES. The old code dropped headCount to
0 when msgs[0] was an assistant message carrying tool calls, because its results
lie inside the span and keeping it emits an unanswered call. That is now a
retreat loop over the head's LAST message, which reduces to exactly the old rule
at keep_first: 1 and keeps a call/result pair whole at keep_first: 2. A loop
rather than one decrement: consecutive tool-calling assistant messages are
malformed input, and malformed input is where a provider 400 comes from.

`custom_prompt` is the second knob, and the frame around it is the point. A bare
instruction pasted after a long trajectory reads to the model as more trajectory
and gets summarized instead of followed, so it is wrapped in text that says
whose instruction it is and what precedence it has. Two limits survive it: the
ban on inventing anything absent from the conversation — the base prompt's
entire purpose — and the <summary> output format.

`summarizer1509` is a PRESET, not a component: summarize configured as the
cache-unaware, no-reuse reference summarizer (cache_state any, resummarize_tokens
0, keep_first 2), which is the control arm the cache-aware trigger's payoff is
measured against, and the right starting point on a backend whose prompt-cache
lifetime this repo cannot derive. It is a preset because turn-aligned cuts,
orphaned tool_result repair and now a configurable head all live in summarize
already — a separate component would duplicate ~500 lines to deliver three YAML
values.

Tests drive the real two-turn path (the summary is commissioned off the hot
path, so turn 1 forwards untouched and turn 2 splices) and carry a vacuity
guard, because a fixture that stops reaching the span makes every head assertion
pass for the wrong reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Itay-Nakash <itay.nakash@ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

2 participants