1290 lines
37 KiB
Go
1290 lines
37 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 provides a middleware that automatically summarizes
|
|
// conversation history when token count exceeds the configured threshold.
|
|
package summarization
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/bytedance/sonic"
|
|
|
|
"github.com/cloudwego/eino/adk"
|
|
"github.com/cloudwego/eino/components/model"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
func init() {
|
|
schema.RegisterName[*TypedCustomizedAction[*schema.Message]]("_eino_adk_summarization_mw_customized_action")
|
|
schema.RegisterName[*TypedCustomizedAction[*schema.AgenticMessage]]("_eino_adk_summarization_mw_customized_action_agentic")
|
|
}
|
|
|
|
type TypedTokenCounterFunc[M adk.MessageType] func(ctx context.Context, input *TypedTokenCounterInput[M]) (int, error)
|
|
type TypedGenModelInputFunc[M adk.MessageType] func(ctx context.Context, sysInstruction, userInstruction M, originalMsgs []M) ([]M, error)
|
|
type TypedGetFailoverModelFunc[M adk.MessageType] func(ctx context.Context, failoverCtx *TypedFailoverContext[M]) (failoverModel model.BaseModel[M], failoverModelInputMsgs []M, failoverErr error)
|
|
type TypedFinalizeFunc[M adk.MessageType] func(ctx context.Context, originalMessages []M, summary M) ([]M, error)
|
|
type TypedCallbackFunc[M adk.MessageType] func(ctx context.Context, before, after adk.TypedChatModelAgentState[M]) error
|
|
|
|
type TokenCounterFunc = TypedTokenCounterFunc[*schema.Message]
|
|
type GenModelInputFunc = TypedGenModelInputFunc[*schema.Message]
|
|
type GetFailoverModelFunc = TypedGetFailoverModelFunc[*schema.Message]
|
|
type FinalizeFunc = TypedFinalizeFunc[*schema.Message]
|
|
type CallbackFunc = TypedCallbackFunc[*schema.Message]
|
|
|
|
// TypedConfig defines the configuration for the summarization middleware,
|
|
// generic over message type M.
|
|
type TypedConfig[M adk.MessageType] struct {
|
|
// Model is the chat model used to generate summaries.
|
|
Model model.BaseModel[M]
|
|
|
|
// ModelOptions specifies options passed to the model when generating summaries.
|
|
// Optional.
|
|
ModelOptions []model.Option
|
|
|
|
// TokenCounter calculates the token count for given messages and tools.
|
|
//
|
|
// Parameters:
|
|
// - input: contains the messages and tools to count tokens for.
|
|
//
|
|
// Returns:
|
|
// - int: the total token count.
|
|
//
|
|
// Optional. Defaults to using the total tokens reported in the last assistant
|
|
// message as baseline, with incremental messages estimated at ~4 chars/token.
|
|
TokenCounter TypedTokenCounterFunc[M]
|
|
|
|
// Trigger specifies the conditions that activate summarization.
|
|
// Optional. Defaults to triggering when total tokens exceed 160k.
|
|
Trigger *TriggerCondition
|
|
|
|
// EmitInternalEvents indicates whether internal events should be emitted during summarization,
|
|
// allowing external observers to track the summarization process.
|
|
//
|
|
// Event Scoping:
|
|
// - ActionTypeBeforeSummarize: emitted before calling model to generate summary
|
|
// - ActionTypeGenerateSummary: emitted after each model generate attempt
|
|
// - ActionTypeAfterSummarize: emitted after summary generation completes
|
|
//
|
|
// Optional. Defaults to false.
|
|
EmitInternalEvents bool
|
|
|
|
// UserInstruction serves as the user-level instruction to guide the model on how to summarize the context.
|
|
// It is appended to the message history as a User message.
|
|
// If provided, it overrides the default user summarization instruction.
|
|
// Optional.
|
|
UserInstruction string
|
|
|
|
// TranscriptFilePath is the path to the file containing the full conversation history.
|
|
// It is appended to the summary to remind the model where to read the original context.
|
|
// This field takes effect only when Finalize is not set.
|
|
// Optional but strongly recommended.
|
|
TranscriptFilePath string
|
|
|
|
// GenModelInput allows full control over the summarization model input construction.
|
|
//
|
|
// Parameters:
|
|
// - sysInstruction: System message defining the model's role. It is set
|
|
// internally by the middleware and is not configurable.
|
|
// - userInstruction: User message with the task instruction.
|
|
// - originalMsgs: original complete message list.
|
|
//
|
|
// Returns:
|
|
// - []M: the constructed model input messages.
|
|
//
|
|
// Typical model input order: systemInstruction -> contextMessages -> userInstruction.
|
|
//
|
|
// Optional.
|
|
GenModelInput TypedGenModelInputFunc[M]
|
|
|
|
// Finalize is called after summary generation.
|
|
// The returned messages are used as the final conversation history.
|
|
// When set, the middleware does not perform any post-processing on the summary.
|
|
// Use DefaultFinalize to apply the same post-processing as the default path.
|
|
//
|
|
// Parameters:
|
|
// - originalMessages: the original conversation messages before summarization.
|
|
// - summary: the model-generated summary message.
|
|
//
|
|
// Returns:
|
|
// - []M: the new conversation history to replace the original messages.
|
|
//
|
|
// Optional.
|
|
Finalize TypedFinalizeFunc[M]
|
|
|
|
// Callback is called after Finalize, before exiting the middleware.
|
|
// Read-only, do not modify state.
|
|
//
|
|
// Parameters:
|
|
// - before: the agent state before summarization.
|
|
// - after: the agent state after summarization.
|
|
//
|
|
// Optional.
|
|
Callback TypedCallbackFunc[M]
|
|
|
|
// Retry configures retry behavior for summary generation on the primary model.
|
|
// Optional. Defaults to no retries.
|
|
Retry *TypedRetryConfig[M]
|
|
|
|
// Failover configures fallback behavior when summary generation on the primary model fails.
|
|
// Optional.
|
|
Failover *TypedFailoverConfig[M]
|
|
}
|
|
|
|
// Config is a backward-compatible alias for TypedConfig specialized with *schema.Message.
|
|
type Config = TypedConfig[*schema.Message]
|
|
|
|
// TypedTokenCounterInput is the input for TypedTokenCounterFunc.
|
|
type TypedTokenCounterInput[M adk.MessageType] struct {
|
|
// Messages is the list of messages to count tokens for.
|
|
Messages []M
|
|
// Tools is the list of tools to count tokens for.
|
|
Tools []*schema.ToolInfo
|
|
}
|
|
|
|
// TokenCounterInput is a backward-compatible alias for TypedTokenCounterInput specialized with *schema.Message.
|
|
type TokenCounterInput = TypedTokenCounterInput[*schema.Message]
|
|
|
|
// TriggerCondition specifies when summarization should be activated.
|
|
// Summarization triggers if ANY of the set conditions is met.
|
|
type TriggerCondition struct {
|
|
// ContextTokens triggers summarization when total token count exceeds this threshold.
|
|
ContextTokens int
|
|
// ContextMessages triggers summarization when total messages count exceeds this threshold.
|
|
ContextMessages int
|
|
}
|
|
|
|
type TypedRetryConfig[M adk.MessageType] struct {
|
|
// MaxRetries specifies the maximum number of retry attempts.
|
|
// Optional. Defaults to 3.
|
|
MaxRetries *int
|
|
|
|
// ShouldRetry determines whether a failed summary generation attempt should be retried.
|
|
// It is called after each failed attempt with the model response and error.
|
|
// Optional. Defaults to retrying when err is non-nil.
|
|
ShouldRetry func(ctx context.Context, resp M, err error) bool
|
|
|
|
// BackoffFunc calculates the delay before the next retry attempt.
|
|
// The attempt parameter starts at 1 for the first retry.
|
|
// Optional. Defaults to a default exponential backoff with jitter.
|
|
BackoffFunc func(ctx context.Context, attempt int, resp M, err error) time.Duration
|
|
}
|
|
|
|
// RetryConfig is a backward-compatible alias for TypedRetryConfig specialized with *schema.Message.
|
|
type RetryConfig = TypedRetryConfig[*schema.Message]
|
|
|
|
type TypedFailoverConfig[M adk.MessageType] struct {
|
|
// MaxRetries specifies the maximum number of failover attempts.
|
|
// Optional. Defaults to 3.
|
|
MaxRetries *int
|
|
|
|
// ShouldFailover determines whether another failover attempt should be made.
|
|
// It is called after each failover attempt with the model response and error.
|
|
// Optional. Defaults to failing over when err is non-nil.
|
|
ShouldFailover func(ctx context.Context, resp M, err error) bool
|
|
|
|
// BackoffFunc calculates the delay before the next failover attempt.
|
|
// The attempt parameter starts at 1 for the first failover attempt.
|
|
// Optional. Defaults to a default exponential backoff with jitter.
|
|
BackoffFunc func(ctx context.Context, attempt int, resp M, err error) time.Duration
|
|
|
|
// GetFailoverModel selects the model and input messages for the current failover attempt.
|
|
//
|
|
// Parameters:
|
|
// - failoverCtx: contains the context for the current failover attempt.
|
|
//
|
|
// Returns:
|
|
// - failoverModel: the model to use for this failover attempt.
|
|
// - failoverModelInputMsgs: the input messages to send to failoverModel.
|
|
// - failoverErr: an error encountered while preparing the failover model or input.
|
|
//
|
|
// Constraints:
|
|
// - When provided, it must return a non-nil model and a non-empty input message list.
|
|
//
|
|
// Optional. Defaults to reusing the primary model with the default input messages.
|
|
GetFailoverModel TypedGetFailoverModelFunc[M]
|
|
}
|
|
|
|
// FailoverConfig is a backward-compatible alias for TypedFailoverConfig specialized with *schema.Message.
|
|
type FailoverConfig = TypedFailoverConfig[*schema.Message]
|
|
|
|
// TypedFailoverContext contains the state for a failover attempt.
|
|
type TypedFailoverContext[M adk.MessageType] struct {
|
|
// Attempt is the current failover attempt number, starting at 1.
|
|
Attempt int
|
|
|
|
// SystemInstruction is the system instruction used for summary generation.
|
|
// It is set internally by the middleware and is not configurable.
|
|
SystemInstruction M
|
|
|
|
// UserInstruction is the user instruction used for summary generation.
|
|
UserInstruction M
|
|
|
|
// OriginalMessages is the full original conversation before summarization.
|
|
OriginalMessages []M
|
|
|
|
// LastModelResponse is the response returned by the previous attempt, if any.
|
|
LastModelResponse M
|
|
|
|
// LastErr is the error returned by the previous attempt, if any.
|
|
LastErr error
|
|
}
|
|
|
|
// FailoverContext is a backward-compatible alias for TypedFailoverContext specialized with *schema.Message.
|
|
type FailoverContext = TypedFailoverContext[*schema.Message]
|
|
|
|
// NewTyped creates a generic summarization middleware that automatically summarizes
|
|
// conversation history when trigger conditions are met.
|
|
//
|
|
// This is the generic constructor that supports both *schema.Message and *schema.AgenticMessage.
|
|
func NewTyped[M adk.MessageType](_ context.Context, cfg *TypedConfig[M]) (adk.TypedChatModelAgentMiddleware[M], error) {
|
|
if err := cfg.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
mw := &TypedMiddleware[M]{
|
|
cfg: cfg,
|
|
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[M]{},
|
|
}
|
|
return mw, nil
|
|
}
|
|
|
|
// New creates a summarization middleware that automatically summarizes conversation history
|
|
// when trigger conditions are met.
|
|
func New(ctx context.Context, cfg *Config) (adk.ChatModelAgentMiddleware, error) {
|
|
return NewTyped(ctx, cfg)
|
|
}
|
|
|
|
type TypedMiddleware[M adk.MessageType] struct {
|
|
*adk.TypedBaseChatModelAgentMiddleware[M]
|
|
cfg *TypedConfig[M]
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) Summarize(ctx context.Context, state *adk.TypedChatModelAgentState[M]) ([]M, error) {
|
|
beforeState := *state
|
|
|
|
if m.cfg.EmitInternalEvents {
|
|
err := m.emitEvent(ctx, &TypedCustomizedAction[M]{
|
|
Type: ActionTypeBeforeSummarize,
|
|
Before: &TypedBeforeSummarizeAction[M]{
|
|
Messages: beforeState.Messages,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
rawSummary, modelInput, err := m.summarize(ctx, beforeState.Messages)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
finalizeCtx := context.WithValue(ctx, ctxKeyModelInput{}, modelInput)
|
|
|
|
finalizer := m.cfg.Finalize
|
|
if finalizer == nil {
|
|
finalizer = buildInternalFinalizer(m.cfg)
|
|
}
|
|
finalMsgs, err := finalizer(finalizeCtx, beforeState.Messages, rawSummary)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if m.cfg.Callback != nil {
|
|
afterState := beforeState
|
|
afterState.Messages = finalMsgs
|
|
if err = m.cfg.Callback(ctx, beforeState, afterState); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if m.cfg.EmitInternalEvents {
|
|
err = m.emitEvent(ctx, &TypedCustomizedAction[M]{
|
|
Type: ActionTypeAfterSummarize,
|
|
After: &TypedAfterSummarizeAction[M]{
|
|
Messages: finalMsgs,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return finalMsgs, nil
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) BeforeModelRewriteState(ctx context.Context, state *adk.TypedChatModelAgentState[M],
|
|
_ *adk.TypedModelContext[M]) (context.Context, *adk.TypedChatModelAgentState[M], error) {
|
|
|
|
triggered, err := m.shouldSummarize(ctx, &TypedTokenCounterInput[M]{
|
|
Messages: state.Messages,
|
|
Tools: state.ToolInfos,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !triggered {
|
|
return ctx, state, nil
|
|
}
|
|
|
|
finalMsgs, err := m.Summarize(ctx, state)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
afterState := *state
|
|
afterState.Messages = finalMsgs
|
|
|
|
return ctx, &afterState, nil
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) shouldSummarize(ctx context.Context, input *TypedTokenCounterInput[M]) (bool, error) {
|
|
if m.cfg.Trigger != nil && m.cfg.Trigger.ContextMessages > 0 {
|
|
if len(input.Messages) > m.cfg.Trigger.ContextMessages {
|
|
return true, nil
|
|
}
|
|
}
|
|
tokens, err := m.countTokens(ctx, input)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to count tokens: %w", err)
|
|
}
|
|
return tokens > m.getTriggerContextTokens(), nil
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) getTriggerContextTokens() int {
|
|
const defaultTriggerContextTokens = 160000
|
|
if m.cfg.Trigger != nil {
|
|
return m.cfg.Trigger.ContextTokens
|
|
}
|
|
return defaultTriggerContextTokens
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) emitEvent(ctx context.Context, action *TypedCustomizedAction[M]) error {
|
|
err := adk.TypedSendEvent(ctx, &adk.TypedAgentEvent[M]{
|
|
Action: &adk.AgentAction{
|
|
CustomizedAction: action,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send internal event: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) emitGenerateSummaryEvent(ctx context.Context, attempt int, phase GenerateSummaryPhase,
|
|
resp M, err error) error {
|
|
|
|
if !m.cfg.EmitInternalEvents {
|
|
return nil
|
|
}
|
|
|
|
action := &TypedGenerateSummaryAction[M]{
|
|
Attempt: attempt,
|
|
Phase: phase,
|
|
ModelResponse: resp,
|
|
err: err,
|
|
}
|
|
|
|
return m.emitEvent(ctx, &TypedCustomizedAction[M]{
|
|
Type: ActionTypeGenerateSummary,
|
|
GenerateSummary: action,
|
|
})
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) countTokens(ctx context.Context, input *TypedTokenCounterInput[M]) (int, error) {
|
|
if m.cfg.TokenCounter != nil {
|
|
return m.cfg.TokenCounter(ctx, input)
|
|
}
|
|
return defaultTypedTokenCounter(ctx, input)
|
|
}
|
|
|
|
func defaultTypedTokenCounter[M adk.MessageType](_ context.Context, input *TypedTokenCounterInput[M]) (int, error) {
|
|
var (
|
|
baseTokens int
|
|
incrementStart int
|
|
)
|
|
|
|
for i := len(input.Messages) - 1; i >= 0; i-- {
|
|
if tokens := getAssistantTotalTokens(input.Messages[i]); tokens > 0 {
|
|
baseTokens = tokens
|
|
incrementStart = i + 1
|
|
break
|
|
}
|
|
}
|
|
|
|
var incrementTokens int
|
|
for _, msg := range input.Messages[incrementStart:] {
|
|
switch m := any(msg).(type) {
|
|
case *schema.Message:
|
|
incrementTokens += estimateMessageTokens(m)
|
|
case *schema.AgenticMessage:
|
|
incrementTokens += estimateAgenticMessageTokens(m)
|
|
}
|
|
}
|
|
|
|
for _, tl := range input.Tools {
|
|
tl_ := *tl
|
|
tl_.Extra = nil
|
|
text, err := sonic.MarshalString(tl_)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to marshal tool info: %w", err)
|
|
}
|
|
incrementTokens += estimateTokenCount(len(text))
|
|
}
|
|
|
|
return baseTokens + incrementTokens, nil
|
|
}
|
|
|
|
func getAssistantTotalTokens[M adk.MessageType](msg M) int {
|
|
if msg == nil {
|
|
return 0
|
|
}
|
|
switch m := any(msg).(type) {
|
|
case *schema.Message:
|
|
if m.Role != schema.Assistant || m.ResponseMeta == nil || m.ResponseMeta.Usage == nil {
|
|
return 0
|
|
}
|
|
return m.ResponseMeta.Usage.TotalTokens
|
|
case *schema.AgenticMessage:
|
|
if m.Role != schema.AgenticRoleTypeAssistant || m.ResponseMeta == nil || m.ResponseMeta.TokenUsage == nil {
|
|
return 0
|
|
}
|
|
return m.ResponseMeta.TokenUsage.TotalTokens
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func estimateTokenCount(charLen int) int {
|
|
return charLen / 4
|
|
}
|
|
|
|
func estimateTokenBytes(tokens int) int {
|
|
return tokens * 4
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) summarize(ctx context.Context, originalMsgs []M) (M, []M, error) {
|
|
var zero M
|
|
_, contextMsgs := splitSystemAndContextMsgs(originalMsgs)
|
|
|
|
modelInput, err := m.buildSummarizationModelInput(ctx, originalMsgs, contextMsgs)
|
|
if err != nil {
|
|
return zero, nil, err
|
|
}
|
|
|
|
rawSummary, err := m.generateWithRetry(ctx, m.cfg.Model, modelInput, m.cfg.ModelOptions, m.cfg.Retry)
|
|
if typedShouldFailover(ctx, m.cfg.Failover, rawSummary, err) {
|
|
rawSummary, modelInput, err = m.runFailover(ctx, originalMsgs, modelInput, rawSummary, err)
|
|
if err != nil {
|
|
return zero, nil, err
|
|
}
|
|
} else if err != nil {
|
|
return zero, nil, fmt.Errorf("failed to generate summary: %w", err)
|
|
}
|
|
|
|
return rawSummary, modelInput, nil
|
|
}
|
|
|
|
func splitSystemAndContextMsgs[M adk.MessageType](msgs []M) ([]M, []M) {
|
|
var systemMsgs []M
|
|
for _, msg := range msgs {
|
|
if isSystemRole(msg) {
|
|
systemMsgs = append(systemMsgs, msg)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
contextMsgs := msgs[len(systemMsgs):]
|
|
return systemMsgs, contextMsgs
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) runFailover(ctx context.Context, originalMsgs, defaultInput []M, lastResp M,
|
|
lastErr error) (M, []M, error) {
|
|
|
|
var zero M
|
|
const defaultMaxRetries = 3
|
|
|
|
sysInstruction, userInstruction := m.getModelInstructions()
|
|
|
|
maxRetries := defaultMaxRetries
|
|
if m.cfg.Failover.MaxRetries != nil {
|
|
maxRetries = *m.cfg.Failover.MaxRetries
|
|
}
|
|
|
|
backoff := m.cfg.Failover.BackoffFunc
|
|
if backoff == nil {
|
|
backoff = defaultTypedBackoffFunc[M]
|
|
}
|
|
|
|
modelInput := defaultInput
|
|
|
|
if maxRetries <= 0 {
|
|
return lastResp, modelInput, lastErr
|
|
}
|
|
|
|
for attempt := 1; ; attempt++ {
|
|
fctx := &TypedFailoverContext[M]{
|
|
Attempt: attempt,
|
|
SystemInstruction: sysInstruction,
|
|
UserInstruction: userInstruction,
|
|
OriginalMessages: originalMsgs,
|
|
LastModelResponse: lastResp,
|
|
LastErr: lastErr,
|
|
}
|
|
|
|
failoverModel, nextInput, failoverErr := m.getFailoverModel(ctx, fctx, defaultInput)
|
|
if failoverErr != nil {
|
|
lastResp = zero
|
|
lastErr = failoverErr
|
|
if emitErr := m.emitGenerateSummaryEvent(ctx, attempt, GenerateSummaryPhaseFailover, zero, failoverErr); emitErr != nil {
|
|
return zero, nil, emitErr
|
|
}
|
|
} else {
|
|
modelInput = nextInput
|
|
lastResp, lastErr = m.generateAndEmit(ctx, failoverModel, modelInput, m.cfg.ModelOptions, attempt, GenerateSummaryPhaseFailover)
|
|
}
|
|
|
|
if !typedShouldFailover(ctx, m.cfg.Failover, lastResp, lastErr) {
|
|
return lastResp, modelInput, lastErr
|
|
}
|
|
if attempt == maxRetries {
|
|
if lastErr != nil {
|
|
return zero, nil, fmt.Errorf("exceeds max failover attempts: %w", lastErr)
|
|
}
|
|
return zero, nil, fmt.Errorf("exceeds max failover attempts")
|
|
}
|
|
|
|
select {
|
|
case <-time.After(backoff(ctx, attempt, lastResp, lastErr)):
|
|
case <-ctx.Done():
|
|
return zero, nil, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) getFailoverModel(ctx context.Context, failoverCtx *TypedFailoverContext[M], defaultInput []M) (model.BaseModel[M], []M, error) {
|
|
if m.cfg.Failover == nil {
|
|
return nil, nil, fmt.Errorf("failover config is required")
|
|
}
|
|
if m.cfg.Failover.GetFailoverModel == nil {
|
|
return m.cfg.Model, defaultInput, nil
|
|
}
|
|
|
|
failoverModel, nextModelInput, err := m.cfg.Failover.GetFailoverModel(ctx, failoverCtx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get failover model: %w", err)
|
|
}
|
|
|
|
if failoverModel == nil {
|
|
return nil, nil, fmt.Errorf("failover model is required")
|
|
}
|
|
if len(nextModelInput) == 0 {
|
|
return nil, nil, fmt.Errorf("failover model input messages are required")
|
|
}
|
|
|
|
return failoverModel, nextModelInput, nil
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) buildSummarizationModelInput(ctx context.Context, originMsgs, contextMsgs []M) ([]M, error) {
|
|
sysInstruction, userInstruction := m.getModelInstructions()
|
|
|
|
if m.cfg.GenModelInput != nil {
|
|
input, err := m.cfg.GenModelInput(ctx, sysInstruction, userInstruction, originMsgs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate model input: %w", err)
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
input := make([]M, 0, len(contextMsgs)+2)
|
|
input = append(input, sysInstruction)
|
|
input = append(input, contextMsgs...)
|
|
input = append(input, userInstruction)
|
|
|
|
return input, nil
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) getModelInstructions() (M, M) {
|
|
userInstruction := m.cfg.UserInstruction
|
|
if userInstruction == "" {
|
|
userInstruction = getUserSummaryInstruction()
|
|
}
|
|
|
|
return makeSystemMsg[M](getSystemInstruction()), makeUserMsg[M](userInstruction)
|
|
}
|
|
|
|
func buildInternalFinalizer[M adk.MessageType](cfg *TypedConfig[M]) TypedFinalizeFunc[M] {
|
|
return func(ctx context.Context, originalMessages []M, rawSummary M) ([]M, error) {
|
|
systemMsgs, contextMsgs := splitSystemAndContextMsgs(originalMessages)
|
|
|
|
processed, err := postProcessSummary(ctx, &postProcessSummaryParams[M]{
|
|
contextMsgs: contextMsgs,
|
|
summaryContent: getAssistantTextContent(rawSummary),
|
|
transcriptPath: cfg.TranscriptFilePath,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return append(systemMsgs, processed), nil
|
|
}
|
|
}
|
|
|
|
func getAssistantTextContent[M adk.MessageType](msg M) string {
|
|
switch r := any(msg).(type) {
|
|
case *schema.Message:
|
|
if r.Role != schema.Assistant {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for _, part := range r.AssistantGenMultiContent {
|
|
if part.Type == schema.ChatMessagePartTypeText && part.Text != "" {
|
|
parts = append(parts, part.Text)
|
|
}
|
|
}
|
|
if len(parts) > 0 {
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
return r.Content
|
|
case *schema.AgenticMessage:
|
|
if r.Role != schema.AgenticRoleTypeAssistant {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for _, block := range r.ContentBlocks {
|
|
if block != nil && block.AssistantGenText != nil {
|
|
parts = append(parts, block.AssistantGenText.Text)
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
type postProcessSummaryParams[M adk.MessageType] struct {
|
|
contextMsgs []M
|
|
summaryContent string
|
|
transcriptPath string
|
|
}
|
|
|
|
func postProcessSummary[M adk.MessageType](ctx context.Context, p *postProcessSummaryParams[M]) (processed M, err error) {
|
|
content := p.summaryContent
|
|
if content == "" {
|
|
return processed, fmt.Errorf("summary content is empty")
|
|
}
|
|
|
|
if len(p.contextMsgs) > 0 {
|
|
newContent, err := replaceUserMessagesInSummary(ctx, &replaceUserMessagesInSummaryParams[M]{
|
|
contextMsgs: p.contextMsgs,
|
|
summaryText: content,
|
|
})
|
|
if err != nil {
|
|
return processed, fmt.Errorf("failed to populate user messages in summary: %w", err)
|
|
}
|
|
content = newContent
|
|
}
|
|
|
|
if p.transcriptPath != "" {
|
|
content = appendSection(content, fmt.Sprintf(getTranscriptPathInstruction(), p.transcriptPath))
|
|
}
|
|
|
|
content = appendSection(getSummaryPreamble(), content)
|
|
content = appendSection(content, getContinueInstruction())
|
|
|
|
processed = newTypedSummaryMessage[M](content)
|
|
|
|
return processed, nil
|
|
}
|
|
|
|
type replaceUserMessagesInSummaryParams[M adk.MessageType] struct {
|
|
contextMsgs []M
|
|
summaryText string
|
|
}
|
|
|
|
func replaceUserMessagesInSummary[M adk.MessageType](ctx context.Context, p *replaceUserMessagesInSummaryParams[M]) (string, error) {
|
|
var userMsgs []M
|
|
var hasUserMsgs bool
|
|
for _, msg := range p.contextMsgs {
|
|
if isInternalUserMessage(msg) {
|
|
continue
|
|
}
|
|
if isUserRole(msg) {
|
|
hasUserMsgs = true
|
|
userMsgs = append(userMsgs, msg)
|
|
}
|
|
}
|
|
|
|
if !hasUserMsgs {
|
|
return p.summaryText, nil
|
|
}
|
|
|
|
var selected []M
|
|
if len(userMsgs) == 1 {
|
|
selected = userMsgs
|
|
} else {
|
|
var totalTokens int
|
|
for i := len(userMsgs) - 1; i >= 0; i-- {
|
|
msg := userMsgs[i]
|
|
|
|
tokens, err := defaultTypedTokenCounter(ctx, &TypedTokenCounterInput[M]{
|
|
Messages: []M{msg},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to count tokens: %w", err)
|
|
}
|
|
|
|
remaining := preserveUserMsgsMaxTokens - totalTokens
|
|
if tokens <= remaining {
|
|
totalTokens += tokens
|
|
selected = append(selected, msg)
|
|
continue
|
|
}
|
|
|
|
trimmedMsg := defaultTypedTrimUserMessage(msg, remaining)
|
|
var zero M
|
|
if any(trimmedMsg) != any(zero) {
|
|
selected = append(selected, trimmedMsg)
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
for i, j := 0, len(selected)-1; i < j; i, j = i+1, j-1 {
|
|
selected[i], selected[j] = selected[j], selected[i]
|
|
}
|
|
}
|
|
|
|
var msgLines []string
|
|
for _, msg := range selected {
|
|
text := getUserMsgTextContent(msg)
|
|
if text != "" {
|
|
msgLines = append(msgLines, " - "+text)
|
|
}
|
|
}
|
|
userMsgsText := strings.Join(msgLines, "\n")
|
|
|
|
lastMatch := findLastMatch(allUserMessagesTagRegex, p.summaryText)
|
|
if lastMatch == nil {
|
|
return p.summaryText, nil
|
|
}
|
|
|
|
var replacement string
|
|
if len(selected) < len(userMsgs) {
|
|
replacement = "<all_user_messages>\n" + getUserMessagesReplacedNote() + "\n" + userMsgsText + "\n</all_user_messages>"
|
|
} else {
|
|
replacement = "<all_user_messages>\n" + userMsgsText + "\n</all_user_messages>"
|
|
}
|
|
|
|
newSummaryText := p.summaryText[:lastMatch[0]] + replacement + p.summaryText[lastMatch[1]:]
|
|
|
|
return newSummaryText, nil
|
|
}
|
|
|
|
func findLastMatch(re *regexp.Regexp, s string) []int {
|
|
matches := re.FindAllStringIndex(s, -1)
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
return matches[len(matches)-1]
|
|
}
|
|
|
|
func appendSection(base, section string) string {
|
|
if base == "" {
|
|
return section
|
|
}
|
|
if section == "" {
|
|
return base
|
|
}
|
|
return base + "\n\n" + section
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) generateAndEmit(ctx context.Context, chatModel model.BaseModel[M], input []M,
|
|
opts []model.Option, attempt int, phase GenerateSummaryPhase) (M, error) {
|
|
|
|
resp, err := chatModel.Generate(ctx, input, opts...)
|
|
if emitErr := m.emitGenerateSummaryEvent(ctx, attempt, phase, resp, err); emitErr != nil {
|
|
var zero M
|
|
return zero, emitErr
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
func (m *TypedMiddleware[M]) generateWithRetry(ctx context.Context, chatModel model.BaseModel[M], input []M,
|
|
opts []model.Option, retryCfg *TypedRetryConfig[M]) (M, error) {
|
|
|
|
const defaultMaxRetries = 3
|
|
|
|
if retryCfg == nil {
|
|
return m.generateAndEmit(ctx, chatModel, input, opts, 1, GenerateSummaryPhasePrimary)
|
|
}
|
|
|
|
shouldRetry := retryCfg.ShouldRetry
|
|
if shouldRetry == nil {
|
|
shouldRetry = defaultTypedShouldRetry[M]
|
|
}
|
|
backoffFunc := retryCfg.BackoffFunc
|
|
if backoffFunc == nil {
|
|
backoffFunc = defaultTypedBackoffFunc[M]
|
|
}
|
|
|
|
maxRetries := defaultMaxRetries
|
|
if retryCfg.MaxRetries != nil {
|
|
maxRetries = *retryCfg.MaxRetries
|
|
}
|
|
totalAttempts := maxRetries + 1
|
|
|
|
var (
|
|
lastModelResp M
|
|
lastErr error
|
|
)
|
|
for attempt := 1; attempt <= totalAttempts; attempt++ {
|
|
resp, err := m.generateAndEmit(ctx, chatModel, input, opts, attempt, GenerateSummaryPhasePrimary)
|
|
if !shouldRetry(ctx, resp, err) {
|
|
return resp, err
|
|
}
|
|
|
|
lastModelResp = resp
|
|
lastErr = err
|
|
if attempt < totalAttempts {
|
|
select {
|
|
case <-time.After(backoffFunc(ctx, attempt, resp, err)):
|
|
case <-ctx.Done():
|
|
var zero M
|
|
return zero, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
if maxRetries > 0 {
|
|
return lastModelResp, fmt.Errorf("exceeds max retries: %w", lastErr)
|
|
}
|
|
|
|
return lastModelResp, lastErr
|
|
}
|
|
|
|
func truncateTextByChars(text string) string {
|
|
const maxRunes = 2000
|
|
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
|
|
if utf8.RuneCountInString(text) <= maxRunes {
|
|
return text
|
|
}
|
|
|
|
halfRunes := maxRunes / 2
|
|
runes := []rune(text)
|
|
totalRunes := len(runes)
|
|
|
|
prefix := string(runes[:halfRunes])
|
|
suffix := string(runes[totalRunes-halfRunes:])
|
|
removedChars := totalRunes - maxRunes
|
|
|
|
marker := fmt.Sprintf(getTruncatedMarkerFormat(), removedChars)
|
|
|
|
return prefix + marker + suffix
|
|
}
|
|
|
|
func (c *TypedConfig[M]) check() error {
|
|
if c == nil {
|
|
return fmt.Errorf("config is required")
|
|
}
|
|
if c.Model == nil {
|
|
return fmt.Errorf("model is required")
|
|
}
|
|
if c.Trigger != nil {
|
|
if err := c.Trigger.check(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if c.Retry != nil {
|
|
if err := c.Retry.check(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if c.Failover != nil {
|
|
if err := c.Failover.check(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *TypedRetryConfig[M]) check() error {
|
|
if c.MaxRetries != nil && *c.MaxRetries < 0 {
|
|
return fmt.Errorf("retry.MaxRetries must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *TypedFailoverConfig[M]) check() error {
|
|
if c.MaxRetries != nil && *c.MaxRetries < 0 {
|
|
return fmt.Errorf("failover.MaxRetries must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *TriggerCondition) check() error {
|
|
if c.ContextTokens < 0 {
|
|
return fmt.Errorf("contextTokens must be non-negative")
|
|
}
|
|
if c.ContextMessages < 0 {
|
|
return fmt.Errorf("contextMessages must be non-negative")
|
|
}
|
|
if c.ContextTokens == 0 && c.ContextMessages == 0 {
|
|
return fmt.Errorf("at least one of contextTokens or contextMessages must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Generic helper functions
|
|
// ============================================================================
|
|
|
|
func isSystemRole[M adk.MessageType](msg M) bool {
|
|
switch m := any(msg).(type) {
|
|
case *schema.Message:
|
|
return m.Role == schema.System
|
|
case *schema.AgenticMessage:
|
|
return m.Role == schema.AgenticRoleTypeSystem
|
|
}
|
|
panic("unreachable")
|
|
}
|
|
|
|
func isUserRole[M adk.MessageType](msg M) bool {
|
|
switch m := any(msg).(type) {
|
|
case *schema.Message:
|
|
return m.Role == schema.User
|
|
case *schema.AgenticMessage:
|
|
return m.Role == schema.AgenticRoleTypeUser
|
|
}
|
|
panic("unreachable")
|
|
}
|
|
|
|
func messageUserTextContent(m *schema.Message) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for _, part := range m.UserInputMultiContent {
|
|
if part.Type == schema.ChatMessagePartTypeText && part.Text != "" {
|
|
parts = append(parts, part.Text)
|
|
}
|
|
}
|
|
if len(parts) > 0 {
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
return m.Content
|
|
}
|
|
|
|
func getUserMsgTextContent[M adk.MessageType](msg M) string {
|
|
switch m := any(msg).(type) {
|
|
case *schema.Message:
|
|
return messageUserTextContent(m)
|
|
|
|
case *schema.AgenticMessage:
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for _, block := range m.ContentBlocks {
|
|
if block == nil {
|
|
continue
|
|
}
|
|
if block.UserInputText != nil {
|
|
parts = append(parts, block.UserInputText.Text)
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
|
|
default:
|
|
panic("unreachable")
|
|
}
|
|
}
|
|
|
|
const multimodalTokenEstimate = 2000
|
|
|
|
func estimateMessageTokens(msg *schema.Message) int {
|
|
if msg == nil {
|
|
return 0
|
|
}
|
|
var totalLen int
|
|
var multimodalTokens int
|
|
|
|
if msg.Role == schema.Assistant {
|
|
if len(msg.AssistantGenMultiContent) > 0 {
|
|
hasReasoning := false
|
|
for _, part := range msg.AssistantGenMultiContent {
|
|
switch part.Type {
|
|
case schema.ChatMessagePartTypeText:
|
|
totalLen += len(part.Text)
|
|
case schema.ChatMessagePartTypeReasoning:
|
|
hasReasoning = true
|
|
if part.Reasoning != nil {
|
|
totalLen += len(part.Reasoning.Text)
|
|
}
|
|
case schema.ChatMessagePartTypeImageURL, schema.ChatMessagePartTypeAudioURL,
|
|
schema.ChatMessagePartTypeVideoURL, schema.ChatMessagePartTypeFileURL:
|
|
multimodalTokens += multimodalTokenEstimate
|
|
}
|
|
}
|
|
if !hasReasoning {
|
|
totalLen += len(msg.ReasoningContent)
|
|
}
|
|
} else {
|
|
totalLen += len(msg.Content) + len(msg.ReasoningContent)
|
|
}
|
|
for _, tc := range msg.ToolCalls {
|
|
totalLen += len(tc.Function.Name) + len(tc.Function.Arguments)
|
|
}
|
|
} else {
|
|
if len(msg.UserInputMultiContent) > 0 {
|
|
for _, part := range msg.UserInputMultiContent {
|
|
switch part.Type {
|
|
case schema.ChatMessagePartTypeText:
|
|
totalLen += len(part.Text)
|
|
case schema.ChatMessagePartTypeToolSearchResult:
|
|
if part.ToolSearchResult != nil {
|
|
for _, tl := range part.ToolSearchResult.Tools {
|
|
totalLen += len(tl.Name) + len(tl.Desc)
|
|
if b, err := sonic.Marshal(tl.ParamsOneOf); err == nil {
|
|
totalLen += len(b)
|
|
}
|
|
}
|
|
}
|
|
case schema.ChatMessagePartTypeImageURL, schema.ChatMessagePartTypeAudioURL,
|
|
schema.ChatMessagePartTypeVideoURL, schema.ChatMessagePartTypeFileURL:
|
|
multimodalTokens += multimodalTokenEstimate
|
|
}
|
|
}
|
|
} else {
|
|
totalLen += len(msg.Content)
|
|
}
|
|
}
|
|
|
|
return estimateTokenCount(totalLen) + multimodalTokens
|
|
}
|
|
|
|
func estimateAgenticMessageTokens(msg *schema.AgenticMessage) int {
|
|
if msg == nil {
|
|
return 0
|
|
}
|
|
var totalLen int
|
|
var multimodalTokens int
|
|
|
|
if msg.Role == schema.AgenticRoleTypeAssistant {
|
|
for _, block := range msg.ContentBlocks {
|
|
if block == nil {
|
|
continue
|
|
}
|
|
switch block.Type {
|
|
case schema.ContentBlockTypeAssistantGenText:
|
|
totalLen += len(block.AssistantGenText.Text)
|
|
case schema.ContentBlockTypeFunctionToolCall:
|
|
totalLen += len(block.FunctionToolCall.Name) + len(block.FunctionToolCall.Arguments)
|
|
case schema.ContentBlockTypeReasoning:
|
|
totalLen += len(block.Reasoning.Text)
|
|
case schema.ContentBlockTypeAssistantGenImage, schema.ContentBlockTypeAssistantGenAudio,
|
|
schema.ContentBlockTypeAssistantGenVideo:
|
|
multimodalTokens += multimodalTokenEstimate
|
|
}
|
|
}
|
|
} else {
|
|
for _, block := range msg.ContentBlocks {
|
|
if block == nil {
|
|
continue
|
|
}
|
|
switch block.Type {
|
|
case schema.ContentBlockTypeUserInputText:
|
|
totalLen += len(block.UserInputText.Text)
|
|
case schema.ContentBlockTypeFunctionToolResult:
|
|
for _, cb := range block.FunctionToolResult.Content {
|
|
if cb == nil {
|
|
continue
|
|
}
|
|
switch cb.Type {
|
|
case schema.FunctionToolResultContentBlockTypeText:
|
|
if cb.Text != nil {
|
|
totalLen += len(cb.Text.Text)
|
|
}
|
|
case schema.FunctionToolResultContentBlockTypeImage, schema.FunctionToolResultContentBlockTypeAudio,
|
|
schema.FunctionToolResultContentBlockTypeVideo, schema.FunctionToolResultContentBlockTypeFile:
|
|
multimodalTokens += multimodalTokenEstimate
|
|
}
|
|
}
|
|
case schema.ContentBlockTypeToolSearchResult:
|
|
if block.ToolSearchFunctionToolResult != nil && block.ToolSearchFunctionToolResult.Result != nil {
|
|
for _, tl := range block.ToolSearchFunctionToolResult.Result.Tools {
|
|
totalLen += len(tl.Name) + len(tl.Desc)
|
|
if b, err := sonic.Marshal(tl.ParamsOneOf); err == nil {
|
|
totalLen += len(b)
|
|
}
|
|
}
|
|
}
|
|
case schema.ContentBlockTypeUserInputImage, schema.ContentBlockTypeUserInputFile,
|
|
schema.ContentBlockTypeUserInputAudio, schema.ContentBlockTypeUserInputVideo:
|
|
multimodalTokens += multimodalTokenEstimate
|
|
}
|
|
}
|
|
}
|
|
|
|
return estimateTokenCount(totalLen) + multimodalTokens
|
|
}
|
|
|
|
func getMsgExtra[M adk.MessageType](msg M) map[string]any {
|
|
switch m := any(msg).(type) {
|
|
case *schema.Message:
|
|
return m.Extra
|
|
case *schema.AgenticMessage:
|
|
return m.Extra
|
|
default:
|
|
panic("unreachable")
|
|
}
|
|
}
|
|
|
|
func setMsgExtra[M adk.MessageType](msg M, key string, value any) {
|
|
switch m := any(msg).(type) {
|
|
case *schema.Message:
|
|
if m.Extra == nil {
|
|
m.Extra = map[string]any{}
|
|
}
|
|
m.Extra[key] = value
|
|
case *schema.AgenticMessage:
|
|
if m.Extra == nil {
|
|
m.Extra = map[string]any{}
|
|
}
|
|
m.Extra[key] = value
|
|
}
|
|
}
|
|
|
|
func makeSystemMsg[M adk.MessageType](text string) M {
|
|
var zero M
|
|
switch any(zero).(type) {
|
|
case *schema.Message:
|
|
return any(schema.SystemMessage(text)).(M)
|
|
case *schema.AgenticMessage:
|
|
return any(schema.SystemAgenticMessage(text)).(M)
|
|
default:
|
|
panic("unreachable")
|
|
}
|
|
}
|
|
|
|
func makeUserMsg[M adk.MessageType](text string) M {
|
|
var zero M
|
|
switch any(zero).(type) {
|
|
case *schema.Message:
|
|
return any(schema.UserMessage(text)).(M)
|
|
case *schema.AgenticMessage:
|
|
return any(schema.UserAgenticMessage(text)).(M)
|
|
default:
|
|
panic("unreachable")
|
|
}
|
|
}
|
|
|
|
func newTypedSummaryMessage[M adk.MessageType](content string) M {
|
|
msg := makeUserMsg[M](content)
|
|
setMsgExtra(msg, extraKeyContentType, string(contentTypeSummary))
|
|
return msg
|
|
}
|
|
|
|
func typedGetContentType[M adk.MessageType](msg M) summarizationContentType {
|
|
extra := getMsgExtra(msg)
|
|
if extra == nil {
|
|
return ""
|
|
}
|
|
ct, ok := extra[extraKeyContentType].(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return summarizationContentType(ct)
|
|
}
|
|
|
|
func isInternalUserMessage[M adk.MessageType](msg M) bool {
|
|
return typedGetContentType(msg) == contentTypeSummary || isPreservedMessage(msg)
|
|
}
|
|
|
|
func isPreservedMessage[M adk.MessageType](msg M) bool {
|
|
return typedGetContentType(msg) == contentTypeSkills
|
|
}
|
|
|
|
func typedShouldFailover[M adk.MessageType](ctx context.Context, cfg *TypedFailoverConfig[M], resp M, err error) bool {
|
|
if cfg == nil {
|
|
return false
|
|
}
|
|
if cfg.ShouldFailover == nil {
|
|
return err != nil
|
|
}
|
|
return cfg.ShouldFailover(ctx, resp, err)
|
|
}
|
|
|
|
func defaultTypedShouldRetry[M adk.MessageType](_ context.Context, _ M, err error) bool {
|
|
return err != nil
|
|
}
|
|
|
|
func defaultTypedBackoffFunc[M adk.MessageType](_ context.Context, attempt int, _ M, _ error) time.Duration {
|
|
return defaultBackoffDuration(attempt)
|
|
}
|
|
|
|
func defaultBackoffDuration(attempt int) time.Duration {
|
|
const (
|
|
baseDelay = time.Second
|
|
maxDelay = 10 * time.Second
|
|
)
|
|
|
|
if attempt <= 0 {
|
|
return baseDelay
|
|
}
|
|
|
|
if attempt > 7 {
|
|
return maxDelay + time.Duration(rand.Int63n(int64(maxDelay/2)))
|
|
}
|
|
|
|
delay := baseDelay * time.Duration(1<<uint(attempt-1))
|
|
if delay > maxDelay {
|
|
delay = maxDelay
|
|
}
|
|
|
|
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
|
|
|
|
return delay + jitter
|
|
}
|
|
|
|
func defaultTypedTrimUserMessage[M adk.MessageType](msg M, remainingTokens int) M {
|
|
var zero M
|
|
if remainingTokens <= 0 {
|
|
return zero
|
|
}
|
|
|
|
textContent := getUserMsgTextContent(msg)
|
|
if len(textContent) == 0 {
|
|
return zero
|
|
}
|
|
|
|
trimmed := truncateTextByChars(textContent)
|
|
if trimmed == "" {
|
|
return zero
|
|
}
|
|
|
|
return makeUserMsg[M](trimmed)
|
|
}
|