/* * 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 ... 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(""), 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("\n%s"+"\n", 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 }