Skip to content

[agent] Give context management turn-level control: pin, unpin, drop #306

Closed

Description

@yusiwen

Context

Roadmap B5 (anchor #168): context management has one strategy. compressHistory
(agent/compression.go) protects the first two user groups, keeps the last two, and summarizes
everything between them, automatically at compression_threshold or by hand with /compress. There is
no way to say "keep this", no way to say "drop that", and no run-level compaction at all — the loop's own
messages are never compressed, only a.History at the start of a run.

Two consequences are worth naming:

  • No control. A user who pasted a large document, or who wants an early instruction kept, has the
    same two options for everything: let it be summarized, or set the threshold so high that nothing is.
  • The D7 gap. A continuation round is one Run, and compaction runs only at its start, so a long
    round grows until the provider refuses — the failure mode D7's rounds make routine.

Where the increments stand

Increment State Where
S1 turn-level control Landed 2026-10-28 #307, 396e77f
S2 run-level compaction + pin persistence Landed 2026-10-28 #311, 65d35d4; path-targeted unload moved to S4
S3 compression preview Landed 2026-10-31 #312, 8d1ccb8
S4 path-targeted unload Landed 2026-10-31 #314, 7887f09

A fresh session picking this up needs only this section: S1's sections are kept for provenance, and the
two open increments each name the failure they remove, the code they start from, and what "done" means.


S1 — turn-level control (landed)

  • types.Message.Pinned (json:"pinned,omitempty"); Agent.PinLast(n) / UnpinLast(n) /
    DropLast(n) work in turns — a user message plus everything up to the next one, the grouping
    compressHistory already uses.
  • compressHistory keeps a pinned group verbatim, never feeds it to the summarizer, and returns the
    history unchanged (no provider call) when the whole middle is pinned.
  • TUI: /pin [n], /unpin [n], /drop [n], refused while a run is active; /drop shapes the live
    context only and says the session file still holds the record.

Acceptance as delivered: PinLast(2) marks exactly the last two turns, UnpinLast(1) clears the newest,
DropLast(2) removes exactly two; a pinned middle group is verbatim and absent from the summarizer's
input; a fully pinned middle costs no call; the commands are refused during a run; /drop leaves the
session store untouched.


S2 — compaction inside a run, and pins that survive a resume (landed)

Why. A continuation round is one Run. compressHistory runs only at its start
(agent/agent.go:447, and again at :941 for the manual path) and only on a.History. The loop's own
messages (agent/agent.go:441) is a local slice that grows with every step and every tool result, and
it is never compressed — so a long round grows until the provider refuses.

What to build.

  • Compact messages at a step boundary once EstimateMessagesTokens(messages) crosses
    CompressionThreshold: after a step's tool results are appended, before the next provider call. The cut
    must snap to a group boundary (groupEnd, agent/compression.go), or an assistant tool_calls
    message is separated from its tool results and the next call is rejected by the provider.
  • Account the summarizer call. It is an extra provider call inside a run, so it must go through
    recordCall like the loop's own calls, otherwise UsageTotal, the token budgets and the status bar
    under-report. Today the compression summarizer (agent/compression.go:195) is invisible to all three —
    a pre-existing gap this increment should not copy.
  • Pin persistence. persistSession (tui/update.go:977) rebuilds the stored messages from
    m.messages, and chatMessage (tui/messages.go:75) has no Pinned, so a pin dies at the first save.
    Carry it through: chatMessage.Pinned → persistSession → historyFromSession (tui/model.go:284).
  • File-level unload → S4. A file's contents are a tool result inside messages, so this is where
    "unload a file" would mean something — but /drop <path> cannot reach those messages: they live inside
    Run, and the TUI refuses commands while a run is active. The acceptance below no longer assumes a
    command channel that does not exist; the work is S4.

Acceptance (S2).

  • A run whose messages cross the threshold compacts at a step boundary, the run completes, and no
    provider request carries a tool_calls message without its tool results.
  • The summary call's tokens and cost appear in UsageTotal / CostTotal and in the status bar.
  • A pin set before a session is saved is still there after --resume.
  • go test ./... -count=1, -race, go vet, gofmt, staticcheck clean.

As delivered: the cut snaps forward to an assistant message (a tool_calls message never loses its
results), the head and the last eight messages are kept, the summarizer's call is counted through
recordCall, and a pin travels chatMessage.Pinned → persistSession → historyFromSession. The TUI
mirrors the agent's pins into the transcript by role and content, newest first, because the two lists are
not index-aligned.


S3 — a compression preview (landed)

What was built. The partition became a value: planCompression computes head, middle, pinned groups,
tail, the token estimate and the threshold; compressHistory executes that plan instead of re-deriving
it. /compress preview reads the same plan and prints each kept region (role plus first line, capped),
the middle's token count, and whether automatic compression would fire. No provider call; refused while a
run is active, because reading a slice the run appends to is a data race even when nothing is written.

Acceptance (S3).

  • The preview names each kept group (role plus the first line) and reports the middle's token count.
  • No provider call is made — assertable with a recording provider.
  • Pinned groups are listed as kept, and the preview and the real compression agree on the partition: the
    same input yields the same head/middle/tail/pinned split.

S4 — a path-targeted unload (landed)

Why it moved here. S2's acceptance assumed /drop <path> could remove the tool results that read a
file. It cannot: those results are in the loop's messages, which exist only inside Run, and the TUI
refuses commands while a run is active — deliberately, because they would mutate a slice the run is
appending to. A command is the wrong channel.

Two shapes worth trying, in order.

  1. A pre-run rule. An agent-level list of paths whose reads must survive compaction (set before the
    run, e.g. from config or a command while idle). compactRunMessages would keep any assistant
    tool_calls + tool-result group whose arguments name one of those paths, instead of folding it into
    the summary. No new channel, and it composes with the compaction that now exists.
  2. A tool the model calls. The model pins a read as it happens. Cheaper to reach mid-run, but it puts
    the decision in the model's hands, which is the opposite of the control B5 is about.

Acceptance (S4).

  • With a path pinned before the run, a compaction that crosses that read keeps the result verbatim in the
    messages, and the summary does not absorb it.
  • A read of a path that is not pinned is summarized away as it is today.
  • The rule survives --resume (it is part of the session or the config, and the acceptance says which).
  • go test ./... -count=1, -race, go vet, gofmt, staticcheck clean.

Out of scope

Dropping by message index; rewriting the session file. Both are deliberate: /drop shapes the live
context, and the record on disk stays what --resume reads.

Activity

  1. yusiwen commented on Oct 28, 2026

    @yusiwen
    OwnerAuthor

    S1 landed as #307, merged as 396e77f.

    • types.Message.Pinned; Agent.PinLast / UnpinLast / DropLast over turns;
    • compressHistory keeps a pinned group verbatim, never shows it to the summarizer, and returns the
      history unchanged (no provider call) when the whole middle is pinned;
    • /pin [n], /unpin [n], /drop [n], refused while a run is active; /drop shapes the live context
      and says the session file still holds the record.

    The mutation check found a hole in my own test before it found anything in the code: the first version
    pinned turn 3 of 6, which is inside the head — kept regardless — so it passed with pin handling
    disabled. The test now uses eight turns and pins a middle turn, and disabling groupPinned fails it with
    "the pinned turn was fed to the summarizer".

    Still open here: S2 (compaction of the run's own messages, where tool results and file contents
    live — the part D7 depends on — plus pin persistence through the UI transcript, since persistSession
    rebuilds the stored messages from m.messages) and S3 (a preview that calls no model). B5 is marked
    In progress in the roadmap until then.

  2. yusiwen commented on Oct 28, 2026

    @yusiwen
    OwnerAuthor

    S2 landed as #311, merged as 65d35d4.

    • Compaction of the run's own messages. compactRunMessages fires at a step boundary once the run
      crosses the threshold; head and last eight messages stay verbatim, the rest becomes one
      [COMPACTED RUN — REFERENCE ONLY] message. The cut snaps to an assistant message, so a tool_calls
      message never loses its results — asserted by walking the result and checking every tool message
      follows its call.
    • The summarizer's spend is counted. summarizeText is the one place compression calls a provider
      and it goes through recordCall; the pre-existing gap (summary tokens invisible to UsageTotal, the
      budgets and the status bar) is closed for /compress too.
    • Pins survive a resume. chatMessage.Pinned → persistSession → historyFromSession, with the TUI
      mirroring the agent's pins into the transcript by role and content, newest first.

    The integration test drives a real Run for ten tool steps and asserts a later provider request carries
    the compaction marker; disabling the loop hook fails it with "no provider request carried a compacted run
    across 12 calls".

    S4 was created by this increment. S2's acceptance asked for /drop <path> to remove the tool results
    that read a file. It cannot: those results live in the loop's messages inside Run, and the TUI refuses
    commands while a run is active — deliberately, since they would mutate a slice the run is appending to.
    The body is corrected and S4 records the two shapes worth trying (a pre-run rule the compactor honours, or
    a tool the model calls) with its own acceptance.

    Open here: S3 (a preview that calls no model) and S4.

  3. yusiwen commented on Oct 31, 2026

    @yusiwen
    OwnerAuthor

    S3 landed as #312, merged as 8d1ccb8.

    • compressionPlan + planCompression: the partition (head, middle, pinned, tail) with its token estimate
      and threshold, computed without a model call. compressHistory now executes the plan rather than
      re-deriving the boundaries, which is what lets the preview agree with the compression message for
      message — asserted by TestCompressionPreviewMatchesTheCompression.
    • /compress preview prints: whether automatic compression would fire, each kept region with its first
      lines and size, and the middle by token count. No provider call, and refused during a run (reading a
      slice being appended to is still a race).
    • Both load-bearing assertions were mutation-checked: a provider call added to the preview fails
      TestCompressionPreviewCallsNoModel; swapping pinned and tail in the compression fails
      TestCompressionPreviewMatchesTheCompression.

    Only S4 (path-targeted unload) is left in B5.

  4. yusiwen commented on Oct 31, 2026

    @yusiwen
    OwnerAuthor

    S4 landed as #314, merged as 7887f09 (with 08a078f
    for the palette scenario the new entries grew out of an 80x24 screen).

    What S4 built, given that the command channel it originally assumed does not exist:

    • Agent.KeptPaths + /keep <path>, /keep (list), /unkeep <path> — the rule is set before a run,
      which is the only place it can be set safely;
    • compactRunMessages keeps any round whose tool_calls arguments name a kept path verbatim (after the
      marker, before the tail) and summarizes the rest; a run whose every round is kept costs no summary call;
    • session.Session.KeptPaths carries the list through persistSession/adoptSession, so --resume
      keeps the rule.

    Both load-bearing paths were mutation-checked: disabling the kept-round check fails "the kept read was
    summarized away", and dropping the persist line fails "the session kept []".

    B5 is complete: docs/roadmap.md marks it Delivered — #306 (17 delivered, 1 in progress, 21 not
    started, 40 total). The one thing this increment deliberately does not build is a mid-run unload
    command — a run owns its messages and the TUI refuses commands while it runs, so a path rule is the
    channel that exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions