Skip to content

feat(agentdiet): add the published AgentDiet trajectory-reduction baseline - #66

Open
itay-nakash wants to merge 1 commit into
rossoctl:mainfrom
itay-nakash:feat/agentdiet-baseline
Open

feat(agentdiet): add the published AgentDiet trajectory-reduction baseline#66
itay-nakash wants to merge 1 commit into
rossoctl:mainfrom
itay-nakash:feat/agentdiet-baseline

Conversation

@itay-nakash

Copy link
Copy Markdown
Collaborator

Why

Every efficiency number in this repo is measured against off. Nothing here has been
compared against a published method on the same traffic, so there is no way to answer
"is context-guru's approach better than what the literature does, or just better than
nothing?"

AgentDiet (Xiao, Gao, Peng, Xiong -- FSE 2026, arXiv:2509.23586) is the closest published
analogue: LLM-driven trajectory reduction on agentic coding traffic, reporting -39.9% to
-59.7% input tokens and -21.1% to -35.9% total cost at unchanged task success on SWE-bench
Verified and Multi-SWE-bench Flash. This PR adds it as a component and a preset so it can
be run as an arm beside extract_llm and summarize, on one agent, one model and one
benchmark.

It is offered as a BASELINE, not a recommendation, and the preset list says so.

What

agentdiet (Offload, LLM) plus an agentdiet preset at the paper's tuned hyperparameters.
Its unit is the STEP -- one assistant message plus the tool results answering it -- and
four consequences of that are things no existing component here does:

  • A fixed AGE picks the target, not size and not economics. When the agent has completed
    step s, only step s-a is eligible (delay_steps, a=2). The most recent a steps are
    untouchable, which is the paper's protection against a malfunctioning reflection model:
    a bad reduction cannot land on what the agent is working on right now.
  • The model is handed a sliding WINDOW of neighbouring steps as XML (context_steps, b=1,
    so steps [s-a-b .. s]). This is the part extract_llm structurally cannot do, and it is
    what lets the model call content redundant (already stated nearby) or expired
    (mattered only to a finished sub-goal) rather than merely verbose. expired is a signal
    nothing else here computes.
  • Two thresholds bound the spend: a step below min_step_tokens (theta=500) never earns a
    call, and a reduction that comes back is applied only if it clears min_saved_tokens or
    max_keep_ratio.
  • A reduction is made once, then frozen and replayed byte-identically, so the request
    prefix stays stable and reductions accumulate over the session -- what the paper gets
    for free by editing the agent's own trajectory in place.

Defaults are the paper's a=2, b=1, theta=500. min_saved_tokens (400) and
max_keep_ratio (0.8) come from the authors' artifact, whose apply-gate is
saved >= 400 || keep < 0.8; Algorithm 1 in the paper states it more simply as
l_orig - l_reduced > theta. The artifact's form is the default because it is what
produced the published numbers. Set min_saved_tokens: 500 and max_keep_ratio: 0 for
the paper's stated gate.

Three deviations, all forced by where context-guru sits, documented in
docs/components/agentdiet.md:

  • The reduction is written IN PLACE into the step's tool-result messages. The paper's
    reflection module replaces the whole step with one assistant message; here only
    summarize may change the message count. Tool results are 63% of trajectory tokens by
    the paper's own accounting (30.4K of 48.4K), so this keeps most of the mass.
  • Tool-call ARGUMENTS are not reduced. On Anthropic traffic bifrost's schema does not model
    tool_use blocks, so their name and input are invisible to every component here and the
    assistant message is not even Rewritable. That forgoes the paper's str_replace_editor
    redundancy case, ~25% of trajectory tokens, and is the main structural reason to expect a
    smaller reduction here than 39.9%-59.7%.
  • The prompt is written from the paper's description of its four parts rather than copied
    from the artifact. components.Model is one prompt in / text out, so the paper's system
    • user + assistant-prefill + </step> stop sequence is folded into one prompt and the
      reply is parsed defensively instead.

cache_tail_only defaults to FALSE, unlike every other age-based offloader here, and that
is the faithful setting rather than an oversight. The target is chosen by AGE, so with
a >= 1 it is ALWAYS inside the provider's cached prefix; a tail restriction would make the
component a silent no-op on every caching backend, which is the trap #28 left extract_llm
in. The paper accepts one suffix cache-write per reduced step and prices it, and because
the decision is then frozen, that write happens once per step rather than once per turn.

Fail-open is countable. CONTEXT_GURU_AGENTDIET_TIMEOUT (default 90s) bounds one
reflection call, and /stats reports agentdiet_timeouts, agentdiet_errors and
agentdiet_call_timeout_ms. Its own budget rather than reusing CONTEXT_GURU_LLM_TIMEOUT
because the prompts differ by roughly the window multiple: extract_llm sends one tool
output, this sends b+1+a serialized steps. Separate counters for the same reason as
summarize's -- this runs in its own arm, so folded into llm_* an agentdiet arm whose
every call expired would report llm_timeouts 0 and read as having nothing to reduce.

One case in the reply parser is DECLINED rather than guessed. A <result> block whose id
is missing or unparseable is read as id 0 only when the step has a single result; on a step
with parallel tool calls it is dropped. Defaulting it to 0 there would splice one tool's
compressed output into ANOTHER tool's message, and that is a misattribution rather than a
lossy reduction: the never-worse token check cannot catch it because the text is smaller,
merely wrong; the agent then reads an answer that never belonged to the call above it; and
the decision is frozen and replayed for the rest of the session. The prompt does ask the
model to reproduce every id unchanged, but there is no stop sequence to enforce the shape,
so the parser must not depend on it having obeyed.

Also adds two config tests that were missing: every preset must BUILD (a name-list is only
a promise until the registry resolves it), and a rich preset's pipeline must match its
presets entry, or ?preset= means something different over HTTP than in a file. The
first found that coding cannot build without -tags cg_skeleton; it skips that case
explicitly rather than papering over it.

Measured, and it does not win here

Run as a fourth arm against off and two of our own pipelines. SWE-bench Verified, 50
tasks, SEED=0 so all arms see the same task set, claude-code, Qwen/Qwen3.6-27B on
self-hosted vLLM (TP=2, 128k, concurrency 4), FOUR replicate trials -- 798 trials total, 0
harbor exceptions. All figures below are MATCHED: only tasks that scored and were
exception-free in all four arms of that trial are compared, because averaging each arm over
its own survivors is survivor bias and has inverted a sign in this workspace before.

Context per turn, two independent measures. final_ctx is the last prompt of the
trajectory; tok/step is cumulative prompt tokens divided by step count, which is the
figure that does not simply grow with step count:

arm                 final_ctx   per-trial delta        mean     SD
off                    52,533   +0.0 +0.0 +0.0 +0.0    +0.0%   0.0
agentdiet-lo           52,976   +0.5 -2.4 -1.1 +6.6    +0.9%   4.0
codesafe-lo-swe        39,909  -26.6 -25.2 -24.3 -20.0 -24.0%   2.9
codesmart-lo-swe       40,203  -26.8 -27.4 -21.5 -18.1 -23.5%   4.5

arm                  tok/step   per-trial delta        mean     SD
agentdiet-lo           40,224   -6.0 -0.9 -0.9 +3.5    -1.1%   3.9
codesafe-lo-swe        31,296  -23.3 -24.8 -26.3 -17.8 -23.1%   3.7
codesmart-lo-swe       31,772  -24.1 -22.7 -23.5 -17.6 -21.9%   3.0

Both measures agree: the published method reduces essentially NOTHING on this traffic
(+0.9%, SD 4.0 -- the effect is smaller than the run-to-run spread), while our own
deterministic reducers cut 23-24% on the same tasks with an SD of about 3.

And it is not free. Agent exec time per task, matched:

arm                per-trial delta vs off       SD
agentdiet-lo       +39.7 +36.6 +40.2 +87.6    24.4
codesafe-lo-swe     +8.3 +10.7 +18.0 +58.7    23.6
codesmart-lo-swe    -3.4  +7.2 +29.7 +64.5    30.0

That is one reflection call on the request's hot path every turn: cg_added_ms_avg 2,862
to 4,260 ms across the four trials. Reward is parity, not a loss -- 0.646 vs 0.682, i.e.
under two tasks in fifty, and two untreated baselines here flip 7 of 50 task outcomes, so
14% of tasks is the noise floor and no reward delta in this run clears it.

The cause is the CADENCE, not the implementation, and /stats says so rather than leaving
it to inference. 624 reflection calls against 2,442 proxy requests is one call per four
turns -- theta=500 plus the benefit gate decline the other three -- i.e. ~12.5 calls per
trial against a mean 50.5 steps, and not every call is applied. One step reduced per turn
cannot offset a trajectory that grows one step per turn, which is also why mask wins on
this traffic: it reduces everything older than keep_recent, not one step. The freeze
itself is sound where it was exercised -- agentdiet_timeouts 0, reverted 0,
discarded_changes 0 across all four trials, frozen_dropped and frozen_repaired 0, and
frozen_hits 3,511 and 1,630 in trials 1 and 2, i.e. reductions do freeze and do replay
byte-identically. See the third bullet below for trials 3 and 4.

Two things this is NOT. It is not a refutation of the paper: the reflection model here is a
27B open model on a shared GPU, where the paper used GPT-5 mini against Claude 4 Sonnet
(~12x cheaper than the agent, and much stronger than ours), and the tool_use deviation
above puts ~25% of the trajectory permanently out of reach in this position. It is also not
a result about min_step_tokens: lowering theta below the paper's 500 would raise the
reduction count, but it would no longer be a reproduction, which is the whole point of
having it in tree.

So the shipped shape is the honest one -- agentdiet exists as a baseline arm and appears
in no recommended preset, and its preset carries no other offloader so that whatever it
does is attributable to it.

Testing

make lint                                    clean
gofmt -l .                                   clean
make build                                   ok
go test -race -count=1 ./...                 pass
CGO_ENABLED=1 go test -race -tags cg_skeleton pass (components, config, proxy)
make cover                                   82.7% total

Component tests cover the age window, the depth guard, the sliding window, both thresholds,
reversibility, freeze and replay, the parallel-call parser guard, and every fail-open path
(deadline, model error, no model, unparseable reply). Two mutation checks, because a test
that cannot fail proves nothing:

  • Forcing the parser to accept an unlabelled block on a parallel step makes four cases fail,
    including the end-to-end one, with "result 0 was rewritten from an unlabelled block".
  • The agent-retry fixture is sized so the reduction stays ABOVE theta, which is the only way
    to reach the double-reduce path: 2 model calls without the phase-2 guard, 1 with it. The
    theta gate would otherwise mask it, since a reduced step normally falls below theta.

End to end through the proxy on a claude-code-shaped Anthropic body with
PRESET=agentdiet and a stub reflection model: exactly one call per turn, only the step at
age 2 reduced, window [s-1 .. s+2], reversible marker present, and the reduced step
replayed byte-identically on the next turn with no second call for it.

What to scrutinise

  • The parser is substring-based, so a tool output that itself contains a literal
    </result> truncates that same id's body early. It cannot move text to a different id
    (scanning always takes the leftmost <result), so the failure mode is over-truncation of
    the correct message, which is reversible through the marker like any other reduction.
    Left as is rather than escaping, because escaping would change what the model sees.
  • savings_pct for this arm reads 11.52% while final context is unchanged. That is the
    inflated-denominator effect: adjusted_saved counts tokens removed across REPEATED
    sends, not a cut in what the model reads per turn. The tables above deliberately use
    final_ctx and tok/step instead. Worth knowing before anyone quotes the proxy's own
    percentage for this component.
  • delay_steps: 0 is accepted, for an ablation, and now documented as giving up the
    protection the component is otherwise built around -- it targets the step the agent has
    just completed, so "the most recent a steps are untouchable" holds only for a >= 1. It is
    not rejected outright because the ablation is legitimate.
  • Not caused by this PR, but found while reading its numbers and worth someone's attention:
    in trials 3 and 4 the freeze store stopped holding decisions for EVERY treated arm, not
    just this one. codesafe-lo-swe went from 33,095 frozen_hits / 8,392 frozen_dropped
    in trial 1 to 68 / 70,391 in trial 3, on an identical config, and agentdiet-lo
    frozen_hits went 3,511 -> 0. Those trials ran longer trajectories (3,396 vs 2,924 proxy
    requests on that arm), which points at the TTL / pin-cap budget in store.Memory rather
    than at any one component -- frozen_flips reached 70,380, and each flip is a suffix
    cache-write at ~11.5x the read price. Worth a look independently of this baseline, since
    it silently degrades whichever offloader is deployed.

…eline

Adds `agentdiet`, a reproduction of the method in "Reducing Cost of LLM Agents
with Trajectory Reduction" (Xiao, Gao, Peng, Xiong -- FSE 2026,
arXiv:2509.23586), plus an `agentdiet` preset that runs it at the paper's tuned
hyperparameters. It is offered as a COMPARABLE BASELINE, not a recommendation:
it lets the published approach be A/B'd against extract_llm and summarize on the
same traffic, agent and benchmark.

