Skip to content

fix(agent): compaction must not hoist an answered steer to the newest-input slot - #272

Open
WhichPaths wants to merge 1 commit into
yetone:mainfrom
WhichPaths:fix/compaction-reorders-answered-steer
Open

fix(agent): compaction must not hoist an answered steer to the newest-input slot#272
WhichPaths wants to merge 1 commit into
yetone:mainfrom
WhichPaths:fix/compaction-reorders-answered-steer

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

The bug

Stage-2 compaction partitioned history by item type: every non-tool item sitting after the first tool item went into a tailNonTool bucket that both rebuild paths re-appended after all surviving pairs.

const newHistory = [
  ...stage1History.slice(0, leadingNonToolEnd),
  summaryMarker,
  ...survivingTailPairs,
  ...tailNonTool,        // <- everything non-tool, moved to the end
]

So compaction reordered, not just deleted. That matters more than it looks, because of two things that are true together:

  • The only non-tool items that ever appear mid-history are role:'user' items the runtime injects itself — the mid-turn steer render, the status-required nudge, and the completion-rejection nudge.
  • There is no assistant message in history for the model to see its own reply in. Assistant text is deliberately never added as a history item (synthesizing one with no item_id / part_id / responseId corrupts history shape — the comment in turn.ts says so and names the earlier bug).

Put together: the only in-context evidence that a steer was already handled is that the function_call / function_call_output pairs answering it sit after it. The tail-move inverted exactly that.

Reproduced

Against the real module, on the history shape turn.ts builds:

before: [0] user  book the team sync for 4pm and send the agenda
        c1 c2 c3 pairs  (create 4pm, draft agenda, reply "booked 4pm")
        [7] user  [Mid-turn update — new message from Alice] actually make it 5pm
        c4 c5 pairs     (move to 5pm, reply "moved to 5pm")

after:  [0] user  book the team sync for 4pm …
        [1] user  [auto-compaction · 3 earlier pairs summarized]
        [2..5]    c4 c5          ← the answer, now BEFORE the question
        [6] user  [Mid-turn update] actually make it 5pm     ← LAST ITEM

The steer's own text is written as an imperative fresh arrival — "Re-assess your current plan in light of these and continue" — so sitting in the newest-input slot it reads as a live instruction. The agent moves the event a second time and posts a second reply. turn.ts assigns the compacted array back to history, so it re-asks on every remaining hop, not once.

Anything side-effecting the steer asked for — send an email, post to a channel, move a calendar event — gets done twice. The same shape applies to the status-required and completion-rejection nudges: a satisfied nudge re-presented last is a live instruction again.

How often: it needs a mid-turn steer, at least one more hop of work after it, and stage-2 compaction firing (past 75% of the context window, with stage-1 truncation not enough on its own). Steering is a headline feature and long tool-heavy turns are exactly the ones that cross 75%, so this is a recurring bug on busy agents rather than a corner case. It cannot happen when compaction stops at stage 1.

The fix

Rebuild in original order. partitionHistory no longer collects a tail bucket — it exists only to decide which call_id groups get dropped. Both rebuild sites now walk the original array and skip the dropped groups:

function tailWithoutDroppedGroups(history, leadingNonToolEnd, droppedCallIds) {
  const kept = []
  for (let i = leadingNonToolEnd; i < history.length; i++) {
    const item = history[i]
    const key = groupKeyOf(item)
    if (key !== null && droppedCallIds.has(key)) continue
    kept.push(item)
  }
  return kept
}

Surviving items keep their positions and their relative order. Documented as invariant 4 in the module header: compaction only deletes, never reorders.

This also stops compaction re-grouping interleaved items. One hop pushes fc1, fc2, out1, out2; that is the shape every uncompacted hop already puts on the wire, so handing it back unchanged is strictly closer to known-good than the fc1,out1,fc2,out2 the bucket rebuild produced. The existing invariants still hold — a function_call still immediately precedes its own function_call_output, and the leading seed messages are untouched.

Tests

Three added, no existing test changed — 100 lines added, 0 removed:

  • stage 2 (drop path): an ANSWERED mid-turn steer keeps its position — never hoisted to the newest-input slot
  • stage 2 (summary path): an ANSWERED mid-turn steer keeps its position
  • stage 2: surviving tool items keep their original relative order (no regrouping by call_id)
against main:   not ok 41, not ok 42, not ok 43        # pass 40  # fail 3
with the fix:                                          # pass 43  # fail 0

The 40 that were already there stay green, including the one that pins how a malformed no-call_id item is handled — that item's original position happens to be after the surviving pairs, so order-preserving and tail-moving agree there.

Also green: tsc --noEmit, biome lint ., all three scripts/guard-*.mjs, and the unit suite (1399 tests, 0 failures).

…-input slot

Stage-2 compaction partitioned history by item TYPE: every non-tool item
sitting after the first tool item went into a `tailNonTool` bucket that both
rebuild paths re-appended AFTER all surviving pairs. So compaction reordered,
not just deleted.

The only non-tool items that ever appear mid-history are `role:'user'` items
the runtime injects itself -- the mid-turn steer render, the status-required
nudge, and the completion-rejection nudge. And there is no assistant message
in `history` for the model to see its own reply in: assistant text is
deliberately never added as a history item. So the ONLY in-context evidence
that a steer was already handled is that the function_call/function_call_output
pairs answering it sit AFTER it -- exactly the ordering the tail-move inverted.

Reproduced against the real module, on the history shape turn.ts builds:

  before: [0] user  book the sync for 4pm and send the agenda
          c1 c2 c3 pairs (create 4pm, draft agenda, reply "booked 4pm")
          [7] user  [Mid-turn update] actually make it 5pm
          c4 c5 pairs (move to 5pm, reply "moved to 5pm")

  after:  [0] user  book the sync for 4pm ...
          [1] user  [auto-compaction summary]
          [2..5]    c4 c5            <- the answer, now BEFORE the question
          [6] user  [Mid-turn update] actually make it 5pm   <- LAST ITEM

The steer's own text reads as an imperative fresh arrival ("Re-assess your
current plan in light of these and continue"), so re-presented in the
newest-input slot it is a live instruction. The agent moves the event a second
time and posts a second reply, and turn.ts persists the compacted array for
every remaining hop, so it re-asks on every hop after that. Anything
side-effecting the steer asked for -- send an email, post to a channel, move an
event -- gets done twice. Same shape for the two nudges: a satisfied nudge
re-presented last is a live instruction again.

Rebuild in original order instead. `partitionHistory` no longer collects a tail
bucket; it exists only to decide WHICH call_ids get dropped. Both rebuild sites
now walk the original array and skip the dropped groups, so surviving items keep
their positions and their relative order. That also stops compaction re-grouping
interleaved items: a hop pushes fc1, fc2, out1, out2, which is the shape every
UNCOMPACTED hop already puts on the wire, so handing it back unchanged is closer
to known-good than the fc1,out1,fc2,out2 the bucket rebuild produced.

Documented as invariant 4 in the module header: compaction only deletes, never
reorders.

No existing test changed -- 100 added, 0 removed. The three new tests fail on
main and pass with this; the 40 that were already there stay green, including
the one pinning how a malformed no-call_id item is handled.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant