tool results: tier classification (hot/warm/cold) with immediate cold summarization

This commit is contained in:
Levi Neely 2026-05-17 10:59:17 +02:00
parent da36db8f2b
commit 779b99fd9b
6 changed files with 99 additions and 4 deletions

View File

@ -34,6 +34,7 @@ type AgentEnv struct {
tools []backend.Tool
exec toolExecutor
classifyTool toolClassifier
classifyTier func(name string) ResultTier
Hooks Hooks
preamble string
genParams backend.GenerationParams
@ -138,6 +139,22 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string) AgentEnv
}
}
var tierFn func(string) ResultTier
if srv, ok := d.GetServer("execute"); ok {
if tc, ok := srv.(tools.TierClassifier); ok {
tierFn = func(name string) ResultTier {
switch tc.ResultTier(name) {
case "cold":
return TierCold
case "warm":
return TierWarm
default:
return TierHot
}
}
}
}
var backendName, modelName string
if cfg != nil {
backendName = cfg.Backend
@ -149,6 +166,7 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string) AgentEnv
tools: allTools,
exec: exec,
classifyTool: classify,
classifyTier: tierFn,
Hooks: hooks,
preamble: preamble,
genParams: genParams,
@ -352,6 +370,7 @@ func NewAgentCore(cfg AgentCoreConfig) Core {
Tools: cfg.Env.tools,
Exec: cfg.Env.exec,
ClassifyTool: cfg.Env.classifyTool,
ClassifyTier: cfg.Env.classifyTier,
ToolResultMaxBytes: defaultToolResultMaxBytes,
GenerationParams: cfg.Env.genParams,
MaxSteps: cfg.Env.maxSteps,

View File

@ -38,8 +38,9 @@ type agentConfig struct {
Backend backend.Backend
Tools []backend.Tool
Exec toolExecutor
ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe
ToolResultMaxBytes int // 0=unlimited; truncate tool results beyond this size
ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe
ClassifyTier func(name string) ResultTier // nil=TierHot for all; tools self-classify retention
ToolResultMaxBytes int // 0=unlimited; truncate tool results beyond this size
Output EventHandler
preamble string // compiled system+agent prompt sent as the system role
GenerationParams backend.GenerationParams
@ -332,7 +333,11 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
resultCache.Store(tc.Name+"\x00"+string(tc.Arguments), result)
}
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result})
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: isErr}, false
tier := TierHot
if !isErr && cfg.ClassifyTier != nil {
tier = cfg.ClassifyTier(tc.Name)
}
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: isErr, Tier: tier}, false
}
isParallelSafe := func(name string) bool {

View File

@ -216,14 +216,28 @@ func (s *Session) recordTurnCost(model string) {
func (s *Session) update(assistant backend.Message, results []toolResult) {
s.messages = append(s.messages, assistant)
for _, r := range results {
content := r.Content
if r.Tier == TierCold && !r.IsError {
// Immediately collapse cold results to a one-line summary.
content = summarizeToolResult(r.Name, content)
}
s.messages = append(s.messages, backend.Message{
Role: "tool",
Content: r.Content,
Content: content,
ToolCallID: r.ToolCallID,
})
}
}
// summarizeToolResult produces a compact one-line summary of a tool result
// for cold-tier results that don't need to stay verbatim.
func summarizeToolResult(name, content string) string {
if len(content) <= 200 {
return content
}
return fmt.Sprintf("[%s: %d bytes, first 200: %s…]", name, len(content), content[:200])
}
// removeCancelledToolResults filters out tool results that were cancelled due
// to interrupt, keeping completed work. Also removes the corresponding tool
// calls from assistant messages to maintain a valid message sequence.

View File

@ -2,12 +2,22 @@ package agent
import "ollie/pkg/backend"
// ResultTier classifies how long a tool result stays in the hot message list.
type ResultTier int
const (
TierHot ResultTier = iota // stays verbatim in messages (default)
TierWarm // summarized on next compaction pass
TierCold // immediately summarized at update time
)
// toolResult holds the output of a single tool call.
type toolResult struct {
ToolCallID string // may be empty (Ollama does not always set this)
Name string
Content string
IsError bool
Tier ResultTier // default TierHot; tools may self-classify
}
// state is the interface both ephemeral and bead-backed state must satisfy.

40
pkg/tools/execute/tier.go Normal file
View File

@ -0,0 +1,40 @@
package execute
import "strings"
// ResultTier implements tools.TierClassifier. It reads the tool script's
// "ollie:tier" annotation to determine result retention. Returns "hot" if
// no annotation is found.
func (e *Server) ResultTier(name string) string {
code, err := ReadTool(name)
if err != nil {
return "hot"
}
return detectTier(code)
}
// detectTier scans the first 10 lines of a tool script for an
// "ollie:tier" annotation. Matches "ollie:tier hot", "ollie:tier warm",
// or "ollie:tier cold" anywhere in the line.
func detectTier(code string) string {
lines := strings.SplitN(code, "\n", 11)
if len(lines) > 10 {
lines = lines[:10]
}
for _, line := range lines {
idx := strings.Index(line, "ollie:tier")
if idx < 0 {
continue
}
rest := strings.TrimSpace(line[idx+len("ollie:tier"):])
switch {
case rest == "cold" || strings.HasPrefix(rest, "cold "):
return "cold"
case rest == "warm" || strings.HasPrefix(rest, "warm "):
return "warm"
case rest == "hot" || strings.HasPrefix(rest, "hot "):
return "hot"
}
}
return "hot"
}

View File

@ -126,3 +126,10 @@ type ToolRestrictionSetter interface {
type ParallelClassifier interface {
IsParallelRead(name string) bool
}
// TierClassifier is implemented by tool servers that can report the retention
// tier of a tool's results: "hot" (verbatim), "warm" (summarized on compaction),
// or "cold" (immediately summarized). Returns "hot" for unknown tools.
type TierClassifier interface {
ResultTier(name string) string
}