Files
yhh 9ca5da9aa5 refactor(agent): the context window owns its state and says what it needs
Projection, compaction and token estimation were 121 methods on *Agent,
free to touch any of its state. They now form one subsystem inside the
agent package:

- windowState holds what the window owns for a conversation: the
  installed projection, the compaction in flight, the pressure latches.
  It used to sit as eight loose fields on sessionRuntime.
  sessionRuntime.reset hands a new conversation to windowState.reset, and
  the lifetime guard in sessionstate_test.go now holds both structs to
  their reset and carry-over lists.
- The window's methods live on *contextWindow, which carries only its
  state, the session, the configuration, the services and a windowHost.
- windowHost is everything the window asks of the loop: eight callbacks
  (the todo and host-context tails, tool facts, the provider projection,
  preparing a sampling request, the extension compaction intercepts,
  failure annotation) and Session. It was derived from the compiler, not
  written by hand, so it is the whole dependency.
- The loop reaches the window through a.window(). The agent's public
  context API (ContextBudget, CacheState, LoadProjectionSidecar and the
  rest) is unchanged and forwards to it.

The new repolint rule window-state holds the boundary. Only methods on
the window's own types may read sess.win, and the owners are declared in
tools/repolint/windowstate.go. REASONIX.md names the boundary.

Behaviour is unchanged. No test was added or removed. Tests reach the
window's internals through a.window() like the loop does.
2026-09-23 23:38:19 -07:00

138 lines
5.9 KiB
Go

package agent
import (
"context"
"encoding/json"
"reasonix/internal/state/sessionstore"
"strings"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
)
// recallAgent holds a transcript whose first `covered` messages have been
// folded away, which is the only state where an index address means anything.
func recallAgent(t *testing.T, covered int, window int) *Agent {
t.Helper()
sess := sessionstore.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "never rewrite history, only append"})
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "c1", Name: "read_file", Arguments: `{"path":"internal/runtime/agent/compact.go"}`},
}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "func estimateTextTokens(s string) int {"})
sess.Add(provider.Message{Role: provider.RoleUser, Content: "keep going"})
a := New(nil, nil, sess, Options{ContextWindow: window}, event.Discard)
a.sess.win.compactionState.Generation = 1
a.sess.win.compactionState.Projection = sessionstore.ContextProjection{CoveredCount: covered}
return a
}
// An index line is an address; this is the read. What the user said is the
// thing recall exists for — no tool can re-derive it.
func TestRecallReturnsFoldedPositions(t *testing.T) {
a := recallAgent(t, 3, 128_000)
res, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{1}})
if err != nil {
t.Fatalf("RecallContext: %v", err)
}
if !strings.Contains(res.Text, "never rewrite history") {
t.Fatalf("recall lost the user turn it addressed:\n%s", res.Text)
}
if res.Tokens <= 0 || res.BudgetLeft >= a.window().recallBudget() {
t.Fatalf("recall did not draw on the budget: tokens=%d left=%d of %d", res.Tokens, res.BudgetLeft, a.window().recallBudget())
}
}
// The index addresses the call, so the call alone would be the half that says
// least: what read_file returned is the reason to recall it at all.
func TestRecallCarriesToolResultsWithTheirCall(t *testing.T) {
a := recallAgent(t, 4, 128_000)
res, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{2}})
if err != nil {
t.Fatalf("RecallContext: %v", err)
}
if !strings.Contains(res.Text, "estimateTextTokens") {
t.Fatalf("recalled a tool call without its result:\n%s", res.Text)
}
}
// A position still in the model's context is not folded, and recalling it
// would pay twice for one fact.
func TestRecallRefusesWhatIsStillVisible(t *testing.T) {
a := recallAgent(t, 2, 128_000)
_, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{3}})
if err == nil || !strings.Contains(err.Error(), "still in your context") {
t.Fatalf("err = %v, want a refusal naming the visible position", err)
}
}
// The budget is what stops a recall loop from undoing the fold that freed the
// window. It refuses whole: half a span reads as the whole of what was there.
func TestRecallBudgetRefusesRatherThanTruncates(t *testing.T) {
a := recallAgent(t, 3, 128_000)
a.sess.win.compactionState.Recall = sessionstore.RecallLedger{Generation: 1, SpentTokens: a.window().recallBudget() - 1}
_, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{1}})
if err == nil || !strings.Contains(err.Error(), "recall budget") {
t.Fatalf("err = %v, want the budget to refuse the request", err)
}
}
// Each compaction grants a fresh budget, so a long session keeps the ability to
// look back. The ledger carries its generation, so nothing has to reset it.
func TestRecallBudgetResetsWithTheGeneration(t *testing.T) {
a := recallAgent(t, 3, 128_000)
a.sess.win.compactionState.Recall = sessionstore.RecallLedger{Generation: 1, SpentTokens: a.window().recallBudget()}
a.sess.win.compactionState.Generation = 2
res, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{1}})
if err != nil {
t.Fatalf("a new generation did not refresh the recall budget: %v", err)
}
if res.BudgetLeft <= 0 {
t.Fatalf("budget left = %d after the first recall of a new generation", res.BudgetLeft)
}
}
// The tool reaches the agent through the call context, so that one binding is
// the whole wiring: without it recall is a tool that can only report itself
// unavailable, and every test above would still pass.
func TestToolCallContextCarriesTheRecaller(t *testing.T) {
prov := &mockProvider{name: "p", chunks: []provider.Chunk{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "c1", Name: "probe", Arguments: `{}`}},
{Type: provider.ChunkDone},
}}
probe := &recallerProbe{}
reg := tool.NewRegistry()
reg.Add(probe)
a := New(prov, reg, sessionstore.NewSession(""), Options{MaxSteps: 1}, event.Discard)
_ = a.Run(context.Background(), "go") // errors at the 1-step cap; the probe is the point
if !probe.bound {
t.Fatal("a tool call ran with no ContextRecaller bound to its context")
}
}
type recallerProbe struct{ bound bool }
func (*recallerProbe) Name() string { return "probe" }
func (*recallerProbe) Description() string { return "probe" }
func (*recallerProbe) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (*recallerProbe) ReadOnly() bool { return true }
func (p *recallerProbe) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
_, p.bound = tool.ContextRecallerFromContext(ctx)
return "ok", nil
}
// Nothing folded means every address is still in context; saying so beats
// returning content the caller already has.
func TestRecallBeforeAnyFoldIsRefused(t *testing.T) {
a := recallAgent(t, 0, 128_000)
_, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{1}})
if err == nil || !strings.Contains(err.Error(), "nothing has been folded") {
t.Fatalf("err = %v, want a refusal explaining that nothing is folded", err)
}
}