Files
2026-07-13 12:49:10 +08:00

760 lines
24 KiB
Go

/*
* Copyright 2025 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 adk
import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"time"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
)
var (
// ErrExceedMaxRetries is returned when the maximum number of retries has been exceeded.
// Use errors.Is to check if an error is due to max retries being exceeded:
//
// if errors.Is(err, adk.ErrExceedMaxRetries) {
// // handle max retries exceeded
// }
//
// Use errors.As to extract the underlying RetryExhaustedError for the last error details:
//
// var retryErr *adk.RetryExhaustedError
// if errors.As(err, &retryErr) {
// fmt.Printf("last error was: %v\n", retryErr.LastErr)
// }
ErrExceedMaxRetries = errors.New("exceeds max retries")
)
// RetryExhaustedError is returned when all retry attempts have been exhausted.
// It wraps the last error that occurred during retry attempts.
type RetryExhaustedError struct {
LastErr error
TotalRetries int
}
func (e *RetryExhaustedError) Error() string {
if e.LastErr != nil {
return fmt.Sprintf("exceeds max retries: last error: %v", e.LastErr)
}
return "exceeds max retries"
}
func (e *RetryExhaustedError) Unwrap() error {
return ErrExceedMaxRetries
}
// WillRetryError is emitted when a retryable error occurs and a retry will be attempted.
// It allows end-users to observe retry events in real-time via AgentEvent.
//
// Field design rationale:
// - ErrStr (exported): Stores the error message string for Gob serialization during checkpointing.
// This ensures the error message is preserved after checkpoint restore.
// - err (unexported): Stores the original error for Unwrap() support at runtime.
// This field is intentionally unexported because Gob serialization would fail for unregistered
// concrete error types. Since end-users only need the original error when the AgentEvent first
// occurs (not after restoring from checkpoint), skipping serialization is acceptable.
// After checkpoint restore, err will be nil and Unwrap() returns nil.
// - rejectReason (unexported): Stores a user-defined value set by the ShouldRetry callback
// via RetryDecision.RejectReason. This is runtime-only observability data — after checkpoint
// restore it will be nil. Unexported to avoid Gob serialization of arbitrary types.
type WillRetryError struct {
ErrStr string
RetryAttempt int
rejectReason any
err error
}
func (e *WillRetryError) Error() string {
return e.ErrStr
}
func (e *WillRetryError) Unwrap() error {
return e.err
}
// RejectReason returns the user-defined rejection reason set by the ShouldRetry callback
// via RetryDecision.RejectReason. Returns nil if not set or after checkpoint restore.
func (e *WillRetryError) RejectReason() any {
return e.rejectReason
}
func init() {
schema.RegisterName[*WillRetryError]("eino_adk_chatmodel_will_retry_error")
}
// TypedRetryContext contains context information passed to TypedModelRetryConfig.ShouldRetry
// during a retry decision.
//
// State combinations for OutputMessage and Err:
//
// OutputMessage != nil, Err == nil → successful call; inspect message quality
// OutputMessage == nil, Err != nil → failed call (Generate error or Stream() error)
// OutputMessage != nil, Err != nil → partial stream (chunks received before mid-stream error)
// OutputMessage == nil, Err == nil → empty stream (zero chunks before EOF)
type TypedRetryContext[M MessageType] struct {
// RetryAttempt is the current retry attempt number (1-based).
// For the first retry decision (after the initial call), this is 1.
RetryAttempt int
// InputMessages is the input messages that were sent to the model for the current attempt.
InputMessages []M
// Options is the model options that were used for the current attempt.
Options []model.Option
// OutputMessage is the output message from the model, if any.
// This is non-nil when the model returned a message successfully.
// For streaming, this is the fully concatenated message (the entire stream is consumed
// before ShouldRetry is called).
// For streaming with mid-stream errors, this is the partial concatenation of chunks
// received before the error occurred.
// May be nil if the model returned an error without producing a message, or if the
// stream was empty (zero chunks before EOF).
OutputMessage M
// Err is the error from the model call, if any.
// May be nil if the model produced a message without error.
// Note: both OutputMessage and Err can be nil simultaneously for empty streams.
Err error
}
// RetryContext is the default retry context type using *schema.Message.
type RetryContext = TypedRetryContext[*schema.Message]
// TypedRetryDecision represents the decision made by TypedModelRetryConfig.ShouldRetry.
type TypedRetryDecision[M MessageType] struct {
// Retry indicates whether the model call should be retried.
// If false, the model output (or error) is accepted as-is, unless RewriteError is set.
Retry bool
// RewriteError, when non-nil, overrides the return value of the model call with this error.
// The agent run will fail with this error.
//
// This is useful for two scenarios:
// - When the model returns a "seemingly correct" message (no error) that actually
// contains unrecoverable issues. RewriteError converts the successful output
// into a fatal error.
// - When the model returns an error, but you want to replace it with a different,
// more descriptive error (e.g., adding context or wrapping).
//
// When Retry is true, RewriteError is ignored.
// When Retry is false and RewriteError is non-nil, the model call returns
// RewriteError regardless of whether the original call had an error or a message.
RewriteError error
// ModifiedInputMessages, when non-nil, replaces the input messages for the next retry.
//
// This enables advanced recovery strategies like context compression or message trimming.
// Only used when Retry is true. Ignored when Retry is false.
ModifiedInputMessages []M
// PersistModifiedInputMessages controls whether ModifiedInputMessages are written
// back to the agent's conversation history, affecting subsequent model calls in
// the agent loop (not just the next retry attempt).
//
// When true, the modified messages replace the current conversation history.
// When false (default), the modified messages are only used for the next retry attempt
// within this retry cycle.
//
// Only used when Retry is true and ModifiedInputMessages is non-nil.
PersistModifiedInputMessages bool
// AdditionalOptions, when non-nil, provides additional model options for the next retry.
// These options are appended to the existing options, taking precedence via last-wins semantics.
//
// This enables adjustments like increasing MaxTokens for the retry attempt.
// Note: options accumulate across retries within a single retry cycle. If ShouldRetry
// returns AdditionalOptions on every attempt, each set is appended to the previous ones.
// Only the last value for each option key takes effect, but earlier values remain in the slice.
// AdditionalOptions are scoped to the current retry cycle and do not persist to subsequent
// agent iterations — each new model call in the agent loop starts with the original options.
// Only used when Retry is true. Ignored when Retry is false.
AdditionalOptions []model.Option
// Backoff specifies the duration to wait before the next retry attempt.
// If zero, the default backoff function (from ModelRetryConfig.BackoffFunc or the
// built-in exponential backoff) is used.
//
// This allows the ShouldRetry callback to dynamically control retry timing based on
// the specific error or problematic message encountered.
// Only used when Retry is true. Ignored when Retry is false.
Backoff time.Duration
// RejectReason is an optional user-defined value describing why the output was rejected.
// When Retry is true and the rejected stream/message is observed downstream via
// AgentEvent, this value is attached to the WillRetryError emitted to the event stream.
// Consumers can retrieve it via WillRetryError.RejectReason().
//
// The ShouldRetry callback has full access to the model output (via retryCtx.OutputMessage)
// and error (via retryCtx.Err), so it can distill whatever information it wants into
// RejectReason — a string, a struct, the output message itself, or nil.
//
// Only used when Retry is true. Ignored when Retry is false.
RejectReason any
}
// RetryDecision is the default retry decision type using *schema.Message.
type RetryDecision = TypedRetryDecision[*schema.Message]
// TypedModelRetryConfig configures retry behavior for the ChatModel node.
// It defines how the agent should handle transient failures when calling the ChatModel.
type TypedModelRetryConfig[M MessageType] struct {
// MaxRetries specifies the maximum number of retry attempts.
// A value of 0 means no retries will be attempted.
// A value of 3 means up to 3 retry attempts (4 total calls including the initial attempt).
MaxRetries int
// ShouldRetry determines how to handle a model call result.
// It receives context information about the current attempt including the output message
// and/or error, and returns a decision on whether to retry, what to modify, etc.
// Returning nil is treated as &RetryDecision{Retry: false} (accept as-is).
//
// If nil, defaults to retrying on any non-nil error (backward compatible with IsRetryAble).
//
// Note: When ShouldRetry is set, IsRetryAble is ignored.
// Note: In streaming mode, the entire stream is consumed before ShouldRetry is called.
// The event stream is sent to the client in real time regardless; only the retry
// decision is deferred until the full response is available.
ShouldRetry func(ctx context.Context, retryCtx *TypedRetryContext[M]) *TypedRetryDecision[M]
// Deprecated: Use ShouldRetry instead for richer retry control including message
// inspection, input modification, and option adjustment. When ShouldRetry is set,
// IsRetryAble is ignored.
IsRetryAble func(ctx context.Context, err error) bool
// BackoffFunc calculates the delay before the next retry attempt.
// The attempt parameter starts at 1 for the first retry.
// Used as the default when RetryDecision.Backoff is zero.
// If nil, a default exponential backoff with jitter is used:
// base delay 100ms, exponentially increasing up to 10s max,
// with random jitter (0-50% of delay) to prevent thundering herd.
BackoffFunc func(ctx context.Context, attempt int) time.Duration
}
// ModelRetryConfig is the default retry config type using *schema.Message.
type ModelRetryConfig = TypedModelRetryConfig[*schema.Message]
func defaultIsRetryAble(_ context.Context, err error) bool {
return err != nil
}
func defaultBackoff(_ context.Context, attempt int) time.Duration {
baseDelay := 100 * time.Millisecond
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 genErrWrapper(ctx context.Context, maxRetries, attempt int, isRetryAbleFunc func(ctx context.Context, err error) bool) func(error) error {
return func(err error) error {
isRetryAble := isRetryAbleFunc == nil || isRetryAbleFunc(ctx, err)
hasRetriesLeft := attempt < maxRetries
if isRetryAble && hasRetriesLeft {
return &WillRetryError{ErrStr: err.Error(), RetryAttempt: attempt, err: err}
}
return err
}
}
func consumeStreamForError[M any](stream *schema.StreamReader[M]) error {
defer stream.Close()
for {
_, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
}
}
type retryVerdictSignal struct {
ch chan retryVerdict
}
type retryVerdict struct {
WillRetry bool
RetryAttempt int
Err error
RejectReason any
}
// retryModelWrapper wraps a BaseChatModel with retry logic.
// This is used inside the model wrapper chain, positioned between eventSenderModelWrapper
// and stateModelWrapper, so that retry only affects the inner chain (event sending, user wrappers,
// callback injection) without re-running state management (BeforeModelRewriteState/AfterModelRewriteState).
type typedRetryModelWrapper[M MessageType] struct {
inner model.BaseModel[M]
config *TypedModelRetryConfig[M]
}
func newTypedRetryModelWrapper[M MessageType](inner model.BaseModel[M], config *TypedModelRetryConfig[M]) *typedRetryModelWrapper[M] {
return &typedRetryModelWrapper[M]{inner: inner, config: config}
}
func (r *typedRetryModelWrapper[M]) Generate(ctx context.Context, input []M, opts ...model.Option) (M, error) {
if r.config.ShouldRetry != nil {
return generateWithShouldRetry(r, ctx, input, opts...)
}
return r.generateLegacy(ctx, input, opts...)
}
func (r *typedRetryModelWrapper[M]) generateLegacy(ctx context.Context, input []M, opts ...model.Option) (zero M, _ error) {
isRetryAble := r.config.IsRetryAble
if isRetryAble == nil {
isRetryAble = defaultIsRetryAble
}
backoffFunc := r.config.BackoffFunc
if backoffFunc == nil {
backoffFunc = defaultBackoff
}
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
out, err := r.inner.Generate(ctx, input, opts...)
if err == nil {
return out, nil
}
if _, ok := compose.ExtractInterruptInfo(err); ok {
return zero, err
}
if errors.Is(err, ErrStreamCanceled) {
return zero, err
}
if !isRetryAble(ctx, err) {
return zero, err
}
lastErr = err
if attempt < r.config.MaxRetries {
if err := r.contextAwareSleep(ctx, backoffFunc(ctx, attempt+1)); err != nil {
return zero, err
}
}
}
return zero, &RetryExhaustedError{LastErr: lastErr, TotalRetries: r.config.MaxRetries}
}
func generateWithShouldRetry[M MessageType](r *typedRetryModelWrapper[M], ctx context.Context, input []M, opts ...model.Option) (M, error) {
backoffFunc := r.config.BackoffFunc
if backoffFunc == nil {
backoffFunc = defaultBackoff
}
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
currentInput := input
currentOpts := opts
var lastErr error
var zero M
defer func() {
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
st.setRetryAttempt(0)
return nil
})
}()
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
st.setRetryAttempt(attempt)
return nil
})
// Suppress event sending during Generate: the ShouldRetry callback must decide whether
// to accept or reject the result before any event is emitted. If accepted, the event
// is sent explicitly below (lines after decision check). If rejected, no event leaks.
if execCtx != nil {
execCtx.suppressEventSend = true
}
out, err := r.inner.Generate(ctx, currentInput, currentOpts...)
if execCtx != nil {
execCtx.suppressEventSend = false
}
if err != nil {
if _, ok := compose.ExtractInterruptInfo(err); ok {
return zero, err
}
if errors.Is(err, ErrStreamCanceled) {
return zero, err
}
}
retryCtx := &TypedRetryContext[M]{
RetryAttempt: attempt + 1,
InputMessages: currentInput,
Options: currentOpts,
OutputMessage: out,
Err: err,
}
decision := r.config.ShouldRetry(ctx, retryCtx)
if decision == nil {
decision = &TypedRetryDecision[M]{}
}
if !decision.Retry {
if decision.RewriteError != nil {
return zero, decision.RewriteError
}
if err != nil {
return zero, err
}
if execCtx != nil && execCtx.generator != nil && out != nil {
event := typedModelOutputEvent(out, nil)
execCtx.send(event)
}
return out, nil
}
lastErr = err
if lastErr == nil {
lastErr = fmt.Errorf("model output rejected by ShouldRetry at attempt %d", attempt+1)
}
if attempt >= r.config.MaxRetries {
break
}
applyDecisionForRetry(&currentInput, &currentOpts, ctx, decision)
delay := decision.Backoff
if delay == 0 {
delay = backoffFunc(ctx, attempt+1)
}
if err := r.contextAwareSleep(ctx, delay); err != nil {
return zero, err
}
}
return zero, &RetryExhaustedError{LastErr: lastErr, TotalRetries: r.config.MaxRetries}
}
func (r *typedRetryModelWrapper[M]) contextAwareSleep(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
return nil
}
}
func streamWithShouldRetry[M MessageType](r *typedRetryModelWrapper[M], ctx context.Context, input []M, opts ...model.Option) (
*schema.StreamReader[M], error) {
backoffFunc := r.config.BackoffFunc
if backoffFunc == nil {
backoffFunc = defaultBackoff
}
defer func() {
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
st.setRetryAttempt(0)
return nil
})
}()
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
currentInput := input
currentOpts := opts
var lastErr error
var curSignal *retryVerdictSignal
// Panic recovery for verdict signal: if ShouldRetry panics, the onEOF/errWrapper closures in
// buildStreamConvertOptions will block forever on signal.ch, causing a goroutine leak. This
// defer ensures a verdict is always sent, even on panic, before re-panicking.
defer func() {
if p := recover(); p != nil {
if curSignal != nil {
select {
case curSignal.ch <- retryVerdict{WillRetry: false, Err: fmt.Errorf("panic: %v", p)}:
default:
}
}
panic(p)
}
}()
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
st.setRetryAttempt(attempt)
return nil
})
signal := &retryVerdictSignal{ch: make(chan retryVerdict, 1)}
curSignal = signal
if execCtx != nil {
execCtx.retryVerdictSignal = signal
}
stream, err := r.inner.Stream(ctx, currentInput, currentOpts...)
if err != nil {
// Defensive no-op: when Stream() returns an error, no stream exists, so
// eventSenderModel never creates the StreamReaderWithConvert hooks that would
// read from signal.ch. This send has no consumer — it merely fills the
// buffered(1) slot so the panic-recovery defer (select/default) won't block
// if a later panic tries to send a second verdict. The signal is discarded
// when the next iteration creates a new one.
signal.ch <- retryVerdict{WillRetry: false}
if _, ok := compose.ExtractInterruptInfo(err); ok {
return nil, err
}
if errors.Is(err, ErrStreamCanceled) {
return nil, err
}
retryCtx := &TypedRetryContext[M]{
RetryAttempt: attempt + 1,
InputMessages: currentInput,
Options: currentOpts,
Err: err,
}
decision := r.config.ShouldRetry(ctx, retryCtx)
if decision == nil {
decision = &TypedRetryDecision[M]{}
}
if !decision.Retry {
if decision.RewriteError != nil {
return nil, decision.RewriteError
}
return nil, err
}
lastErr = err
if attempt < r.config.MaxRetries {
applyDecisionForRetry(&currentInput, &currentOpts, ctx, decision)
delay := decision.Backoff
if delay == 0 {
delay = backoffFunc(ctx, attempt+1)
}
if err := r.contextAwareSleep(ctx, delay); err != nil {
return nil, err
}
}
continue
}
// Split the stream: checkCopy is consumed synchronously here to build the complete
// message for ShouldRetry inspection; returnCopy is returned to the caller and may
// already be consumed downstream in parallel. The verdict signal bridges the two:
// once ShouldRetry decides, the signal tells returnCopy's errWrapper/onEOF whether
// to pass through normally or inject a WillRetryError.
copies := stream.Copy(2)
checkCopy := copies[0]
returnCopy := copies[1]
msg, streamErr := typedConsumeStream(checkCopy)
if errors.Is(streamErr, ErrStreamCanceled) {
signal.ch <- retryVerdict{WillRetry: false}
returnCopy.Close()
return nil, streamErr
}
retryCtx := &TypedRetryContext[M]{
RetryAttempt: attempt + 1,
InputMessages: currentInput,
Options: currentOpts,
OutputMessage: msg,
Err: streamErr,
}
decision := r.config.ShouldRetry(ctx, retryCtx)
if decision == nil {
decision = &TypedRetryDecision[M]{}
}
if !decision.Retry {
signal.ch <- retryVerdict{WillRetry: false}
if decision.RewriteError != nil {
returnCopy.Close()
return nil, decision.RewriteError
}
if streamErr != nil {
returnCopy.Close()
return nil, streamErr
}
return returnCopy, nil
}
verdictErr := streamErr
if verdictErr == nil {
verdictErr = fmt.Errorf("model output rejected by ShouldRetry at attempt %d", attempt+1)
}
signal.ch <- retryVerdict{
WillRetry: true,
RetryAttempt: attempt,
Err: verdictErr,
RejectReason: decision.RejectReason,
}
returnCopy.Close()
lastErr = verdictErr
if attempt < r.config.MaxRetries {
applyDecisionForRetry(&currentInput, &currentOpts, ctx, decision)
delay := decision.Backoff
if delay == 0 {
delay = backoffFunc(ctx, attempt+1)
}
if err := r.contextAwareSleep(ctx, delay); err != nil {
return nil, err
}
}
}
return nil, &RetryExhaustedError{LastErr: lastErr, TotalRetries: r.config.MaxRetries}
}
func applyDecisionForRetry[M MessageType](currentInput *[]M, currentOpts *[]model.Option, ctx context.Context, decision *TypedRetryDecision[M]) {
if decision.ModifiedInputMessages != nil {
*currentInput = decision.ModifiedInputMessages
if decision.PersistModifiedInputMessages {
modifiedInput := *currentInput
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
st.Messages = modifiedInput
return nil
})
}
}
if decision.AdditionalOptions != nil {
cloned := make([]model.Option, len(*currentOpts), len(*currentOpts)+len(decision.AdditionalOptions))
copy(cloned, *currentOpts)
*currentOpts = append(cloned, decision.AdditionalOptions...)
}
}
func (r *typedRetryModelWrapper[M]) Stream(ctx context.Context, input []M, opts ...model.Option) (
*schema.StreamReader[M], error) {
if r.config.ShouldRetry != nil {
return streamWithShouldRetry(r, ctx, input, opts...)
}
return r.streamLegacy(ctx, input, opts...)
}
func (r *typedRetryModelWrapper[M]) streamLegacy(ctx context.Context, input []M, opts ...model.Option) (
*schema.StreamReader[M], error) {
isRetryAble := r.config.IsRetryAble
if isRetryAble == nil {
isRetryAble = defaultIsRetryAble
}
backoffFunc := r.config.BackoffFunc
if backoffFunc == nil {
backoffFunc = defaultBackoff
}
defer func() {
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
st.setRetryAttempt(0)
return nil
})
}()
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
st.setRetryAttempt(attempt)
return nil
})
stream, err := r.inner.Stream(ctx, input, opts...)
if err != nil {
if _, ok := compose.ExtractInterruptInfo(err); ok {
return nil, err
}
if errors.Is(err, ErrStreamCanceled) {
return nil, err
}
if !isRetryAble(ctx, err) {
return nil, err
}
lastErr = err
if attempt < r.config.MaxRetries {
if err := r.contextAwareSleep(ctx, backoffFunc(ctx, attempt+1)); err != nil {
return nil, err
}
}
continue
}
copies := stream.Copy(2)
checkCopy := copies[0]
returnCopy := copies[1]
streamErr := consumeStreamForError(checkCopy)
if streamErr == nil {
return returnCopy, nil
}
returnCopy.Close()
if errors.Is(streamErr, ErrStreamCanceled) {
return nil, streamErr
}
if !isRetryAble(ctx, streamErr) {
return nil, streamErr
}
lastErr = streamErr
if attempt < r.config.MaxRetries {
if err := r.contextAwareSleep(ctx, backoffFunc(ctx, attempt+1)); err != nil {
return nil, err
}
}
}
return nil, &RetryExhaustedError{LastErr: lastErr, TotalRetries: r.config.MaxRetries}
}