mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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.
140 lines
4.8 KiB
Go
140 lines
4.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reasonix/internal/state/sessionstore"
|
|
"strings"
|
|
|
|
"reasonix/internal/contract/ablation"
|
|
"reasonix/internal/contract/provider"
|
|
"reasonix/internal/contract/tool"
|
|
)
|
|
|
|
// A fold's index gives an address; recall is what reads it. The budget is per
|
|
// generation so the fold that freed the window cannot be undone by the turns
|
|
// after it: a recall loop runs out of budget instead of running forever.
|
|
const recallBudgetRatio = 0.10
|
|
|
|
const minRecallBudgetTokens = 2000
|
|
|
|
// recallBudget is one generation's ceiling on pulling folded content back.
|
|
func (a *contextWindow) recallBudget() int {
|
|
window := a.effectiveContextWindow()
|
|
if window <= 0 {
|
|
return minRecallBudgetTokens
|
|
}
|
|
return max(minRecallBudgetTokens, int(float64(window)*recallBudgetRatio))
|
|
}
|
|
|
|
// RecallContext reads folded canonical positions, or searches for them. What
|
|
// comes back re-enters context as an ordinary tool result, never a projection.
|
|
// Both operations spend one generation budget: two would let a search refill
|
|
// what a read was refused.
|
|
func (a *Agent) RecallContext(ctx context.Context, req tool.RecallRequest) (tool.RecallResult, error) {
|
|
return a.window().recallContext(ctx, req)
|
|
}
|
|
|
|
func (a *contextWindow) recallContext(_ context.Context, req tool.RecallRequest) (tool.RecallResult, error) {
|
|
query := strings.TrimSpace(req.Query)
|
|
if query != "" && a.ablation.Off(ablation.RecallSearch) {
|
|
return tool.RecallResult{}, fmt.Errorf("recall: searching the folded region is not available in this configuration; read a #n address from the folded work index")
|
|
}
|
|
switch {
|
|
case query != "" && len(req.Positions) > 0:
|
|
return tool.RecallResult{}, fmt.Errorf("recall: give positions to read or a query to search, not both")
|
|
case query == "" && len(req.Positions) == 0:
|
|
return tool.RecallResult{}, fmt.Errorf("recall: no positions given")
|
|
}
|
|
canonical, _ := a.sess.conversation.SnapshotMessagesVersion()
|
|
|
|
a.sess.win.compactionMu.Lock()
|
|
defer a.sess.win.compactionMu.Unlock()
|
|
state := &a.sess.win.compactionState
|
|
covered := min(state.Projection.CoveredCount, len(canonical))
|
|
if covered <= 0 {
|
|
return tool.RecallResult{}, fmt.Errorf("recall: nothing has been folded in this session yet, so every position is still in your context")
|
|
}
|
|
if state.Recall.Generation != state.Generation {
|
|
state.Recall = sessionstore.RecallLedger{Generation: state.Generation}
|
|
}
|
|
budget := a.recallBudget()
|
|
left := budget - state.Recall.SpentTokens
|
|
if query != "" {
|
|
return a.searchRecallLocked(canonical[:covered], query, req, budget, left)
|
|
}
|
|
|
|
var body strings.Builder
|
|
var recalled, missing []int
|
|
for _, pos := range req.Positions {
|
|
if pos < 0 || pos >= len(canonical) {
|
|
missing = append(missing, pos)
|
|
continue
|
|
}
|
|
if pos >= covered {
|
|
return tool.RecallResult{}, fmt.Errorf("recall: #%d is not folded — it is still in your context, so read it there", pos)
|
|
}
|
|
rendered := renderTranscriptVerbatim(a.recallSpan(canonical, pos))
|
|
if strings.TrimSpace(rendered) == "" {
|
|
missing = append(missing, pos)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&body, "#%d\n%s\n", pos, strings.TrimRight(rendered, "\n"))
|
|
recalled = append(recalled, pos)
|
|
}
|
|
if len(recalled) == 0 {
|
|
return tool.RecallResult{BudgetLeft: left, Missing: missing},
|
|
fmt.Errorf("recall: %s named nothing the transcript still holds", positionsText(missing))
|
|
}
|
|
// Refused whole rather than truncated: a half-recalled span reads as the
|
|
// whole of what was there, which is the failure recall exists to prevent.
|
|
cost := a.textTokens(body.String())
|
|
if cost > left {
|
|
return tool.RecallResult{BudgetLeft: left},
|
|
fmt.Errorf("recall: %d tokens exceeds the %d left in this generation's recall budget — ask for fewer positions", cost, left)
|
|
}
|
|
state.Recall.SpentTokens += cost
|
|
return tool.RecallResult{
|
|
Text: strings.TrimRight(body.String(), "\n"),
|
|
Recalled: recalled,
|
|
Missing: missing,
|
|
Tokens: cost,
|
|
BudgetLeft: budget - state.Recall.SpentTokens,
|
|
}, nil
|
|
}
|
|
|
|
// recallSpan returns the message at pos plus the tool results that answer it.
|
|
// An index line addresses the call, and a call without its result is the half
|
|
// that says least.
|
|
func (a *contextWindow) recallSpan(canonical []provider.Message, pos int) []provider.Message {
|
|
span := []provider.Message{canonical[pos]}
|
|
wanted := map[string]bool{}
|
|
for _, tc := range canonical[pos].ToolCalls {
|
|
wanted[tc.ID] = true
|
|
}
|
|
for i := pos + 1; i < len(canonical) && len(wanted) > 0; i++ {
|
|
m := canonical[i]
|
|
if m.Role != provider.RoleTool {
|
|
break
|
|
}
|
|
if wanted[m.ToolCallID] {
|
|
span = append(span, m)
|
|
delete(wanted, m.ToolCallID)
|
|
}
|
|
}
|
|
return span
|
|
}
|
|
|
|
func positionsText(positions []int) string {
|
|
if len(positions) == 0 {
|
|
return "the request"
|
|
}
|
|
parts := make([]string, len(positions))
|
|
for i, p := range positions {
|
|
parts[i] = fmt.Sprintf("#%d", p)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
var _ tool.ContextRecaller = (*Agent)(nil)
|