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

76 lines
3.0 KiB
Go

package agent
import (
"context"
"encoding/json"
"reasonix/internal/state/sessionstore"
"slices"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
)
type estimationProbe struct{ name string }
func (p estimationProbe) Name() string { return p.name }
func (p estimationProbe) Description() string { return p.name + " occupies schema tokens" }
func (p estimationProbe) ReadOnly() bool { return true }
func (p estimationProbe) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (p estimationProbe) Execute(context.Context, json.RawMessage) (string, error) {
return "", nil
}
type gatedProbe struct{ estimationProbe }
func (gatedProbe) ProviderVisible(context.Context) bool { return false }
func (gatedProbe) Unavailable(context.Context) tool.Refusal {
return tool.Refusal{Code: "probe.closed", Message: "closed"}
}
// A token estimate is compared against a window the provider enforces, so it
// has to measure the request that went out. The surface narrowed to what a turn
// admits while the estimate still read the whole registry, which overstates by
// every contextual tool the turn left behind and folds earlier than it needs to.
func TestEstimateMeasuresTheSurfaceTheRequestCarried(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(estimationProbe{name: "always_here"})
reg.Add(gatedProbe{estimationProbe{name: "never_admitted"}})
reg.SetProviderVisibleTools([]string{"always_here", "never_admitted"})
prov := &scriptedProvider{name: "estimation", turns: [][]provider.Chunk{
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, sessionstore.NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "go"); err != nil {
t.Fatalf("Run: %v", err)
}
if len(prov.requests) == 0 {
t.Fatal("provider received no request")
}
sent := toolSchemaNames(prov.requests[0].Tools)
if slices.Contains(sent, "never_admitted") {
t.Fatalf("the gated tool reached the request, so this arm measures nothing: %v", sent)
}
estimated := toolSchemaNames(a.window().estimationSurface())
if !slices.Equal(estimated, sent) {
t.Fatalf("the estimate measures a surface no request carried\nestimated %v\nsent %v", estimated, sent)
}
}
// Before a request has gone out there is nothing to have carried, and an
// estimate still has to answer. The whole visible set is the answer that errs
// toward folding early rather than overrunning the window.
func TestEstimateFallsBackToTheWholeSurfaceBeforeAnyRequest(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(estimationProbe{name: "always_here"})
reg.SetProviderVisibleTools([]string{"always_here"})
a := New(&scriptedProvider{name: "estimation-cold"}, reg, sessionstore.NewSession("sys"), Options{}, event.Discard)
if got := toolSchemaNames(a.window().estimationSurface()); !slices.Equal(got, []string{"always_here"}) {
t.Fatalf("cold estimate = %v, want the whole visible surface", got)
}
}