1449 lines
47 KiB
Go
1449 lines
47 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"
|
|
"io"
|
|
"reflect"
|
|
"sync"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/cloudwego/eino/adk/internal"
|
|
"github.com/cloudwego/eino/callbacks"
|
|
"github.com/cloudwego/eino/components"
|
|
"github.com/cloudwego/eino/components/model"
|
|
"github.com/cloudwego/eino/components/tool"
|
|
"github.com/cloudwego/eino/compose"
|
|
"github.com/cloudwego/eino/internal/generic"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
type typedGenerateEndpoint[M MessageType] func(ctx context.Context, input []M, opts ...model.Option) (M, error)
|
|
type typedStreamEndpoint[M MessageType] func(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error)
|
|
|
|
type typedModelWrapperConfig[M MessageType] struct {
|
|
handlers []TypedChatModelAgentMiddleware[M]
|
|
middlewares []AgentMiddleware
|
|
retryConfig *TypedModelRetryConfig[M]
|
|
failoverConfig *ModelFailoverConfig[M]
|
|
toolInfos []*schema.ToolInfo
|
|
cancelContext *cancelContext
|
|
}
|
|
|
|
type modelWrapperConfig = typedModelWrapperConfig[*schema.Message]
|
|
|
|
func buildModelWrappers[M MessageType](m model.BaseModel[M], config *typedModelWrapperConfig[M]) model.BaseModel[M] {
|
|
return buildModelWrappersImpl(m, config)
|
|
}
|
|
|
|
func buildModelWrappersImpl[M MessageType](m model.BaseModel[M], config *typedModelWrapperConfig[M]) model.BaseModel[M] {
|
|
var wrapped = m
|
|
|
|
if config.failoverConfig != nil {
|
|
wrapped = &typedFailoverProxyModel[M]{}
|
|
}
|
|
|
|
if !components.IsCallbacksEnabled(wrapped) {
|
|
wrapped = typedCallbackInjectionModelWrapper[M]{}.wrapModel(wrapped)
|
|
}
|
|
|
|
wrapped = &typedStateModelWrapper[M]{
|
|
inner: wrapped,
|
|
original: m,
|
|
handlers: config.handlers,
|
|
middlewares: config.middlewares,
|
|
toolInfos: config.toolInfos,
|
|
modelRetryConfig: config.retryConfig,
|
|
modelFailoverConfig: config.failoverConfig,
|
|
cancelContext: config.cancelContext,
|
|
}
|
|
|
|
return wrapped
|
|
}
|
|
|
|
type typedCallbackInjectionModelWrapper[M MessageType] struct{}
|
|
|
|
func (w typedCallbackInjectionModelWrapper[M]) wrapModel(m model.BaseModel[M]) model.BaseModel[M] {
|
|
return &typedCallbackInjectedModel[M]{inner: m}
|
|
}
|
|
|
|
type typedCallbackInjectedModel[M MessageType] struct {
|
|
inner model.BaseModel[M]
|
|
}
|
|
|
|
func (m *typedCallbackInjectedModel[M]) Generate(ctx context.Context, input []M, opts ...model.Option) (M, error) {
|
|
ctx = callbacks.OnStart(ctx, input)
|
|
result, err := m.inner.Generate(ctx, input, opts...)
|
|
if err != nil {
|
|
callbacks.OnError(ctx, err)
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
callbacks.OnEnd(ctx, result)
|
|
return result, nil
|
|
}
|
|
|
|
func (m *typedCallbackInjectedModel[M]) Stream(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
ctx = callbacks.OnStart(ctx, input)
|
|
result, err := m.inner.Stream(ctx, input, opts...)
|
|
if err != nil {
|
|
callbacks.OnError(ctx, err)
|
|
return nil, err
|
|
}
|
|
_, wrappedStream := callbacks.OnEndWithStreamOutput(ctx, result)
|
|
return wrappedStream, nil
|
|
}
|
|
|
|
func handlersToToolMiddlewares[M MessageType](handlers []TypedChatModelAgentMiddleware[M]) []compose.ToolMiddleware {
|
|
var middlewares []compose.ToolMiddleware
|
|
// Forward iteration: compose.wrapToolCall applies middlewares in reverse order
|
|
// (len-1 down to 0), so keeping the original handler order here means
|
|
// handlers[0] ends up outermost — matching the model wrapping convention.
|
|
for _, handler := range handlers {
|
|
|
|
m := compose.ToolMiddleware{}
|
|
|
|
h := handler
|
|
m.Invokable = func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
|
tCtx := &ToolContext{
|
|
Name: input.Name,
|
|
CallID: input.CallID,
|
|
}
|
|
wrappedEndpoint, err := h.WrapInvokableToolCall(
|
|
ctx,
|
|
func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
|
output, err := next(ctx, &compose.ToolInput{
|
|
Name: input.Name,
|
|
CallID: input.CallID,
|
|
Arguments: argumentsInJSON,
|
|
CallOptions: opts,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return output.Result, nil
|
|
},
|
|
tCtx,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := wrappedEndpoint(ctx, input.Arguments, input.CallOptions...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &compose.ToolOutput{Result: result}, nil
|
|
}
|
|
}
|
|
|
|
m.Streamable = func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
|
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
|
|
tCtx := &ToolContext{
|
|
Name: input.Name,
|
|
CallID: input.CallID,
|
|
}
|
|
wrappedEndpoint, err := h.WrapStreamableToolCall(
|
|
ctx,
|
|
func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (*schema.StreamReader[string], error) {
|
|
output, err := next(ctx, &compose.ToolInput{
|
|
Name: input.Name,
|
|
CallID: input.CallID,
|
|
Arguments: argumentsInJSON,
|
|
CallOptions: opts,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return output.Result, nil
|
|
},
|
|
tCtx,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := wrappedEndpoint(ctx, input.Arguments, input.CallOptions...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &compose.StreamToolOutput{Result: result}, nil
|
|
}
|
|
}
|
|
|
|
m.EnhancedInvokable = func(next compose.EnhancedInvokableToolEndpoint) compose.EnhancedInvokableToolEndpoint {
|
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.EnhancedInvokableToolOutput, error) {
|
|
tCtx := &ToolContext{
|
|
Name: input.Name,
|
|
CallID: input.CallID,
|
|
}
|
|
wrappedEndpoint, err := h.WrapEnhancedInvokableToolCall(
|
|
ctx,
|
|
func(ctx context.Context, toolArgument *schema.ToolArgument, opts ...tool.Option) (*schema.ToolResult, error) {
|
|
output, err := next(ctx, &compose.ToolInput{
|
|
Name: input.Name,
|
|
CallID: input.CallID,
|
|
Arguments: toolArgument.Text,
|
|
CallOptions: opts,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return output.Result, nil
|
|
},
|
|
tCtx,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := wrappedEndpoint(ctx, &schema.ToolArgument{Text: input.Arguments}, input.CallOptions...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &compose.EnhancedInvokableToolOutput{Result: result}, nil
|
|
}
|
|
}
|
|
|
|
m.EnhancedStreamable = func(next compose.EnhancedStreamableToolEndpoint) compose.EnhancedStreamableToolEndpoint {
|
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.EnhancedStreamableToolOutput, error) {
|
|
tCtx := &ToolContext{
|
|
Name: input.Name,
|
|
CallID: input.CallID,
|
|
}
|
|
wrappedEndpoint, err := h.WrapEnhancedStreamableToolCall(
|
|
ctx,
|
|
func(ctx context.Context, toolArgument *schema.ToolArgument, opts ...tool.Option) (*schema.StreamReader[*schema.ToolResult], error) {
|
|
output, err := next(ctx, &compose.ToolInput{
|
|
Name: input.Name,
|
|
CallID: input.CallID,
|
|
Arguments: toolArgument.Text,
|
|
CallOptions: opts,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return output.Result, nil
|
|
},
|
|
tCtx,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := wrappedEndpoint(ctx, &schema.ToolArgument{Text: input.Arguments}, input.CallOptions...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &compose.EnhancedStreamableToolOutput{Result: result}, nil
|
|
}
|
|
}
|
|
|
|
middlewares = append(middlewares, m)
|
|
}
|
|
return middlewares
|
|
}
|
|
|
|
type typedEventSenderModelWrapper[M MessageType] struct {
|
|
*TypedBaseChatModelAgentMiddleware[M]
|
|
}
|
|
|
|
// NewEventSenderModelWrapper creates a ChatModelAgentMiddleware that sends model output as agent events.
|
|
func NewEventSenderModelWrapper() ChatModelAgentMiddleware {
|
|
return &typedEventSenderModelWrapper[*schema.Message]{
|
|
TypedBaseChatModelAgentMiddleware: &TypedBaseChatModelAgentMiddleware[*schema.Message]{},
|
|
}
|
|
}
|
|
|
|
func (w *typedEventSenderModelWrapper[M]) WrapModel(_ context.Context, m model.BaseModel[M], mc *TypedModelContext[M]) (model.BaseModel[M], error) {
|
|
inner := m
|
|
if mc != nil && mc.cancelContext != nil {
|
|
inner = &typedCancelMonitoredModel[M]{
|
|
inner: inner,
|
|
cancelContext: mc.cancelContext,
|
|
}
|
|
}
|
|
var retryConfig *TypedModelRetryConfig[M]
|
|
if mc != nil {
|
|
retryConfig = mc.ModelRetryConfig
|
|
}
|
|
var failoverConfig *ModelFailoverConfig[M]
|
|
if mc != nil {
|
|
failoverConfig = mc.ModelFailoverConfig
|
|
}
|
|
return &typedEventSenderModel[M]{inner: inner, modelRetryConfig: retryConfig, modelFailoverConfig: failoverConfig}, nil
|
|
}
|
|
|
|
type typedEventSenderModel[M MessageType] struct {
|
|
inner model.BaseModel[M]
|
|
modelRetryConfig *TypedModelRetryConfig[M]
|
|
modelFailoverConfig *ModelFailoverConfig[M]
|
|
}
|
|
|
|
func (m *typedEventSenderModel[M]) Generate(ctx context.Context, input []M, opts ...model.Option) (M, error) {
|
|
result, err := m.inner.Generate(ctx, input, opts...)
|
|
if err != nil {
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
if execCtx != nil && execCtx.suppressEventSend {
|
|
return result, nil
|
|
}
|
|
if execCtx == nil || execCtx.generator == nil {
|
|
var zero M
|
|
return zero, errors.New("generator is nil when sending event in Generate: ensure agent state is properly initialized")
|
|
}
|
|
|
|
event := typedModelOutputEvent(copyMessage(result), nil)
|
|
execCtx.send(event)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (m *typedEventSenderModel[M]) Stream(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
result, err := m.inner.Stream(ctx, input, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
if execCtx == nil || execCtx.generator == nil {
|
|
result.Close()
|
|
return nil, errors.New("generator is nil when sending event in Stream: ensure agent state is properly initialized")
|
|
}
|
|
|
|
streams := result.Copy(2)
|
|
|
|
eventStream := streams[0]
|
|
if convertOpts := m.buildStreamConvertOptions(ctx); len(convertOpts) > 0 {
|
|
eventStream = schema.StreamReaderWithConvert(streams[0],
|
|
func(msg M) (M, error) { return msg, nil },
|
|
convertOpts...)
|
|
}
|
|
|
|
var zero M
|
|
event := typedModelOutputEvent[M](zero, eventStream)
|
|
execCtx.send(event)
|
|
|
|
return streams[1], nil
|
|
}
|
|
|
|
// buildStreamConvertOptions constructs ConvertOption hooks that gate stream termination behind
|
|
// the retry verdict signal protocol.
|
|
//
|
|
// Verdict signal lifecycle:
|
|
// - streamWithShouldRetry creates a new retryVerdictSignal per retry attempt, stores it in
|
|
// execCtx.retryVerdictSignal, and sends exactly one retryVerdict after ShouldRetry decides.
|
|
// - The closures below capture a *retryVerdictSignal that is nil at closure-creation time; they
|
|
// read the live value from execCtx.retryVerdictSignal, which is set before each model call.
|
|
//
|
|
// Two hooks cooperate to cover all stream termination paths:
|
|
// - WithErrWrapper intercepts mid-stream errors. It blocks on the verdict to decide
|
|
// whether to wrap the error as WillRetryError (rejected attempt) or pass it through (accepted).
|
|
// - WithOnEOF intercepts clean EOF (successful stream). It blocks on the verdict to
|
|
// either inject a WillRetryError (rejected) or pass through io.EOF (accepted).
|
|
//
|
|
// Both hooks share a sync.Once-guarded reader so the verdict channel is read at most once.
|
|
// This prevents a goroutine leak when a mid-stream error is followed by EOF: errWrapper fires
|
|
// first (caching the verdict), and onEOF reuses the cached value instead of blocking on a
|
|
// drained channel.
|
|
func (m *typedEventSenderModel[M]) buildStreamConvertOptions(ctx context.Context) []schema.ConvertOption {
|
|
var retryAttempt int
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
retryAttempt = st.getRetryAttempt()
|
|
return nil
|
|
})
|
|
|
|
wrapWithCancelGuard := func(inner func(error) error) func(error) error {
|
|
return func(err error) error {
|
|
if errors.Is(err, ErrStreamCanceled) {
|
|
return err
|
|
}
|
|
return inner(err)
|
|
}
|
|
}
|
|
|
|
var opts []schema.ConvertOption
|
|
|
|
var retryWrapper func(error) error
|
|
if m.modelRetryConfig != nil {
|
|
if m.modelRetryConfig.ShouldRetry != nil {
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
signal := (*retryVerdictSignal)(nil)
|
|
if execCtx != nil {
|
|
signal = execCtx.retryVerdictSignal
|
|
}
|
|
if signal != nil {
|
|
var (
|
|
verdictOnce sync.Once
|
|
cachedVerdict retryVerdict
|
|
)
|
|
readVerdict := func() retryVerdict {
|
|
verdictOnce.Do(func() {
|
|
cachedVerdict = <-signal.ch
|
|
})
|
|
return cachedVerdict
|
|
}
|
|
|
|
retryWrapper = wrapWithCancelGuard(func(err error) error {
|
|
verdict := readVerdict()
|
|
if verdict.WillRetry {
|
|
return &WillRetryError{
|
|
ErrStr: err.Error(),
|
|
RetryAttempt: verdict.RetryAttempt,
|
|
rejectReason: verdict.RejectReason,
|
|
err: err,
|
|
}
|
|
}
|
|
return err
|
|
})
|
|
|
|
opts = append(opts, schema.WithOnEOF(func() (any, error) {
|
|
verdict := readVerdict()
|
|
if verdict.WillRetry {
|
|
return nil, &WillRetryError{
|
|
ErrStr: verdict.Err.Error(),
|
|
RetryAttempt: verdict.RetryAttempt,
|
|
rejectReason: verdict.RejectReason,
|
|
err: verdict.Err,
|
|
}
|
|
}
|
|
return nil, io.EOF
|
|
}))
|
|
}
|
|
} else {
|
|
retryWrapper = wrapWithCancelGuard(
|
|
genErrWrapper(ctx, m.modelRetryConfig.MaxRetries, retryAttempt, m.modelRetryConfig.IsRetryAble),
|
|
)
|
|
}
|
|
}
|
|
|
|
hasFailover := m.modelFailoverConfig != nil
|
|
// failoverHasMoreAttempts is set by failoverModelWrapper before each inner call.
|
|
// It is true when additional failover attempts remain after the current one,
|
|
// meaning stream errors should be wrapped as WillRetryError so the flow layer
|
|
// skips them. On the final attempt it is false, so the error propagates normally.
|
|
failoverHasMore := getFailoverHasMoreAttempts(ctx)
|
|
|
|
if retryWrapper == nil && !(hasFailover && failoverHasMore) {
|
|
return opts
|
|
}
|
|
|
|
combinedErrWrapper := func(err error) error {
|
|
// If retry is configured and will retry this error, use the retry wrapper's WillRetryError.
|
|
if retryWrapper != nil {
|
|
wrapped := retryWrapper(err)
|
|
if errors.As(wrapped, new(*WillRetryError)) {
|
|
return wrapped
|
|
}
|
|
}
|
|
// Retry won't handle this error (either exhausted or not configured), but
|
|
// failover still has more attempts remaining. Wrap it as WillRetryError so
|
|
// the flow layer skips this event from the failed attempt.
|
|
if hasFailover && failoverHasMore {
|
|
if errors.Is(err, ErrStreamCanceled) {
|
|
return err
|
|
}
|
|
return &WillRetryError{ErrStr: err.Error(), err: err}
|
|
}
|
|
return err
|
|
}
|
|
opts = append(opts, schema.WithErrWrapper(combinedErrWrapper))
|
|
|
|
return opts
|
|
}
|
|
|
|
func copyMessage[M MessageType](msg M) M {
|
|
switch v := any(msg).(type) {
|
|
case *schema.Message:
|
|
cp := *v
|
|
return any(&cp).(M)
|
|
case *schema.AgenticMessage:
|
|
cp := *v
|
|
return any(&cp).(M)
|
|
default:
|
|
return msg
|
|
}
|
|
}
|
|
|
|
// typedSetMessageID sets a specific message ID in Extra.
|
|
func typedSetMessageID[M MessageType](msg M, id string) {
|
|
switch v := any(msg).(type) {
|
|
case *schema.Message:
|
|
v.Extra = internal.SetMessageID(v.Extra, id)
|
|
case *schema.AgenticMessage:
|
|
v.Extra = internal.SetMessageID(v.Extra, id)
|
|
}
|
|
}
|
|
|
|
// GetMessageID returns the eino-internal message ID from the given message, or "".
|
|
func GetMessageID[M MessageType](msg M) string {
|
|
switch v := any(msg).(type) {
|
|
case *schema.Message:
|
|
return internal.GetMessageID(v.Extra)
|
|
case *schema.AgenticMessage:
|
|
return internal.GetMessageID(v.Extra)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// EnsureMessageID assigns a UUID v4 message ID if the message doesn't have one.
|
|
// Idempotent: if ID already set, no-op.
|
|
// Middleware authors should call this before SendEvent if they create messages.
|
|
func EnsureMessageID[M MessageType](msg M) {
|
|
switch v := any(msg).(type) {
|
|
case *schema.Message:
|
|
v.Extra = internal.EnsureMessageID(v.Extra)
|
|
case *schema.AgenticMessage:
|
|
v.Extra = internal.EnsureMessageID(v.Extra)
|
|
}
|
|
}
|
|
|
|
func typedPopToolGenAction[M MessageType](ctx context.Context, toolName string) *AgentAction {
|
|
toolCallID := compose.GetToolCallID(ctx)
|
|
|
|
var action *AgentAction
|
|
_ = compose.ProcessState(ctx, func(ctx context.Context, st *typedState[M]) error {
|
|
if len(toolCallID) > 0 {
|
|
if a := st.popToolGenAction(toolCallID); a != nil {
|
|
action = a
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if a := st.popToolGenAction(toolName); a != nil {
|
|
action = a
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return action
|
|
}
|
|
|
|
type typedEventSenderToolWrapper[M MessageType] struct {
|
|
*TypedBaseChatModelAgentMiddleware[M]
|
|
}
|
|
|
|
func (*typedEventSenderToolWrapper[M]) isEventSenderToolWrapper() {}
|
|
|
|
// eventSenderToolWrapperMarker enables cross-type detection of eventSenderToolWrapper
|
|
// in generic contexts. hasUserEventSenderToolWrapper[M] receives
|
|
// []TypedChatModelAgentMiddleware[M], so when M is *schema.AgenticMessage, a direct
|
|
// type assertion to *eventSenderToolWrapper (which implements the *schema.Message alias)
|
|
// would fail. The marker interface bridges this gap.
|
|
type eventSenderToolWrapperMarker interface{ isEventSenderToolWrapper() }
|
|
|
|
// NewEventSenderToolWrapper returns a ChatModelAgentMiddleware that sends tool result events.
|
|
// By default, the framework places this before all user middlewares (outermost), so events
|
|
// reflect the fully processed tool output. To control exactly where events are emitted,
|
|
// include this in ChatModelAgentConfig.Handlers at the desired position.
|
|
// When detected in Handlers, the framework skips the default event sender to avoid duplicates.
|
|
func NewEventSenderToolWrapper() ChatModelAgentMiddleware {
|
|
return newTypedEventSenderToolWrapper[*schema.Message]()
|
|
}
|
|
|
|
// newTypedEventSenderToolWrapper creates a typed event sender wrapper for the given message type.
|
|
// This is used internally to ensure the default event sender matches the agent's message type
|
|
// (e.g. *schema.AgenticMessage agents need an AgenticMessage-typed wrapper so that
|
|
// compose.ProcessState can access the correct state type).
|
|
func newTypedEventSenderToolWrapper[M MessageType]() *typedEventSenderToolWrapper[M] {
|
|
return &typedEventSenderToolWrapper[M]{
|
|
TypedBaseChatModelAgentMiddleware: &TypedBaseChatModelAgentMiddleware[M]{},
|
|
}
|
|
}
|
|
|
|
// textToFunctionToolResultBlocks wraps a plain text string into FunctionToolResultBlocks.
|
|
func textToFunctionToolResultBlocks(text string) []*schema.FunctionToolResultContentBlock {
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
return []*schema.FunctionToolResultContentBlock{
|
|
{Type: schema.FunctionToolResultContentBlockTypeText, Text: &schema.UserInputText{Text: text}},
|
|
}
|
|
}
|
|
|
|
// functionToolResultAgenticMessage constructs a function tool result message with AgenticRoleType "user".
|
|
func functionToolResultAgenticMessage(callID, name string, content []*schema.FunctionToolResultContentBlock) *schema.AgenticMessage {
|
|
return &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeUser,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.FunctionToolResult{
|
|
CallID: callID,
|
|
Name: name,
|
|
Content: content,
|
|
}),
|
|
},
|
|
}
|
|
}
|
|
|
|
func markAgenticMessageStreamingMeta(msg *schema.AgenticMessage, index int) {
|
|
if msg == nil {
|
|
return
|
|
}
|
|
for _, block := range msg.ContentBlocks {
|
|
if block == nil {
|
|
continue
|
|
}
|
|
block.StreamingMeta = &schema.StreamingMeta{Index: index}
|
|
}
|
|
}
|
|
|
|
func toolSearchResultAgenticMessage(callID, name string, tr *schema.ToolResult) (*schema.AgenticMessage, bool) {
|
|
if tr == nil || len(tr.Parts) != 1 {
|
|
return nil, false
|
|
}
|
|
|
|
part := tr.Parts[0]
|
|
if part.Type != schema.ToolPartTypeToolSearchResult || part.ToolSearchResult == nil {
|
|
return nil, false
|
|
}
|
|
|
|
return &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeUser,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.ToolSearchFunctionToolResult{
|
|
CallID: callID,
|
|
Name: name,
|
|
Result: part.ToolSearchResult,
|
|
}),
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func toolResultAgenticMessage(callID, name string, tr *schema.ToolResult) *schema.AgenticMessage {
|
|
if msg, ok := toolSearchResultAgenticMessage(callID, name, tr); ok {
|
|
return msg
|
|
}
|
|
return functionToolResultAgenticMessage(callID, name, toolResultToBlocks(tr))
|
|
}
|
|
|
|
// toolResultToBlocks converts a ToolResult's multimodal parts into FunctionToolResultBlocks.
|
|
// This preserves all media types (text, image, audio, video, file), unlike toolResultText
|
|
// which only extracts text.
|
|
func toolResultToBlocks(tr *schema.ToolResult) []*schema.FunctionToolResultContentBlock {
|
|
if tr == nil || len(tr.Parts) == 0 {
|
|
return nil
|
|
}
|
|
blocks := make([]*schema.FunctionToolResultContentBlock, 0, len(tr.Parts))
|
|
for _, p := range tr.Parts {
|
|
var block *schema.FunctionToolResultContentBlock
|
|
switch p.Type {
|
|
case schema.ToolPartTypeText:
|
|
block = &schema.FunctionToolResultContentBlock{
|
|
Type: schema.FunctionToolResultContentBlockTypeText,
|
|
Text: &schema.UserInputText{Text: p.Text},
|
|
Extra: p.Extra,
|
|
}
|
|
case schema.ToolPartTypeImage:
|
|
if p.Image != nil {
|
|
block = &schema.FunctionToolResultContentBlock{
|
|
Type: schema.FunctionToolResultContentBlockTypeImage,
|
|
Image: &schema.UserInputImage{
|
|
URL: derefString(p.Image.URL),
|
|
Base64Data: derefString(p.Image.Base64Data),
|
|
MIMEType: p.Image.MIMEType,
|
|
},
|
|
Extra: p.Extra,
|
|
}
|
|
}
|
|
case schema.ToolPartTypeAudio:
|
|
if p.Audio != nil {
|
|
block = &schema.FunctionToolResultContentBlock{
|
|
Type: schema.FunctionToolResultContentBlockTypeAudio,
|
|
Audio: &schema.UserInputAudio{
|
|
URL: derefString(p.Audio.URL),
|
|
Base64Data: derefString(p.Audio.Base64Data),
|
|
MIMEType: p.Audio.MIMEType,
|
|
},
|
|
Extra: p.Extra,
|
|
}
|
|
}
|
|
case schema.ToolPartTypeVideo:
|
|
if p.Video != nil {
|
|
block = &schema.FunctionToolResultContentBlock{
|
|
Type: schema.FunctionToolResultContentBlockTypeVideo,
|
|
Video: &schema.UserInputVideo{
|
|
URL: derefString(p.Video.URL),
|
|
Base64Data: derefString(p.Video.Base64Data),
|
|
MIMEType: p.Video.MIMEType,
|
|
},
|
|
Extra: p.Extra,
|
|
}
|
|
}
|
|
case schema.ToolPartTypeFile:
|
|
if p.File != nil {
|
|
block = &schema.FunctionToolResultContentBlock{
|
|
Type: schema.FunctionToolResultContentBlockTypeFile,
|
|
File: &schema.UserInputFile{
|
|
URL: derefString(p.File.URL),
|
|
Base64Data: derefString(p.File.Base64Data),
|
|
MIMEType: p.File.MIMEType,
|
|
},
|
|
Extra: p.Extra,
|
|
}
|
|
}
|
|
}
|
|
if block != nil {
|
|
blocks = append(blocks, block)
|
|
}
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
func derefString(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
// typedToolInvokeEvent constructs the tool result event for the invoke path,
|
|
// dispatching on M to create the correct message and event types.
|
|
func typedToolInvokeEvent[M MessageType](callID, toolName, result, toolMsgID string) *TypedAgentEvent[M] {
|
|
var zero M
|
|
switch any(zero).(type) {
|
|
case *schema.Message:
|
|
msg := schema.ToolMessage(result, callID, schema.WithToolName(toolName))
|
|
msg.Extra = internal.SetMessageID(msg.Extra, toolMsgID)
|
|
event := EventFromMessage(msg, nil, schema.Tool, toolName)
|
|
return any(event).(*TypedAgentEvent[M])
|
|
case *schema.AgenticMessage:
|
|
msg := functionToolResultAgenticMessage(callID, toolName, textToFunctionToolResultBlocks(result))
|
|
msg.Extra = internal.SetMessageID(msg.Extra, toolMsgID)
|
|
event := EventFromAgenticMessage(msg, nil, schema.AgenticRoleTypeUser)
|
|
return any(event).(*TypedAgentEvent[M])
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// typedToolStreamEvent constructs the tool result event for the stream path,
|
|
// dispatching on M to create the correct message stream and event types.
|
|
func typedToolStreamEvent[M MessageType](callID, toolName, toolMsgID string, stream *schema.StreamReader[string]) *TypedAgentEvent[M] {
|
|
var zero M
|
|
switch any(zero).(type) {
|
|
case *schema.Message:
|
|
first := true
|
|
cvt := func(in string) (Message, error) {
|
|
msg := schema.ToolMessage(in, callID, schema.WithToolName(toolName))
|
|
if first {
|
|
first = false
|
|
msg.Extra = internal.SetMessageID(msg.Extra, toolMsgID)
|
|
}
|
|
return msg, nil
|
|
}
|
|
msgStream := schema.StreamReaderWithConvert(stream, cvt)
|
|
event := EventFromMessage(nil, msgStream, schema.Tool, toolName)
|
|
return any(event).(*TypedAgentEvent[M])
|
|
case *schema.AgenticMessage:
|
|
first := true
|
|
cvt := func(in string) (*schema.AgenticMessage, error) {
|
|
msg := functionToolResultAgenticMessage(callID, toolName, textToFunctionToolResultBlocks(in))
|
|
markAgenticMessageStreamingMeta(msg, 0)
|
|
if first {
|
|
first = false
|
|
msg.Extra = internal.SetMessageID(msg.Extra, toolMsgID)
|
|
}
|
|
return msg, nil
|
|
}
|
|
msgStream := schema.StreamReaderWithConvert(stream, cvt)
|
|
event := EventFromAgenticMessage(nil, msgStream, schema.AgenticRoleTypeUser)
|
|
return any(event).(*TypedAgentEvent[M])
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// typedToolEnhancedInvokeEvent constructs the tool result event for the enhanced invoke path.
|
|
// For *schema.Message it builds a multimodal tool message; for *schema.AgenticMessage it
|
|
// uses the string content of the result (AgenticToolsNode only uses the string path).
|
|
func typedToolEnhancedInvokeEvent[M MessageType](callID, toolName, toolMsgID string, result *schema.ToolResult) (*TypedAgentEvent[M], error) {
|
|
var zero M
|
|
switch any(zero).(type) {
|
|
case *schema.Message:
|
|
msg := schema.ToolMessage("", callID, schema.WithToolName(toolName))
|
|
var err error
|
|
msg.UserInputMultiContent, err = result.ToMessageInputParts()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
msg.Extra = internal.SetMessageID(msg.Extra, toolMsgID)
|
|
event := EventFromMessage(msg, nil, schema.Tool, toolName)
|
|
return any(event).(*TypedAgentEvent[M]), nil
|
|
case *schema.AgenticMessage:
|
|
msg := toolResultAgenticMessage(callID, toolName, result)
|
|
msg.Extra = internal.SetMessageID(msg.Extra, toolMsgID)
|
|
event := EventFromAgenticMessage(msg, nil, schema.AgenticRoleTypeUser)
|
|
return any(event).(*TypedAgentEvent[M]), nil
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
// typedToolEnhancedStreamEvent constructs the tool result event for the enhanced stream path.
|
|
// For *schema.Message it builds multimodal tool messages; for *schema.AgenticMessage it
|
|
// converts each chunk's multimodal parts into FunctionToolResultBlocks.
|
|
func typedToolEnhancedStreamEvent[M MessageType](callID, toolName, toolMsgID string, stream *schema.StreamReader[*schema.ToolResult]) *TypedAgentEvent[M] {
|
|
var zero M
|
|
switch any(zero).(type) {
|
|
case *schema.Message:
|
|
first := true
|
|
cvt := func(in *schema.ToolResult) (Message, error) {
|
|
msg := schema.ToolMessage("", callID, schema.WithToolName(toolName))
|
|
var cvtErr error
|
|
msg.UserInputMultiContent, cvtErr = in.ToMessageInputParts()
|
|
if cvtErr != nil {
|
|
return nil, cvtErr
|
|
}
|
|
if first {
|
|
first = false
|
|
msg.Extra = internal.SetMessageID(msg.Extra, toolMsgID)
|
|
}
|
|
return msg, nil
|
|
}
|
|
msgStream := schema.StreamReaderWithConvert(stream, cvt)
|
|
event := EventFromMessage(nil, msgStream, schema.Tool, toolName)
|
|
return any(event).(*TypedAgentEvent[M])
|
|
case *schema.AgenticMessage:
|
|
first := true
|
|
cvt := func(in *schema.ToolResult) (*schema.AgenticMessage, error) {
|
|
msg := toolResultAgenticMessage(callID, toolName, in)
|
|
markAgenticMessageStreamingMeta(msg, 0)
|
|
if first {
|
|
first = false
|
|
msg.Extra = internal.SetMessageID(msg.Extra, toolMsgID)
|
|
}
|
|
return msg, nil
|
|
}
|
|
msgStream := schema.StreamReaderWithConvert(stream, cvt)
|
|
event := EventFromAgenticMessage(nil, msgStream, schema.AgenticRoleTypeUser)
|
|
return any(event).(*TypedAgentEvent[M])
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (w *typedEventSenderToolWrapper[M]) WrapInvokableToolCall(_ context.Context, endpoint InvokableToolCallEndpoint, tCtx *ToolContext) (InvokableToolCallEndpoint, error) {
|
|
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
|
result, err := endpoint(ctx, argumentsInJSON, opts...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
toolName := tCtx.Name
|
|
callID := tCtx.CallID
|
|
|
|
prePopAction := typedPopToolGenAction[M](ctx, toolName)
|
|
toolMsgID := uuid.NewString()
|
|
event := typedToolInvokeEvent[M](callID, toolName, result, toolMsgID)
|
|
if prePopAction != nil {
|
|
event.Action = prePopAction
|
|
}
|
|
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
st.setToolMsgID(toolName, callID, toolMsgID)
|
|
if st.getReturnDirectlyToolCallID() == callID {
|
|
st.setReturnDirectlyEvent(event)
|
|
} else {
|
|
execCtx.send(event)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
return result, nil
|
|
}, nil
|
|
}
|
|
|
|
func (w *typedEventSenderToolWrapper[M]) WrapStreamableToolCall(_ context.Context, endpoint StreamableToolCallEndpoint, tCtx *ToolContext) (StreamableToolCallEndpoint, error) {
|
|
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (*schema.StreamReader[string], error) {
|
|
result, err := endpoint(ctx, argumentsInJSON, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
toolName := tCtx.Name
|
|
callID := tCtx.CallID
|
|
|
|
prePopAction := typedPopToolGenAction[M](ctx, toolName)
|
|
streams := result.Copy(2)
|
|
|
|
toolMsgID := uuid.NewString()
|
|
event := typedToolStreamEvent[M](callID, toolName, toolMsgID, streams[0])
|
|
event.Action = prePopAction
|
|
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
st.setToolMsgID(toolName, callID, toolMsgID)
|
|
if st.getReturnDirectlyToolCallID() == callID {
|
|
st.setReturnDirectlyEvent(event)
|
|
} else {
|
|
execCtx.send(event)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
return streams[1], nil
|
|
}, nil
|
|
}
|
|
|
|
func (w *typedEventSenderToolWrapper[M]) WrapEnhancedInvokableToolCall(_ context.Context, endpoint EnhancedInvokableToolCallEndpoint, tCtx *ToolContext) (EnhancedInvokableToolCallEndpoint, error) {
|
|
return func(ctx context.Context, toolArgument *schema.ToolArgument, opts ...tool.Option) (*schema.ToolResult, error) {
|
|
result, err := endpoint(ctx, toolArgument, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
toolName := tCtx.Name
|
|
callID := tCtx.CallID
|
|
|
|
prePopAction := typedPopToolGenAction[M](ctx, toolName)
|
|
toolMsgID := uuid.NewString()
|
|
event, eventErr := typedToolEnhancedInvokeEvent[M](callID, toolName, toolMsgID, result)
|
|
if eventErr != nil {
|
|
return nil, eventErr
|
|
}
|
|
if prePopAction != nil {
|
|
event.Action = prePopAction
|
|
}
|
|
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
st.setToolMsgID(toolName, callID, toolMsgID)
|
|
if st.getReturnDirectlyToolCallID() == callID {
|
|
st.setReturnDirectlyEvent(event)
|
|
} else {
|
|
execCtx.send(event)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
return result, nil
|
|
}, nil
|
|
}
|
|
|
|
func (w *typedEventSenderToolWrapper[M]) WrapEnhancedStreamableToolCall(_ context.Context, endpoint EnhancedStreamableToolCallEndpoint, tCtx *ToolContext) (EnhancedStreamableToolCallEndpoint, error) {
|
|
return func(ctx context.Context, toolArgument *schema.ToolArgument, opts ...tool.Option) (*schema.StreamReader[*schema.ToolResult], error) {
|
|
result, err := endpoint(ctx, toolArgument, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
toolName := tCtx.Name
|
|
callID := tCtx.CallID
|
|
|
|
prePopAction := typedPopToolGenAction[M](ctx, toolName)
|
|
streams := result.Copy(2)
|
|
|
|
toolMsgID := uuid.NewString()
|
|
event := typedToolEnhancedStreamEvent[M](callID, toolName, toolMsgID, streams[0])
|
|
event.Action = prePopAction
|
|
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
st.setToolMsgID(toolName, callID, toolMsgID)
|
|
if st.getReturnDirectlyToolCallID() == callID {
|
|
st.setReturnDirectlyEvent(event)
|
|
} else {
|
|
execCtx.send(event)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
return streams[1], nil
|
|
}, nil
|
|
}
|
|
|
|
func hasUserEventSenderToolWrapper[M MessageType](handlers []TypedChatModelAgentMiddleware[M]) bool {
|
|
for _, handler := range handlers {
|
|
if _, ok := any(handler).(eventSenderToolWrapperMarker); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type typedStateModelWrapper[M MessageType] struct {
|
|
inner model.BaseModel[M]
|
|
original model.BaseModel[M]
|
|
handlers []TypedChatModelAgentMiddleware[M]
|
|
middlewares []AgentMiddleware
|
|
toolInfos []*schema.ToolInfo
|
|
modelRetryConfig *TypedModelRetryConfig[M]
|
|
modelFailoverConfig *ModelFailoverConfig[M]
|
|
cancelContext *cancelContext
|
|
}
|
|
|
|
type stateModelWrapper = typedStateModelWrapper[*schema.Message]
|
|
|
|
func (w *typedStateModelWrapper[M]) IsCallbacksEnabled() bool {
|
|
return true
|
|
}
|
|
|
|
func (w *typedStateModelWrapper[M]) GetType() string {
|
|
if typer, ok := any(w.original).(components.Typer); ok {
|
|
return typer.GetType()
|
|
}
|
|
return generic.ParseTypeName(reflect.ValueOf(w.original))
|
|
}
|
|
|
|
func (w *typedStateModelWrapper[M]) hasUserEventSender() bool {
|
|
for _, handler := range w.handlers {
|
|
if _, ok := any(handler).(*typedEventSenderModelWrapper[M]); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (w *typedStateModelWrapper[M]) wrapGenerateEndpoint(endpoint typedGenerateEndpoint[M]) typedGenerateEndpoint[M] {
|
|
// === ID Assignment layer (innermost, framework-controlled) ===
|
|
// Ensures model output has a message ID before any WrapModel handler or event sender sees it.
|
|
// Copies the result to avoid mutating a potentially shared pointer returned by the model.
|
|
{
|
|
realInner := endpoint
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (M, error) {
|
|
result, err := realInner(ctx, input, opts...)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if GetMessageID(result) == "" {
|
|
result = copyMessage(result)
|
|
EnsureMessageID(result)
|
|
}
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
hasUserEventSender := w.hasUserEventSender()
|
|
retryConfig := w.modelRetryConfig
|
|
failoverConfig := w.modelFailoverConfig
|
|
cc := w.cancelContext
|
|
|
|
for i := len(w.handlers) - 1; i >= 0; i-- {
|
|
handler := w.handlers[i]
|
|
innerEndpoint := endpoint
|
|
baseToolInfos := w.toolInfos
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (M, error) {
|
|
baseOpts := &model.Options{Tools: baseToolInfos}
|
|
commonOpts := model.GetCommonOptions(baseOpts, opts...)
|
|
mc := &TypedModelContext[M]{Tools: commonOpts.Tools, ModelRetryConfig: retryConfig, cancelContext: cc}
|
|
wrappedModel, err := handler.WrapModel(ctx, &typedEndpointModel[M]{generate: innerEndpoint}, mc)
|
|
if err != nil {
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
return wrappedModel.Generate(ctx, input, opts...)
|
|
}
|
|
}
|
|
|
|
if !hasUserEventSender {
|
|
innerEndpoint := endpoint
|
|
eventSender := &typedEventSenderModelWrapper[M]{
|
|
TypedBaseChatModelAgentMiddleware: &TypedBaseChatModelAgentMiddleware[M]{},
|
|
}
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (M, error) {
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
if execCtx == nil || execCtx.generator == nil {
|
|
return innerEndpoint(ctx, input, opts...)
|
|
}
|
|
mc := &TypedModelContext[M]{ModelRetryConfig: retryConfig, ModelFailoverConfig: failoverConfig, cancelContext: cc}
|
|
wrappedModel, err := eventSender.WrapModel(ctx, &typedEndpointModel[M]{generate: innerEndpoint}, mc)
|
|
if err != nil {
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
return wrappedModel.Generate(ctx, input, opts...)
|
|
}
|
|
}
|
|
|
|
if w.modelRetryConfig != nil {
|
|
innerEndpoint := endpoint
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (M, error) {
|
|
retryWrapper := newTypedRetryModelWrapper[M](&typedEndpointModel[M]{generate: innerEndpoint}, w.modelRetryConfig)
|
|
return retryWrapper.Generate(ctx, input, opts...)
|
|
}
|
|
}
|
|
|
|
if w.modelFailoverConfig != nil {
|
|
config := w.modelFailoverConfig
|
|
innerEndpoint := endpoint
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (M, error) {
|
|
failoverWrapper := newFailoverModelWrapper[M](&typedEndpointModel[M]{generate: innerEndpoint}, config)
|
|
return failoverWrapper.Generate(ctx, input, opts...)
|
|
}
|
|
}
|
|
|
|
return endpoint
|
|
}
|
|
|
|
func (w *typedStateModelWrapper[M]) wrapStreamEndpoint(endpoint typedStreamEndpoint[M]) typedStreamEndpoint[M] {
|
|
// === ID Assignment layer (innermost, framework-controlled) ===
|
|
// Pre-allocates a UUID and injects it into the first chunk only.
|
|
// Only the first chunk carries the ID in Extra to avoid concatStrings corruption
|
|
// during ConcatMessages (which string-concatenates duplicate Extra keys).
|
|
{
|
|
realInner := endpoint
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
reader, err := realInner(ctx, input, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
msgID := uuid.NewString()
|
|
first := true
|
|
return schema.StreamReaderWithConvert(reader, func(msg M) (M, error) {
|
|
if first {
|
|
first = false
|
|
if GetMessageID(msg) == "" {
|
|
typedSetMessageID(msg, msgID)
|
|
}
|
|
}
|
|
return msg, nil
|
|
}), nil
|
|
}
|
|
}
|
|
|
|
hasUserEventSender := w.hasUserEventSender()
|
|
retryConfig := w.modelRetryConfig
|
|
failoverConfig := w.modelFailoverConfig
|
|
cc := w.cancelContext
|
|
|
|
for i := len(w.handlers) - 1; i >= 0; i-- {
|
|
handler := w.handlers[i]
|
|
innerEndpoint := endpoint
|
|
baseToolInfos := w.toolInfos
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
baseOpts := &model.Options{Tools: baseToolInfos}
|
|
commonOpts := model.GetCommonOptions(baseOpts, opts...)
|
|
mc := &TypedModelContext[M]{Tools: commonOpts.Tools, ModelRetryConfig: retryConfig, cancelContext: cc}
|
|
wrappedModel, err := handler.WrapModel(ctx, &typedEndpointModel[M]{stream: innerEndpoint}, mc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return wrappedModel.Stream(ctx, input, opts...)
|
|
}
|
|
}
|
|
|
|
if !hasUserEventSender {
|
|
innerEndpoint := endpoint
|
|
eventSender := &typedEventSenderModelWrapper[M]{
|
|
TypedBaseChatModelAgentMiddleware: &TypedBaseChatModelAgentMiddleware[M]{},
|
|
}
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
execCtx := getTypedChatModelAgentExecCtx[M](ctx)
|
|
if execCtx == nil || execCtx.generator == nil {
|
|
return innerEndpoint(ctx, input, opts...)
|
|
}
|
|
mc := &TypedModelContext[M]{ModelRetryConfig: retryConfig, ModelFailoverConfig: failoverConfig, cancelContext: cc}
|
|
wrappedModel, err := eventSender.WrapModel(ctx, &typedEndpointModel[M]{stream: innerEndpoint}, mc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return wrappedModel.Stream(ctx, input, opts...)
|
|
}
|
|
}
|
|
|
|
if w.modelRetryConfig != nil {
|
|
innerEndpoint := endpoint
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
retryWrapper := newTypedRetryModelWrapper[M](&typedEndpointModel[M]{stream: innerEndpoint}, w.modelRetryConfig)
|
|
return retryWrapper.Stream(ctx, input, opts...)
|
|
}
|
|
}
|
|
|
|
if w.modelFailoverConfig != nil {
|
|
config := w.modelFailoverConfig
|
|
innerEndpoint := endpoint
|
|
endpoint = func(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
failoverWrapper := newFailoverModelWrapper[M](&typedEndpointModel[M]{stream: innerEndpoint}, config)
|
|
return failoverWrapper.Stream(ctx, input, opts...)
|
|
}
|
|
}
|
|
|
|
return endpoint
|
|
}
|
|
|
|
func (w *typedStateModelWrapper[M]) Generate(ctx context.Context, _ []M, opts ...model.Option) (M, error) {
|
|
var (
|
|
stateMessages []M
|
|
stateToolInfos []*schema.ToolInfo
|
|
stateDeferredToolInfos []*schema.ToolInfo
|
|
)
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
stateMessages = st.Messages
|
|
stateToolInfos = st.ToolInfos
|
|
stateDeferredToolInfos = st.DeferredToolInfos
|
|
return nil
|
|
})
|
|
|
|
// Backfill: old checkpoints or fresh starts have nil ToolInfos.
|
|
// Use compose-level tools from opts (which always reflects the latest bc.toolInfos)
|
|
// rather than w.toolInfos (which may be stale if the graph was reused).
|
|
if stateToolInfos == nil {
|
|
composeLevelOpts := model.GetCommonOptions(&model.Options{}, opts...)
|
|
if composeLevelOpts.Tools != nil {
|
|
stateToolInfos = composeLevelOpts.Tools
|
|
} else {
|
|
stateToolInfos = w.toolInfos
|
|
}
|
|
}
|
|
|
|
state := &TypedChatModelAgentState[M]{
|
|
Messages: stateMessages,
|
|
ToolInfos: stateToolInfos,
|
|
DeferredToolInfos: stateDeferredToolInfos,
|
|
}
|
|
|
|
if msgState, ok := any(state).(*ChatModelAgentState); ok {
|
|
for _, m := range w.middlewares {
|
|
if m.BeforeChatModel != nil {
|
|
if err := m.BeforeChatModel(ctx, msgState); err != nil {
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
baseOpts := &model.Options{Tools: w.toolInfos}
|
|
commonOpts := model.GetCommonOptions(baseOpts, opts...)
|
|
mc := &TypedModelContext[M]{Tools: commonOpts.Tools, ModelRetryConfig: w.modelRetryConfig, cancelContext: w.cancelContext}
|
|
for _, handler := range w.handlers {
|
|
var err error
|
|
ctx, state, err = handler.BeforeModelRewriteState(ctx, state, mc)
|
|
if err != nil {
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
}
|
|
|
|
// Persist state (including tool infos) after BeforeModelRewriteState.
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
st.Messages = state.Messages
|
|
st.ToolInfos = state.ToolInfos
|
|
st.DeferredToolInfos = state.DeferredToolInfos
|
|
return nil
|
|
})
|
|
|
|
// Derive model options from state. Append after caller opts so state takes precedence
|
|
// (model.GetCommonOptions applies left-to-right, last wins).
|
|
// Use explicit copy to avoid mutating the caller's opts slice.
|
|
derivedOpts := make([]model.Option, len(opts), len(opts)+2)
|
|
copy(derivedOpts, opts)
|
|
derivedOpts = append(derivedOpts, model.WithTools(state.ToolInfos))
|
|
if state.DeferredToolInfos != nil {
|
|
derivedOpts = append(derivedOpts, model.WithDeferredTools(state.DeferredToolInfos))
|
|
}
|
|
|
|
wrappedEndpoint := w.wrapGenerateEndpoint(w.inner.Generate)
|
|
result, err := wrappedEndpoint(ctx, state.Messages, derivedOpts...)
|
|
if err != nil {
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
|
|
// Re-read State.Messages after Generate completes: when ShouldRetry uses
|
|
// PersistModifiedInputMessages, applyDecisionForRetry writes modified messages to State.
|
|
// We must pick up those changes before appending the model result.
|
|
if w.modelRetryConfig != nil && w.modelRetryConfig.ShouldRetry != nil {
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
state.Messages = st.Messages
|
|
return nil
|
|
})
|
|
}
|
|
|
|
state.Messages = append(state.Messages, result)
|
|
|
|
for _, handler := range w.handlers {
|
|
ctx, state, err = handler.AfterModelRewriteState(ctx, state, mc)
|
|
if err != nil {
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
}
|
|
|
|
if msgState, ok := any(state).(*ChatModelAgentState); ok {
|
|
for _, m := range w.middlewares {
|
|
if m.AfterChatModel != nil {
|
|
if err := m.AfterChatModel(ctx, msgState); err != nil {
|
|
var zero M
|
|
return zero, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persist state (including tool infos) after AfterModelRewriteState.
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
st.Messages = state.Messages
|
|
st.ToolInfos = state.ToolInfos
|
|
st.DeferredToolInfos = state.DeferredToolInfos
|
|
return nil
|
|
})
|
|
|
|
if len(state.Messages) == 0 {
|
|
var zero M
|
|
return zero, errors.New("no messages left in state after model call")
|
|
}
|
|
return state.Messages[len(state.Messages)-1], nil
|
|
}
|
|
|
|
func (w *typedStateModelWrapper[M]) Stream(ctx context.Context, _ []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
var (
|
|
stateMessages []M
|
|
stateToolInfos []*schema.ToolInfo
|
|
stateDeferredToolInfos []*schema.ToolInfo
|
|
)
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
stateMessages = st.Messages
|
|
stateToolInfos = st.ToolInfos
|
|
stateDeferredToolInfos = st.DeferredToolInfos
|
|
return nil
|
|
})
|
|
|
|
// Backfill: old checkpoints or fresh starts have nil ToolInfos.
|
|
// Use compose-level tools from opts (which always reflects the latest bc.toolInfos)
|
|
// rather than w.toolInfos (which may be stale if the graph was reused).
|
|
if stateToolInfos == nil {
|
|
composeLevelOpts := model.GetCommonOptions(&model.Options{}, opts...)
|
|
if composeLevelOpts.Tools != nil {
|
|
stateToolInfos = composeLevelOpts.Tools
|
|
} else {
|
|
stateToolInfos = w.toolInfos
|
|
}
|
|
}
|
|
|
|
state := &TypedChatModelAgentState[M]{
|
|
Messages: stateMessages,
|
|
ToolInfos: stateToolInfos,
|
|
DeferredToolInfos: stateDeferredToolInfos,
|
|
}
|
|
|
|
if msgState, ok := any(state).(*ChatModelAgentState); ok {
|
|
for _, m := range w.middlewares {
|
|
if m.BeforeChatModel != nil {
|
|
if err := m.BeforeChatModel(ctx, msgState); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
baseOpts := &model.Options{Tools: w.toolInfos}
|
|
commonOpts := model.GetCommonOptions(baseOpts, opts...)
|
|
mc := &TypedModelContext[M]{Tools: commonOpts.Tools, ModelRetryConfig: w.modelRetryConfig, cancelContext: w.cancelContext}
|
|
for _, handler := range w.handlers {
|
|
var err error
|
|
ctx, state, err = handler.BeforeModelRewriteState(ctx, state, mc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Persist state (including tool infos) after BeforeModelRewriteState.
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
st.Messages = state.Messages
|
|
st.ToolInfos = state.ToolInfos
|
|
st.DeferredToolInfos = state.DeferredToolInfos
|
|
return nil
|
|
})
|
|
|
|
// Derive model options from state. Append after caller opts so state takes precedence
|
|
// (model.GetCommonOptions applies left-to-right, last wins).
|
|
// Use explicit copy to avoid mutating the caller's opts slice.
|
|
derivedOpts := make([]model.Option, len(opts), len(opts)+2)
|
|
copy(derivedOpts, opts)
|
|
derivedOpts = append(derivedOpts, model.WithTools(state.ToolInfos))
|
|
if state.DeferredToolInfos != nil {
|
|
derivedOpts = append(derivedOpts, model.WithDeferredTools(state.DeferredToolInfos))
|
|
}
|
|
|
|
wrappedEndpoint := w.wrapStreamEndpoint(w.inner.Stream)
|
|
stream, err := wrappedEndpoint(ctx, state.Messages, derivedOpts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := concatMessageStream(stream)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Re-read State.Messages after Stream completes: same rationale as in Generate above.
|
|
if w.modelRetryConfig != nil && w.modelRetryConfig.ShouldRetry != nil {
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
state.Messages = st.Messages
|
|
return nil
|
|
})
|
|
}
|
|
|
|
state.Messages = append(state.Messages, result)
|
|
|
|
for _, handler := range w.handlers {
|
|
ctx, state, err = handler.AfterModelRewriteState(ctx, state, mc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if msgState, ok := any(state).(*ChatModelAgentState); ok {
|
|
for _, m := range w.middlewares {
|
|
if m.AfterChatModel != nil {
|
|
if err := m.AfterChatModel(ctx, msgState); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persist state (including tool infos) after AfterModelRewriteState.
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *typedState[M]) error {
|
|
st.Messages = state.Messages
|
|
st.ToolInfos = state.ToolInfos
|
|
st.DeferredToolInfos = state.DeferredToolInfos
|
|
return nil
|
|
})
|
|
|
|
if len(state.Messages) == 0 {
|
|
return nil, errors.New("no messages left in state after model call")
|
|
}
|
|
return schema.StreamReaderFromArray([]M{state.Messages[len(state.Messages)-1]}), nil
|
|
}
|
|
|
|
type typedEndpointModel[M MessageType] struct {
|
|
generate typedGenerateEndpoint[M]
|
|
stream typedStreamEndpoint[M]
|
|
}
|
|
|
|
func (m *typedEndpointModel[M]) Generate(ctx context.Context, input []M, opts ...model.Option) (M, error) {
|
|
if m.generate != nil {
|
|
return m.generate(ctx, input, opts...)
|
|
}
|
|
var zero M
|
|
return zero, errors.New("generate endpoint not set")
|
|
}
|
|
|
|
func (m *typedEndpointModel[M]) Stream(ctx context.Context, input []M, opts ...model.Option) (*schema.StreamReader[M], error) {
|
|
if m.stream != nil {
|
|
return m.stream(ctx, input, opts...)
|
|
}
|
|
return nil, errors.New("stream endpoint not set")
|
|
}
|