Files
Justin Truong d13cc0804a fix(providers): carry tool name on tool results for Gemini
Gemini drops tool_call_id and pairs functionCall/functionResponse by
function name, so its OpenAI-compat shim requires a non-empty
FunctionResponse.name. The name was only recoverable through a reverse
id->name lookup over assistant tool_calls still present in the request
window, which fails after pruning, truncation or tool_call collapse and
produces HTTP 400 "Name cannot be empty" on any follow-up iteration.

Add Message.ToolName, set it at every tool-result creation site and
preserve it through context pruning, then prefer it when serializing.
Fall back to the existing index for history persisted before the field,
so old sessions keep working without a reset.

When neither source resolves a name, drop the unlabelled tool result
instead of emitting an empty one: an empty name is a guaranteed 400 and
a synthetic name would match no prior functionCall. Gated behind the
existing Gemini detection so other OpenAI-compat hosts, which pair by
tool_call_id, are unaffected.

Field is JSON-optional, so session history needs no migration and older
binaries ignore it on rollback.
2026-08-03 22:50:06 +07:00

440 lines
13 KiB
Go

package agent
import (
"fmt"
"log/slog"
"unicode/utf8"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
)
// Context pruning defaults matching TS DEFAULT_CONTEXT_PRUNING_SETTINGS.
const (
defaultKeepLastAssistants = 3
defaultSoftTrimRatio = 0.3
defaultHardClearRatio = 0.5
defaultPruningMode = "cache-ttl"
defaultMinPrunableToolChars = 50000
defaultSoftTrimMaxChars = 6000
defaultSoftTrimHeadChars = 3000
defaultSoftTrimTailChars = 3000
defaultHardClearPlaceholder = "[Old tool result content cleared]"
defaultCacheTTL = "5m"
charsPerTokenEstimate = 4
// Media tool results contain irreplaceable vision/audio descriptions
// from dedicated providers (Gemini, Anthropic) — re-generating requires
// another LLM call. Give them a higher soft trim budget.
mediaSoftTrimHeadChars = 4000
mediaSoftTrimTailChars = 4000
)
// PruningDefaults mirrors the private pruning const block for external
// consumers (e.g. config.defaults RPC). Values are the SSoT the resolver uses
// when a user config leaves a field unset.
type PruningDefaults struct {
KeepLastAssistants int
SoftTrimRatio float64
HardClearRatio float64
MinPrunableToolChars int
SoftTrimMaxChars int
SoftTrimHeadChars int
SoftTrimTailChars int
HardClearEnabled bool
HardClearPlaceholder string
TTL string
}
// DefaultPruningValues returns the private pruning consts packaged for
// cross-package consumption. The resolver in resolvePruningSettings uses these
// same values.
func DefaultPruningValues() PruningDefaults {
return PruningDefaults{
KeepLastAssistants: defaultKeepLastAssistants,
SoftTrimRatio: defaultSoftTrimRatio,
HardClearRatio: defaultHardClearRatio,
MinPrunableToolChars: defaultMinPrunableToolChars,
SoftTrimMaxChars: defaultSoftTrimMaxChars,
SoftTrimHeadChars: defaultSoftTrimHeadChars,
SoftTrimTailChars: defaultSoftTrimTailChars,
HardClearEnabled: true,
HardClearPlaceholder: defaultHardClearPlaceholder,
TTL: defaultCacheTTL,
}
}
// pruningEstimator wraps either a tiktoken counter or the legacy char-based heuristic.
// When counter is nil, falls back to rune_count / charsPerTokenEstimate.
type pruningEstimator struct {
counter tokencount.TokenCounter
model string
}
// estimateTokens returns an integer unit comparable to tokenWindow.
// When counter is set: returns actual token count (tiktoken).
// When nil (fallback): returns rune count, to be compared against charWindow = tokens * charsPerTokenEstimate.
func (e *pruningEstimator) estimateTokens(content string) int {
if e.counter != nil {
return e.counter.Count(e.model, content)
}
return utf8.RuneCountInString(content)
}
// mediaToolNames identifies builtin tools whose results use a higher soft trim budget.
var mediaToolNames = map[string]bool{
"read_image": true,
"read_document": true,
"read_audio": true,
"read_video": true,
}
// effectivePruningSettings holds resolved pruning settings with defaults applied.
type effectivePruningSettings struct {
keepLastAssistants int
softTrimRatio float64
hardClearRatio float64
minPrunableToolChars int
softTrimMaxChars int
softTrimHeadChars int
softTrimTailChars int
hardClearEnabled bool
hardClearPlaceholder string
}
// resolvePruningSettings applies defaults to user config.
func resolvePruningSettings(cfg *config.ContextPruningConfig) *effectivePruningSettings {
s := &effectivePruningSettings{
keepLastAssistants: defaultKeepLastAssistants,
softTrimRatio: defaultSoftTrimRatio,
hardClearRatio: defaultHardClearRatio,
minPrunableToolChars: defaultMinPrunableToolChars,
softTrimMaxChars: defaultSoftTrimMaxChars,
softTrimHeadChars: defaultSoftTrimHeadChars,
softTrimTailChars: defaultSoftTrimTailChars,
hardClearEnabled: true,
hardClearPlaceholder: defaultHardClearPlaceholder,
}
if cfg == nil {
return s
}
if cfg.KeepLastAssistants > 0 {
s.keepLastAssistants = cfg.KeepLastAssistants
}
if cfg.SoftTrimRatio > 0 && cfg.SoftTrimRatio <= 1 {
s.softTrimRatio = cfg.SoftTrimRatio
}
if cfg.HardClearRatio > 0 && cfg.HardClearRatio <= 1 {
s.hardClearRatio = cfg.HardClearRatio
}
if cfg.MinPrunableToolChars > 0 {
s.minPrunableToolChars = cfg.MinPrunableToolChars
}
if cfg.SoftTrim != nil {
if cfg.SoftTrim.MaxChars > 0 {
s.softTrimMaxChars = cfg.SoftTrim.MaxChars
}
if cfg.SoftTrim.HeadChars > 0 {
s.softTrimHeadChars = cfg.SoftTrim.HeadChars
}
if cfg.SoftTrim.TailChars > 0 {
s.softTrimTailChars = cfg.SoftTrim.TailChars
}
}
if cfg.HardClear != nil {
if cfg.HardClear.Enabled != nil {
s.hardClearEnabled = *cfg.HardClear.Enabled
}
if cfg.HardClear.Placeholder != "" {
s.hardClearPlaceholder = cfg.HardClear.Placeholder
}
}
return s
}
// pruneContextMessages trims old tool results to reduce context window usage.
// Matching TS src/agents/pi-extensions/context-pruning/pruner.ts.
//
// Two-pass approach:
// 1. Soft trim: keep head + tail of long tool results, drop middle.
// 2. Hard clear: replace entire tool result with placeholder.
//
// Only tool results older than keepLastAssistants are eligible for pruning.
// Returns a new slice if any changes were made, otherwise the original.
//
// When tc is non-nil, token counting uses tiktoken for accuracy (especially
// for non-ASCII content like Vietnamese/Chinese). When nil, falls back to the
// legacy rune_count/charsPerTokenEstimate heuristic so existing tests pass.
func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg *config.ContextPruningConfig, tc tokencount.TokenCounter, model string, stats *pipeline.PruneStats) []providers.Message {
// Resolve effective mode: empty defaults to "cache-ttl" (enabled by default).
mode := defaultPruningMode
if cfg != nil && cfg.Mode != "" {
mode = cfg.Mode
}
if mode == "off" {
return msgs
}
if mode != "cache-ttl" {
slog.Warn("context_pruning: unknown mode, disabled", "mode", mode)
return msgs
}
if contextWindowTokens <= 0 || len(msgs) == 0 {
return msgs
}
est := &pruningEstimator{counter: tc, model: model}
settings := resolvePruningSettings(cfg)
// tokenWindow is contextWindowTokens when using tiktoken (est.counter != nil),
// or charWindow (tokens * 4) when using the char-based fallback.
tokenWindow := contextWindowTokens
if tc == nil {
tokenWindow = contextWindowTokens * charsPerTokenEstimate
}
// softTrimMaxTokens is the threshold for the estimator's unit.
// When tiktoken (tc != nil): convert chars → tokens (3000 chars / 4 ≈ 750 tokens).
// When char fallback (tc == nil): keep as rune count (estimateTokens returns rune count).
softTrimMaxTokens := settings.softTrimMaxChars
mediaSoftTrimMaxTokens := mediaSoftTrimHeadChars + mediaSoftTrimTailChars
if tc != nil {
softTrimMaxTokens = settings.softTrimMaxChars / charsPerTokenEstimate
mediaSoftTrimMaxTokens = mediaSoftTrimMaxTokens / charsPerTokenEstimate
}
// Find cutoff: protect last N assistant messages.
cutoffIndex := findAssistantCutoff(msgs, settings.keepLastAssistants)
if cutoffIndex < 0 {
return msgs
}
// Find first user message — never prune before it (protects bootstrap reads).
pruneStart := len(msgs)
for i, m := range msgs {
if m.Role == "user" {
pruneStart = i
break
}
}
// Estimate total tokens (or chars in fallback mode).
totalTokens := 0
for _, m := range msgs {
totalTokens += est.estimateTokens(m.Content)
}
ratio := float64(totalTokens) / float64(tokenWindow)
if ratio < settings.softTrimRatio {
return msgs // context is small enough
}
// Collect prunable tool result indexes.
var prunableIndexes []int
for i := pruneStart; i < cutoffIndex; i++ {
if msgs[i].Role == "tool" && msgs[i].Content != "" {
prunableIndexes = append(prunableIndexes, i)
}
}
if len(prunableIndexes) == 0 {
return msgs
}
// Build tool call name map for media tool detection in soft trim.
toolCallNames := buildToolCallNameMap(msgs)
// Pass 1: Soft trim long tool results.
var result []providers.Message
for i := range prunableIndexes {
idx := prunableIndexes[i]
msg := msgs[idx]
msgTokens := est.estimateTokens(msg.Content)
// Media tool results (read_image, etc.) get a higher trim budget because
// their content is an irreplaceable description from a dedicated vision provider.
isMedia := mediaToolNames[toolCallNames[msg.ToolCallID]]
trimThreshold := softTrimMaxTokens
if isMedia {
trimThreshold = mediaSoftTrimMaxTokens
}
if msgTokens <= trimThreshold {
continue
}
// Lazy copy
if result == nil {
result = make([]providers.Message, len(msgs))
copy(result, msgs)
}
// Tail-aware split: if tail has important content (errors, summaries),
// use dynamic 70/30 split. Otherwise use configured head/tail sizes.
// String slicing always uses chars (not tokens).
headChars := settings.softTrimHeadChars
tailChars := settings.softTrimTailChars
if isMedia {
headChars = mediaSoftTrimHeadChars
tailChars = mediaSoftTrimTailChars
}
if hasImportantTail(msg.Content) {
totalBudget := headChars + tailChars
tailChars = totalBudget * 7 / 10
headChars = totalBudget - tailChars
}
head := takeHead(msg.Content, headChars)
tail := takeTail(msg.Content, tailChars)
msgChars := utf8.RuneCountInString(msg.Content)
trimmed := fmt.Sprintf("%s\n...\n%s\n\n[Tool result trimmed: kept first %d chars and last %d chars of %d chars.]",
head, tail, headChars, tailChars, msgChars)
result[idx] = providers.Message{
Role: msg.Role,
Content: trimmed,
ToolCallID: msg.ToolCallID,
// Preserve ToolName: Gemini needs it to build FunctionResponse.name,
// and pruned results are exactly the ones whose assistant tool_call
// may already be out of the request window.
ToolName: msg.ToolName,
}
totalTokens += est.estimateTokens(trimmed) - msgTokens
if stats != nil {
stats.ResultsTrimmed++
}
}
output := msgs
if result != nil {
output = result
}
// Re-check ratio after soft trim.
ratio = float64(totalTokens) / float64(tokenWindow)
if ratio < settings.hardClearRatio || !settings.hardClearEnabled {
return output
}
// Check min prunable chars threshold (always char-based per config).
prunableChars := 0
for _, idx := range prunableIndexes {
prunableChars += utf8.RuneCountInString(output[idx].Content)
}
if prunableChars < settings.minPrunableToolChars {
return output
}
// Pass 2: Hard clear — replace entire tool results with placeholder.
if result == nil {
result = make([]providers.Message, len(msgs))
copy(result, msgs)
output = result
}
for _, idx := range prunableIndexes {
if ratio < settings.hardClearRatio {
break
}
msg := output[idx]
// Media tool results (read_image, etc.) are never hard-cleared because
// they contain irreplaceable vision/audio descriptions — re-generating
// requires another LLM call. Soft trim already caps them at 8K chars.
if mediaToolNames[toolCallNames[msg.ToolCallID]] {
continue
}
beforeTokens := est.estimateTokens(msg.Content)
output[idx] = providers.Message{
Role: msg.Role,
Content: settings.hardClearPlaceholder,
ToolCallID: msg.ToolCallID,
ToolName: msg.ToolName,
}
afterTokens := est.estimateTokens(settings.hardClearPlaceholder)
totalTokens += afterTokens - beforeTokens
ratio = float64(totalTokens) / float64(tokenWindow)
if stats != nil {
stats.ResultsCleared++
}
}
return output
}
// findAssistantCutoff returns the index of the Nth-from-last assistant message.
// Messages at or after this index are protected from pruning.
// Returns -1 if not enough assistant messages exist.
func findAssistantCutoff(msgs []providers.Message, keepLast int) int {
if keepLast <= 0 {
return len(msgs)
}
remaining := keepLast
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "assistant" {
remaining--
if remaining == 0 {
return i
}
}
}
return -1
}
// estimateMessageChars returns the character count of a message's content.
func estimateMessageChars(m providers.Message) int {
return utf8.RuneCountInString(m.Content)
}
// hasImportantTail checks if the last ~500 chars of content contain error/summary keywords.
func hasImportantTail(content string) bool {
runes := []rune(content)
checkLen := min(500, len(runes))
tail := string(runes[len(runes)-checkLen:])
return importantTailRe.MatchString(tail)
}
// takeHead returns the first n runes of s.
func takeHead(s string, n int) string {
if n <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n])
}
// takeTail returns the last n runes of s.
func takeTail(s string, n int) string {
if n <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[len(runes)-n:])
}
// buildToolCallNameMap creates a mapping from tool_call_id → tool name
// by scanning assistant messages for their ToolCalls.
func buildToolCallNameMap(msgs []providers.Message) map[string]string {
m := make(map[string]string)
for _, msg := range msgs {
for _, tc := range msg.ToolCalls {
m[tc.ID] = tc.Name
}
}
return m
}