feat(apply): recognize tool output embedded in user messages (terminal-style agents) - #63
Closed
itay-nakash wants to merge 1 commit into
Closed
Conversation
…l-style agents)
Some agents never send tool outputs as tool messages. Harbor's terminus-2 — and
any agent built the same way — drives a plain chat loop with NO tools array,
feeding terminal output back as the TEXT of the next user message behind a fixed
template preamble ("New Terminal Output:", "Current terminal state:", ...).
Every component begins with `if m.Role != ChatMessageRoleTool { continue }`, and
the tool_result -> role=tool conversion in normalize is Anthropic-only, so on the
OpenAI route this traffic reached the pipeline as ordinary user prose and passed
through completely untouched.
It does not look like a bug from the outside: the proxy runs, every component runs,
the benchmark completes, and the report shows 0.00% savings — which reads as
"nothing here was worth compacting" rather than "the text was never examined."
Measured on a 50-task terminal-bench run (gemma-4-31B, 128k window): 2,245
requests, 20,205 component invocations across 9 components, acted=0, and
tokens_before == tokens_after exactly. Not a threshold problem — median final
context was 14,095 tokens and 47 of 48 trials cleared the components' floor, so
lowering min_tokens could not have helped.
This adds a third normalization shape. apply/embedded.go is self-contained;
apply.go gains one normalize branch and one write-back case:
- New slot kind embeddedToolText records the untouched prefix/suffix around the
extracted span, so the write-back rebuilds the string field as
preamble + rewritten + trailer. The preamble marker and any trailing instruction
prose stay byte-identical — they tell the model how to respond, so they are not
tool output and are never handed to the components.
- Innermost marker wins. terminus-2 nests them: the completion-confirmation prompt
is "Current terminal state:\n" wrapping a screen that itself begins "New Terminal
Output:\n". The last marker is the one that actually precedes the payload (48 real
occurrences in the run).
- Deliberately narrow, to avoid the dangerous direction. A marker qualifies only at
a line start and only if the span clears embeddedMinChars (200, override with
CONTEXT_GURU_EMBEDDED_MIN_CHARS). terminus-2's own system prompt QUOTES THESE
VERY STRINGS while describing the output format — treating that as tool output
would corrupt the instructions. The 5,661-char prompt in the fixture is correctly
rejected. Markers come from the agent's actual templates
(tmux_session.get_incremental_output, terminus-{json,xml}-plain.txt, timeout.txt),
not guessed.
Reversibility: these agents declare no tools, so they cannot call
context_guru_expand and expand.Inject's `auto` mode correctly declines to advertise
it. With the default marker_mode: full the marker therefore invites a call the
agent cannot make (the stash is still reachable via GET /expand?id=). The default
is left alone — marker_mode is per-component config, and overriding it from the
absence of a tools array is the wrong layer — so the pairing is documented and
pinned by a test instead. Recommended for tool-less agents: marker_mode: summary.
Tests run against apply/testdata/terminus2_request.json, a real 28 KB request body
reconstructed from the failing run (task adaptive-rejection-sampler), not a
synthetic mock: the body actually shrinks and the preamble survives; the splice is
byte-lossless for non-messages fields, every assistant turn, the instruction prompt
and the newest unmasked screen; a mid-sentence marker does not qualify; nesting and
trailing-instruction boundaries hold; and marker_mode: summary leaves no dangling
tool reference. Verified the tests gate the behavior — with the normalize branch
disabled, four of them fail with the original acted=0 symptom.
No behavior change for Anthropic, or for OpenAI traffic that already carries
role=tool: the new branch is reached only for a role=user message whose content is
a plain string containing a known marker. Fail-open is unchanged (BodyOpts' recover
still covers the new path), and a count-changing component still routes through
rebuildCountChanged, which emits the original raw bytes for an embedded slot.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Itay-Nakash <itay.nakash@ibm.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Some agents never send tool outputs as tool messages at all. Harbor's terminus-2,
and any agent built the same way, drives a plain chat loop with NO tools array and
feeds the terminal screen back as the TEXT of the next user message, behind a fixed
template preamble ("New Terminal Output:", "Current terminal state:", ...).
Every component begins with
if m.Role != ChatMessageRoleTool { continue }, andthe tool_result -> role=tool conversion in apply's normalize is Anthropic-only. So
on the OpenAI route this traffic reached the pipeline as ordinary user prose and
passed through completely untouched.
It does not look like a bug from the outside. The proxy runs, every component runs,
the benchmark completes, and the report shows 0.00% savings -- which reads as
"nothing here was worth compacting" rather than "the text was never examined."
Measured on a 50-task terminal-bench run (gemma-4-31B, 128k window):
Not a threshold problem: median final context was 14,095 tokens and 47 of 48
trials were above the components' 3,000-token floor. Lowering min_tokens could not
have helped, because no component ever looked at the bytes.
This adds a third normalization shape. apply/embedded.go is self-contained;
apply.go gains one normalize branch and one write-back case.
extracted span, so the write-back rebuilds the string field as
preamble + rewritten + trailer. The preamble marker and any trailing instruction
prose stay byte-identical: they tell the model how to respond, so they are not
tool output and are never handed to the components.
prompt is "Current terminal state:\n" wrapping a screen that itself begins
"New Terminal Output:\n" -- and the last marker is the one that actually precedes
the payload (48 real occurrences in the run).
"Are you sure you want to mark the task as complete? ..." AFTER the screen; that
stays outside the span.
a line start and only if the span clears embeddedMinChars (200, override with
CONTEXT_GURU_EMBEDDED_MIN_CHARS). terminus-2's own system prompt QUOTES THESE
VERY STRINGS while describing the output format -- treating that as tool output
would corrupt the instructions. The 5,661-char prompt in the fixture is correctly
rejected.
The four markers are taken from the agent's actual templates
(tmux_session.get_incremental_output, terminus-{json,xml}-plain.txt, timeout.txt),
not guessed.
Tests run against apply/testdata/terminus2_request.json, a real 28 KB request body
reconstructed from the failing run (task adaptive-rejection-sampler), not a
synthetic mock:
preamble survives.
assistant turn, the instruction prompt, and the newest (unmasked) screen all come
back byte-identical.
boundaries.
pairing described below.
I verified the tests genuinely gate the behavior: with the normalize branch
disabled, four of them fail with the original acted=0 symptom. Also verified
end-to-end through the built binary rather than only in unit tests -- POSTing the
fixture to /compact with a [mask] pipeline takes it from 28,281 to 18,316 bytes
(35.24%); the older screen goes 10,192 -> 342 chars carrying a <cg:HASH> marker,
and the newest screen plus both preambles are byte-identical.
One design question worth a reviewer's opinion. These agents declare no tools, so
they cannot call context_guru_expand, and expand.Inject's "auto" mode correctly
declines to advertise it. With the default marker_mode: full, the marker therefore
invites a call the agent cannot make (the stash is still reachable out-of-band via
GET /expand?id=). I did NOT change the default, because marker_mode is
per-component config and silently overriding it based on the absence of a tools
array felt like the wrong layer. Instead the pairing is documented and pinned by a
test. Recommended config for tool-less agents:
Happy to make that automatic if reviewers prefer.
Scope and compatibility: no behavior change for Anthropic, or for OpenAI traffic
that already carries role=tool. The new branch is reached only for a role=user
message whose content is a plain string containing a known marker. Fail-open is
unchanged -- BodyOpts' recover still covers the new path -- and a component that
changes the message count still routes through rebuildCountChanged, which emits the
original raw bytes for an embedded slot.