443 lines
13 KiB
Go
443 lines
13 KiB
Go
/*
|
|
* Copyright 2026 CloudWeGo Authors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package summarization
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/bytedance/sonic"
|
|
|
|
"github.com/cloudwego/eino/adk"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
// DefaultFinalize is the default TypedFinalizeFunc implementation, providing the same
|
|
// summary post-processing as the middleware does internally:
|
|
// 1. Replaces the <all_user_messages>...</all_user_messages> section in the model-generated
|
|
// summary with recent original user messages from the conversation (up to ~30k tokens).
|
|
// 2. Adds a preamble and a postamble around the summary content.
|
|
// 3. Converts the summary into a user message, prepended with the original system messages.
|
|
func DefaultFinalize[M adk.MessageType](ctx context.Context, originalMessages []M, summary M) ([]M, error) {
|
|
systemMsgs, contextMsgs := splitSystemAndContextMsgs(originalMessages)
|
|
|
|
processed, err := postProcessSummary(ctx, &postProcessSummaryParams[M]{
|
|
contextMsgs: contextMsgs,
|
|
summaryContent: getAssistantTextContent(summary),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]M, 0, len(systemMsgs)+1)
|
|
result = append(result, systemMsgs...)
|
|
result = append(result, processed)
|
|
return result, nil
|
|
}
|
|
|
|
// TypedFinalizerBuilder builds a TypedFinalizeFunc by chaining handlers,
|
|
// generic over message type M.
|
|
type TypedFinalizerBuilder[M adk.MessageType] struct {
|
|
handlers []TypedFinalizeFunc[M]
|
|
errs []error
|
|
}
|
|
|
|
// FinalizerBuilder is a backward-compatible alias for TypedFinalizerBuilder
|
|
// specialized with *schema.Message.
|
|
type FinalizerBuilder = TypedFinalizerBuilder[*schema.Message]
|
|
|
|
// NewTypedFinalizer creates a new TypedFinalizerBuilder.
|
|
//
|
|
// Handlers run in registration order, and the summary message is post-processed
|
|
// as DefaultFinalize does after all handlers have run. For example, with
|
|
// PreserveSkills and a system message in originalMessages, the final output is:
|
|
//
|
|
// [system message, preserved skill message, processed summary]
|
|
//
|
|
// Example:
|
|
//
|
|
// finalizer, err := NewTypedFinalizer[*schema.Message]().
|
|
// PreserveSkills(&PreserveSkillsConfig{}).
|
|
// Build()
|
|
//
|
|
// cfg := &Config{
|
|
// Finalize: finalizer,
|
|
// // ...
|
|
// }
|
|
func NewTypedFinalizer[M adk.MessageType]() *TypedFinalizerBuilder[M] {
|
|
return &TypedFinalizerBuilder[M]{}
|
|
}
|
|
|
|
// NewFinalizer creates a new FinalizerBuilder that builds a FinalizeFunc
|
|
// by chaining handlers.
|
|
func NewFinalizer() *FinalizerBuilder {
|
|
return &FinalizerBuilder{}
|
|
}
|
|
|
|
// Build constructs the final TypedFinalizeFunc by chaining all registered handlers
|
|
// and post-processing the summary message as DefaultFinalize does.
|
|
// For example, with PreserveSkills and a system message in
|
|
// originalMessages, the final output is:
|
|
//
|
|
// [system message, preserved skill message, processed summary]
|
|
func (b *TypedFinalizerBuilder[M]) Build() (TypedFinalizeFunc[M], error) {
|
|
if len(b.errs) > 0 {
|
|
msgs := make([]string, len(b.errs))
|
|
for i, e := range b.errs {
|
|
msgs[i] = e.Error()
|
|
}
|
|
return nil, fmt.Errorf("failed to build finalizer:\n%s", strings.Join(msgs, "\n"))
|
|
}
|
|
|
|
if len(b.handlers) == 0 {
|
|
return nil, fmt.Errorf("at least one handler is required")
|
|
}
|
|
|
|
handlers := make([]TypedFinalizeFunc[M], len(b.handlers))
|
|
copy(handlers, b.handlers)
|
|
|
|
return func(ctx context.Context, originalMessages []M, summary M) ([]M, error) {
|
|
var extraMessages []M
|
|
for _, fn := range handlers {
|
|
result, err := fn(ctx, originalMessages, summary)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(result) == 0 {
|
|
return nil, fmt.Errorf("finalizer handler returned no messages")
|
|
}
|
|
extraMessages = append(extraMessages, result[:len(result)-1]...)
|
|
summary = result[len(result)-1]
|
|
}
|
|
|
|
systemMsgs, contextMsgs := splitSystemAndContextMsgs(originalMessages)
|
|
|
|
processed, err := postProcessSummary(ctx, &postProcessSummaryParams[M]{
|
|
contextMsgs: contextMsgs,
|
|
summaryContent: getAssistantTextContent(summary),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]M, 0, len(systemMsgs)+len(extraMessages)+1)
|
|
result = append(result, systemMsgs...)
|
|
result = append(result, extraMessages...)
|
|
result = append(result, processed)
|
|
return result, nil
|
|
}, nil
|
|
}
|
|
|
|
type PreserveSkillsConfig struct {
|
|
// SkillToolName is the tool name used for loading skills.
|
|
// Must match the tool name configured in the ADK skill middleware.
|
|
// Optional. Defaults to "skill".
|
|
SkillToolName string
|
|
|
|
// MaxSkills limits the maximum number of skills to preserve.
|
|
// = 0 means do not preserve any skills (disabled).
|
|
// > 0 means preserve up to this many most recent skills.
|
|
// Optional. Defaults to 5.
|
|
MaxSkills *int
|
|
|
|
// MaxTokensPerSkill limits the maximum token count for a single preserved skill.
|
|
// Skills exceeding this limit are truncated, with the truncated portion replaced
|
|
// by a short marker text (e.g. "[... skill content truncated ...]").
|
|
// Note: if this value is set smaller than the token count of the marker text itself,
|
|
// the skill will contain only the marker text with no original content preserved.
|
|
// Optional. Defaults to 5000.
|
|
MaxTokensPerSkill *int
|
|
|
|
// SkillsTokenBudget limits the total token count for all preserved skills combined.
|
|
// Skills are preserved from most recent to oldest; once the budget is exhausted,
|
|
// remaining skills are dropped.
|
|
// Optional. Defaults to 25000.
|
|
SkillsTokenBudget *int
|
|
}
|
|
|
|
// PreserveSkills preserves skill contents loaded by the ADK skill middleware.
|
|
// It scans the conversation for matching skill tool calls and returns the preserved
|
|
// skill content as a user message before the summary.
|
|
//
|
|
// Example:
|
|
//
|
|
// messages: [assistant(tool_call: skill "foo"), tool(content: "bar")]
|
|
// summary: S
|
|
//
|
|
// When skill content is found, PreserveSkills returns:
|
|
//
|
|
// []M{user("<preserved foo: bar>"), S}
|
|
func (b *TypedFinalizerBuilder[M]) PreserveSkills(config *PreserveSkillsConfig) *TypedFinalizerBuilder[M] {
|
|
if err := config.check(); err != nil {
|
|
b.errs = append(b.errs, fmt.Errorf("PreserveSkills: %w", err))
|
|
return b
|
|
}
|
|
b.handlers = append(b.handlers, func(ctx context.Context, originalMessages []M, summary M) ([]M, error) {
|
|
messages := originalMessages
|
|
|
|
modelInput, ok := ctx.Value(ctxKeyModelInput{}).([]M)
|
|
if ok && len(modelInput) > 0 {
|
|
messages = modelInput
|
|
}
|
|
|
|
if len(messages) == 0 {
|
|
return []M{summary}, nil
|
|
}
|
|
|
|
skillText, err := buildPreservedSkillsText(ctx, messages, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if skillText == "" {
|
|
return []M{summary}, nil
|
|
}
|
|
|
|
preserved := makeUserMsg[M](skillText)
|
|
setMsgExtra(preserved, extraKeyContentType, string(contentTypeSkills))
|
|
return []M{preserved, summary}, nil
|
|
})
|
|
return b
|
|
}
|
|
|
|
func (c *PreserveSkillsConfig) check() error {
|
|
if c == nil {
|
|
return fmt.Errorf("PreserveSkillsConfig is required")
|
|
}
|
|
if c.MaxSkills != nil && *c.MaxSkills < 0 {
|
|
return fmt.Errorf("MaxSkills must be non-negative")
|
|
}
|
|
if c.MaxTokensPerSkill != nil && *c.MaxTokensPerSkill < 0 {
|
|
return fmt.Errorf("MaxTokensPerSkill must be non-negative")
|
|
}
|
|
if c.SkillsTokenBudget != nil && *c.SkillsTokenBudget < 0 {
|
|
return fmt.Errorf("SkillsTokenBudget must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type skillInfo struct {
|
|
Name string
|
|
Content string
|
|
}
|
|
|
|
func extractSkillInfos[M adk.MessageType](messages []M, skillTool string) ([]*skillInfo, error) {
|
|
var skills []*skillInfo
|
|
argsMap := make(map[string]string)
|
|
|
|
for _, msg := range messages {
|
|
switch m := any(msg).(type) {
|
|
case *schema.Message:
|
|
if m.Role == schema.Assistant {
|
|
for _, tc := range m.ToolCalls {
|
|
if tc.Function.Name == skillTool {
|
|
argsMap[tc.ID] = tc.Function.Arguments
|
|
}
|
|
}
|
|
} else if m.Role == schema.Tool {
|
|
arguments, ok := argsMap[m.ToolCallID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
var arg struct {
|
|
Skill string `json:"skill"`
|
|
}
|
|
if err := sonic.UnmarshalString(arguments, &arg); err != nil {
|
|
return nil, fmt.Errorf("failed to parse skill arguments from tool call %s: %w", m.ToolCallID, err)
|
|
}
|
|
skills = append(skills, &skillInfo{
|
|
Name: arg.Skill,
|
|
Content: messageUserTextContent(m),
|
|
})
|
|
}
|
|
|
|
case *schema.AgenticMessage:
|
|
for _, block := range m.ContentBlocks {
|
|
if block == nil {
|
|
continue
|
|
}
|
|
if block.Type == schema.ContentBlockTypeFunctionToolCall && block.FunctionToolCall != nil {
|
|
if block.FunctionToolCall.Name == skillTool {
|
|
argsMap[block.FunctionToolCall.CallID] = block.FunctionToolCall.Arguments
|
|
}
|
|
}
|
|
if block.Type == schema.ContentBlockTypeFunctionToolResult && block.FunctionToolResult != nil {
|
|
arguments, ok := argsMap[block.FunctionToolResult.CallID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
var arg struct {
|
|
Skill string `json:"skill"`
|
|
}
|
|
if err := sonic.UnmarshalString(arguments, &arg); err != nil {
|
|
return nil, fmt.Errorf("failed to parse skill arguments from tool call %s: %w", block.FunctionToolResult.CallID, err)
|
|
}
|
|
var contentParts []string
|
|
for _, cb := range block.FunctionToolResult.Content {
|
|
if cb != nil && cb.Type == schema.FunctionToolResultContentBlockTypeText && cb.Text != nil {
|
|
contentParts = append(contentParts, cb.Text.Text)
|
|
}
|
|
}
|
|
skills = append(skills, &skillInfo{
|
|
Name: arg.Skill,
|
|
Content: strings.Join(contentParts, "\n"),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return skills, nil
|
|
}
|
|
|
|
func buildPreservedSkillsText[M adk.MessageType](_ context.Context, messages []M, config *PreserveSkillsConfig) (string, error) {
|
|
const (
|
|
defaultSkillTool = "skill"
|
|
defaultMaxTokensPerSkill = 5000
|
|
defaultSkillsTokenBudget = 25000
|
|
)
|
|
|
|
if config == nil {
|
|
config = &PreserveSkillsConfig{}
|
|
}
|
|
|
|
maxSkills := 5
|
|
if config.MaxSkills != nil {
|
|
maxSkills = *config.MaxSkills
|
|
}
|
|
if maxSkills <= 0 {
|
|
return "", nil
|
|
}
|
|
|
|
skillTool := defaultSkillTool
|
|
if config.SkillToolName != "" {
|
|
skillTool = config.SkillToolName
|
|
}
|
|
|
|
maxTokensPerSkill := defaultMaxTokensPerSkill
|
|
if config.MaxTokensPerSkill != nil {
|
|
maxTokensPerSkill = *config.MaxTokensPerSkill
|
|
}
|
|
|
|
skillsTokenBudget := defaultSkillsTokenBudget
|
|
if config.SkillsTokenBudget != nil {
|
|
skillsTokenBudget = *config.SkillsTokenBudget
|
|
}
|
|
|
|
skills, err := extractSkillInfos(messages, skillTool)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(skills) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
uniqueSkills := make([]*skillInfo, 0, len(skills))
|
|
seenNames := make(map[string]bool)
|
|
for i := len(skills) - 1; i >= 0; i-- {
|
|
skill := skills[i]
|
|
if !seenNames[skill.Name] {
|
|
seenNames[skill.Name] = true
|
|
uniqueSkills = append(uniqueSkills, skill)
|
|
}
|
|
}
|
|
|
|
for i, j := 0, len(uniqueSkills)-1; i < j; i, j = i+1, j-1 {
|
|
uniqueSkills[i], uniqueSkills[j] = uniqueSkills[j], uniqueSkills[i]
|
|
}
|
|
skills = uniqueSkills
|
|
|
|
if len(skills) > maxSkills {
|
|
skills = skills[len(skills)-maxSkills:]
|
|
}
|
|
|
|
totalTokens := 0
|
|
var budgetedSkills []*skillInfo
|
|
for i := len(skills) - 1; i >= 0; i-- {
|
|
skill := skills[i]
|
|
tokens := estimateTokenCount(len(skill.Content))
|
|
|
|
if tokens > maxTokensPerSkill {
|
|
skill = &skillInfo{
|
|
Name: skill.Name,
|
|
Content: truncateSkillContent(skill.Content, maxTokensPerSkill),
|
|
}
|
|
tokens = maxTokensPerSkill
|
|
}
|
|
|
|
if totalTokens+tokens > skillsTokenBudget {
|
|
break
|
|
}
|
|
|
|
totalTokens += tokens
|
|
budgetedSkills = append(budgetedSkills, skill)
|
|
}
|
|
|
|
if len(budgetedSkills) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
// Reverse to restore chronological order.
|
|
for i, j := 0, len(budgetedSkills)-1; i < j; i, j = i+1, j-1 {
|
|
budgetedSkills[i], budgetedSkills[j] = budgetedSkills[j], budgetedSkills[i]
|
|
}
|
|
|
|
var parts []string
|
|
for _, skill := range budgetedSkills {
|
|
parts = append(parts, fmt.Sprintf(skillSectionFormat, skill.Name, skill.Content))
|
|
}
|
|
|
|
skillsText := strings.Join(parts, "\n\n---\n\n")
|
|
skillsText = fmt.Sprintf(getSkillPreamble(), skillsText)
|
|
skillsText = fmt.Sprintf("<system-reminder>\n%s"+"\n</system-reminder>", skillsText)
|
|
|
|
return skillsText, nil
|
|
}
|
|
|
|
// truncateSkillContent truncates skill content to fit within maxTokens.
|
|
// It keeps the first portion of the content and appends a truncation marker
|
|
// (e.g. "[... skill content truncated ...]") to indicate the omission.
|
|
// If maxTokens is smaller than the marker itself, only the marker is returned.
|
|
func truncateSkillContent(content string, maxTokens int) string {
|
|
if len(content) == 0 {
|
|
return content
|
|
}
|
|
|
|
if estimateTokenCount(len(content)) <= maxTokens {
|
|
return content
|
|
}
|
|
|
|
marker := getSkillTruncationMarker()
|
|
targetBytes := estimateTokenBytes(maxTokens) - len(marker)
|
|
if targetBytes < 0 {
|
|
targetBytes = 0
|
|
}
|
|
if targetBytes > len(content) {
|
|
targetBytes = len(content)
|
|
}
|
|
|
|
// Back up to a valid UTF-8 rune boundary.
|
|
for targetBytes > 0 && targetBytes < len(content) && !utf8.RuneStart(content[targetBytes]) {
|
|
targetBytes--
|
|
}
|
|
|
|
return content[:targetBytes] + marker
|
|
}
|