Files
ragflow/internal/harness/core/middlewares/reduction/reduction.go
ZF 97a4c64cc8 fix(harness): truncate text on rune boundary to keep UTF-8 valid (#16511)
### 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.
2026-07-01 16:45:26 +08:00

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])
}