Skip to content

feat(apply): recognize tool output embedded in user messages (terminal-style agents) - #63

Closed
itay-nakash wants to merge 1 commit into
rossoctl:mainfrom
itay-nakash:feat/apply-embedded-tool-output
Closed

feat(apply): recognize tool output embedded in user messages (terminal-style agents)#63
itay-nakash wants to merge 1 commit into
rossoctl:mainfrom
itay-nakash:feat/apply-embedded-tool-output

Conversation

@itay-nakash

Copy link
Copy Markdown
Collaborator

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 }, and
the 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):

requests through the proxy                        2,245
component invocations (9 components)             20,205
acted                                                 0
tokens_before                               133,802,901
tokens_after                                133,802,901   (identical)

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.

  • 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" -- and the last marker is the one that actually precedes
    the payload (48 real occurrences in the run).
  • Trailing instructions excluded. _get_completion_confirmation_message appends
    "Are you sure you want to mark the task as complete? ..." AFTER the screen; that
    stays outside the span.
  • 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.

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:

  • TestEmbeddedTerminalOutputIsCompacted: the body actually shrinks and the
    preamble survives.
  • TestEmbeddedSpliceIsByteLossless: the I1 invariant. Non-messages fields, every
    assistant turn, the instruction prompt, and the newest (unmasked) screen all come
    back byte-identical.
  • TestEmbeddedMarkerNotMatchedInProse: a mid-sentence marker must not qualify.
  • TestEmbeddedNestedMarkerTakesInnermost: nesting plus trailing-instruction
    boundaries.
  • TestEmbeddedSummaryMarkerLeavesNoToolReference: pins the marker_mode: summary
    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.

go test -race ./...                    pass
go test -race -tags cg_skeleton ./...  pass
make lint                              clean
apply/ coverage                        80.6%

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:

components:
  mask:
    marker_mode: summary   # self-contained marker, no dangling tool reference

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.

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants