mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-02 22:07:31 +08:00
### Summary `truncateText` in the `reduction` and `summarization` middlewares truncates with `s[:maxLen]`, which slices by byte. When `maxLen` lands inside a multi-byte character (common with CJK or other non-ASCII content flowing through the agent), the string is cut mid-rune and the tail byte(s) become invalid UTF-8. That broken text then goes into the reduced context / summary prompt. `TruncateToolResult` in the same `reduction` package already avoids this by slicing on a rune boundary and even notes it in a comment. This PR makes the two `truncateText` helpers do the same, so they stay consistent with the existing helper. Both functions keep their existing output shape (summarization still appends `...`). Added a small unit test in each package covering ASCII truncation and a CJK string, asserting the result stays valid UTF-8.
176 lines
4.5 KiB
Go
176 lines
4.5 KiB
Go
// Package reduction provides tool output reduction middleware.
|
|
// Two-phase design: Truncation (immediate) -> Clear (before model rewrite).
|
|
package reduction
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"ragflow/internal/harness/core"
|
|
"ragflow/internal/harness/core/schema"
|
|
)
|
|
|
|
// Backend persists overflow content.
|
|
type Backend interface {
|
|
Store(key string, content string) error
|
|
Load(key string) (string, error)
|
|
}
|
|
|
|
// ToolConfig provides per-tool reduction configuration.
|
|
type ToolConfig struct {
|
|
SkipTruncation bool
|
|
SkipClear bool
|
|
}
|
|
|
|
// TypedConfig configures the reduction middleware.
|
|
type TypedConfig[M core.MessageType] struct {
|
|
Backend Backend
|
|
MaxToolOutputLen int // Truncate tool output beyond this (0 = no truncation)
|
|
MaxToolCalls int // Clear tool calls beyond this (0 = no clear)
|
|
MaxTokensForClear int // Trigger clear when total tokens exceed this
|
|
ClearAtLeast int // Ensure at least this many tokens are freed per clear
|
|
ToolConfigs map[string]*ToolConfig
|
|
ExcludeTools map[string]bool
|
|
}
|
|
|
|
type Config = TypedConfig[*schema.Message]
|
|
|
|
type middleware[M core.MessageType] struct {
|
|
core.BaseMiddleware[M]
|
|
cfg *TypedConfig[M]
|
|
mu sync.Mutex
|
|
keyCounter int
|
|
}
|
|
|
|
func NewTyped[M core.MessageType](cfg *TypedConfig[M]) core.TypedReActMiddleware[M] {
|
|
if cfg == nil {
|
|
cfg = &TypedConfig[M]{}
|
|
}
|
|
if cfg.MaxToolOutputLen <= 0 {
|
|
cfg.MaxToolOutputLen = 2000
|
|
}
|
|
if cfg.MaxToolCalls <= 0 {
|
|
cfg.MaxToolCalls = 20
|
|
}
|
|
if cfg.MaxTokensForClear <= 0 {
|
|
cfg.MaxTokensForClear = 100000
|
|
}
|
|
return &middleware[M]{cfg: cfg}
|
|
}
|
|
|
|
func New(cfg *Config) core.TypedReActMiddleware[*schema.Message] {
|
|
return NewTyped[*schema.Message](cfg)
|
|
}
|
|
|
|
// ---- Clear Phase (BeforeModelRewrite) ----
|
|
|
|
func (mw *middleware[M]) BeforeModelRewrite(ctx context.Context, state *core.TypedReActAgentState[M], mc *core.TypedModelContext[M]) (context.Context, *core.TypedReActAgentState[M], error) {
|
|
// Phase 1: Truncate oversized outputs
|
|
mw.truncateToolOutputs(state)
|
|
|
|
// Phase 2: Clear old tool calls if total tokens exceed threshold
|
|
if mw.cfg.MaxTokensForClear > 0 {
|
|
totalTokens := mw.estimateTokens(state.Messages)
|
|
if totalTokens > mw.cfg.MaxTokensForClear {
|
|
mw.clearOldToolCalls(state, totalTokens)
|
|
}
|
|
}
|
|
|
|
return ctx, state, nil
|
|
}
|
|
|
|
func (mw *middleware[M]) truncateToolOutputs(state *core.TypedReActAgentState[M]) {
|
|
toolCount := 0
|
|
for i, msg := range state.Messages {
|
|
m, ok := any(msg).(*schema.Message)
|
|
if !ok || m == nil || m.Role != schema.RoleTool {
|
|
continue
|
|
}
|
|
toolCount++
|
|
if mw.cfg.MaxToolCalls > 0 && toolCount > mw.cfg.MaxToolCalls {
|
|
m.Content = "..."
|
|
m.Extra = nil
|
|
state.Messages[i] = any(m).(M)
|
|
continue
|
|
}
|
|
if mw.cfg.MaxToolOutputLen > 0 && len(m.Content) > mw.cfg.MaxToolOutputLen {
|
|
if !mw.isExcluded(m.ToolName) {
|
|
m.Content = m.Content[:mw.cfg.MaxToolOutputLen] + "\n...(truncated)"
|
|
state.Messages[i] = any(m).(M)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (mw *middleware[M]) clearOldToolCalls(state *core.TypedReActAgentState[M], totalTokens int) {
|
|
if mw.cfg.ClearAtLeast <= 0 {
|
|
return
|
|
}
|
|
targetTokens := mw.cfg.MaxTokensForClear - mw.cfg.ClearAtLeast
|
|
if totalTokens <= targetTokens {
|
|
return
|
|
}
|
|
|
|
freed := 0
|
|
toolCount := 0
|
|
for i, msg := range state.Messages {
|
|
m, ok := any(msg).(*schema.Message)
|
|
if !ok || m == nil || m.Role != schema.RoleTool {
|
|
continue
|
|
}
|
|
toolCount++
|
|
if mw.cfg.MaxToolCalls > 0 && toolCount > mw.cfg.MaxToolCalls {
|
|
before := len([]rune(m.Content))
|
|
m.Content = "..."
|
|
freed += before - 3
|
|
state.Messages[i] = any(m).(M)
|
|
if totalTokens-freed <= targetTokens {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (mw *middleware[M]) estimateTokens(msgs []M) int {
|
|
total := 0
|
|
for _, msg := range msgs {
|
|
switch v := any(msg).(type) {
|
|
case *schema.Message:
|
|
total += len([]rune(v.Content)) * 4 / 3
|
|
for _, tc := range v.ToolCalls {
|
|
total += len([]rune(tc.Function.Arguments)) * 4 / 3
|
|
}
|
|
case *schema.AgenticMessage:
|
|
total += len([]rune(v.Content)) * 4 / 3
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
func (mw *middleware[M]) isExcluded(name string) bool {
|
|
if mw.cfg.ExcludeTools == nil {
|
|
return false
|
|
}
|
|
return mw.cfg.ExcludeTools[name]
|
|
}
|
|
|
|
func (mw *middleware[M]) nextKey() int {
|
|
mw.mu.Lock()
|
|
defer mw.mu.Unlock()
|
|
mw.keyCounter++
|
|
return mw.keyCounter
|
|
}
|
|
|
|
func truncateText(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
// Slice on a rune boundary; [:maxLen] indexes bytes and can split a
|
|
// multi-byte UTF-8 character, same as TruncateToolResult guards against.
|
|
runes := []rune(s)
|
|
if maxLen > len(runes) {
|
|
return s
|
|
}
|
|
return string(runes[:maxLen])
|
|
}
|