Its unit is the STEP -- one assistant message plus the tool results answering it
-- and four things follow from that which no existing component here does:

  * A fixed AGE picks the target, not size or economics: when the agent has
    completed step s, only step s-a is eligible (delay_steps, a=2). The most
    recent a steps are untouchable, so a bad reduction cannot corrupt what the
    agent is working on now.
  * The model gets a sliding WINDOW of neighbouring steps as XML
    (context_steps, b=1 => steps [s-a-b .. s]). This is what extract_llm
    structurally cannot do, and it is what lets the model call content
    *redundant* or *expired* rather than merely verbose.
  * Two thresholds bound the spend: a step below min_step_tokens (theta=500)
    never earns a call, and a reduction is applied only if it clears
    min_saved_tokens or max_keep_ratio.
  * A reduction is made once, then frozen and replayed byte-identically, so the
    prefix stays stable and reductions accumulate across the session.

Three deviations, all forced by where context-guru sits, documented in
docs/components/agentdiet.md: the reduction is written in place into the step's
tool results (only summarize may change the message count) rather than replacing
the step with one assistant message; tool-call arguments are not reduced because
bifrost's schema does not model Anthropic tool_use blocks; and the prompt is
written from the paper's description rather than copied from its artifact.

cache_tail_only defaults to FALSE, unlike every other age-based offloader here.
The target is chosen by age, so with a>=1 it is always inside the cached prefix
and a tail restriction would make the component a silent no-op on caching
backends. The paper accepts one suffix cache-write per reduced step and counts
it; the freeze makes that once per step, not once per turn.

Fail-open is countable: CONTEXT_GURU_AGENTDIET_TIMEOUT bounds one reflection
call and /stats reports agentdiet_timeouts / agentdiet_errors /
agentdiet_call_timeout_ms, so an arm that quietly stops reducing cannot be
mistaken for one that got faster.

The reply parser declines one case rather than guessing it. A `<result>` block
whose id is missing or unparseable is read as id 0 only when the step has a
single result; on a step with PARALLEL tool calls it is dropped. Defaulting it
to 0 there would splice one tool's compressed output into another tool's
message, which is a MISATTRIBUTION rather than a lossy reduction: the
never-worse token check cannot see it (the text is smaller, merely wrong), the
agent reads an answer that never belonged to the call above it, and the decision
is then frozen and replayed for the rest of the session. The prompt does ask the
model to reproduce every id unchanged; the parser does not depend on it having
obeyed, because it has no stop sequence to enforce the shape. Pinned by four
parser cases and one end-to-end test, all four verified to fail when the guard
is removed.

delay_steps: 0 is still accepted, for an ablation, but it is now documented as
giving up the protection above -- it targets the step the agent has just
completed, so the "most recent a steps are untouchable" property holds only for
a >= 1.

Also adds two config tests that were missing: every preset must BUILD (a
name-list is only a promise until the registry resolves it), and a rich preset's
pipeline must match its `presets` entry (or ?preset= means something different
over HTTP than in a file). The first found that `coding` cannot build without
-tags cg_skeleton; it skips that case explicitly rather than papering over it.

Verified: unit tests cover the age window, the sliding window, both thresholds,
reversibility, freeze/replay, the parallel-call parser guard, and the
fault-injection paths (timeout, model error, no model, unparseable reply). End
to end through the proxy on a claude-code-shaped Anthropic body,
PRESET=agentdiet against a stub reflection model: exactly one call per turn,
only the step at age 2 reduced, window = [s-1 .. s+2], reversible marker
present, and the reduced step replayed byte-identically on the next turn with no
second call for it.

Also guards the agent-retry case: two consecutive requests of the same depth
target the same step, and after a replay the message holds the reduced text, so a
frozen lookup keyed on its content hash misses and (under marker_mode: off) there
is no placeholder for skipReduce to catch. The theta gate usually masks this
because a reduced step falls below it -- but a big output whose reduction stays
above theta would be reduced twice, the second time from already-reduced bytes.
Phase 2 now skips indices phase 1 replayed; the test fixture is sized to keep the
reduction above theta so it actually exercises the path (2 model calls without the
guard, 1 with it).

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: New/ToDo

Development

Successfully merging this pull request may close these issues.

2 participants