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

264 lines
8.4 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 patchtoolcalls provides a middleware that patches dangling tool calls in the message history.
package patchtoolcalls
import (
"context"
"fmt"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/internal"
"github.com/cloudwego/eino/schema"
)
// Config defines the configuration options for the patch tool calls middleware.
type Config struct {
// PatchedContentGenerator is an optional custom function to generate the content
// of patched tool messages. If not provided, a default message will be used.
//
// Parameters:
// - ctx: the context for the operation
// - toolName: the name of the tool that was called
// - toolCallID: the id of the tool call
//
// Returns:
// - string: the content to use for the patched tool message
// - error: any error that occurred during generation
PatchedContentGenerator func(ctx context.Context, toolName, toolCallID string) (string, error)
}
// NewTyped creates a new generic patch tool calls middleware.
//
// The middleware scans the message history before each model invocation and inserts
// placeholder tool messages for any tool calls that don't have corresponding responses.
func NewTyped[M adk.MessageType](_ context.Context, cfg *Config) (adk.TypedChatModelAgentMiddleware[M], error) {
if cfg == nil {
cfg = &Config{}
}
return &typedMiddleware[M]{
gen: cfg.PatchedContentGenerator,
}, nil
}
// New creates a new patch tool calls middleware with the given configuration.
//
// The middleware scans the message history before each model invocation and inserts
// placeholder tool messages for any tool calls that don't have corresponding responses.
func New(ctx context.Context, cfg *Config) (adk.ChatModelAgentMiddleware, error) {
return NewTyped[*schema.Message](ctx, cfg)
}
type typedMiddleware[M adk.MessageType] struct {
*adk.TypedBaseChatModelAgentMiddleware[M]
gen func(ctx context.Context, toolName, toolCallID string) (string, error)
}
func (m *typedMiddleware[M]) BeforeModelRewriteState(ctx context.Context, state *adk.TypedChatModelAgentState[M],
mc *adk.TypedModelContext[M],
) (context.Context, *adk.TypedChatModelAgentState[M], error) {
if len(state.Messages) == 0 {
return ctx, state, nil
}
var zero M
switch any(zero).(type) {
case *schema.Message:
return patchToolCallsForMessage(ctx, m.gen, any(state).(*adk.TypedChatModelAgentState[*schema.Message]), mc)
case *schema.AgenticMessage:
return patchToolCallsForAgenticMessage(ctx, m.gen, any(state).(*adk.TypedChatModelAgentState[*schema.AgenticMessage]), mc)
default:
panic("unreachable: unknown MessageType")
}
}
func patchToolCallsForMessage[M adk.MessageType](ctx context.Context,
gen func(ctx context.Context, toolName, toolCallID string) (string, error),
state *adk.TypedChatModelAgentState[*schema.Message],
_ *adk.TypedModelContext[M],
) (context.Context, *adk.TypedChatModelAgentState[M], error) {
patched := make([]*schema.Message, 0, len(state.Messages))
for i, msg := range state.Messages {
patched = append(patched, msg)
if msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
continue
}
for _, tc := range msg.ToolCalls {
if hasCorrespondingToolMessage(state.Messages[i+1:], tc.ID) {
continue
}
toolMsg, err := createPatchedToolMessage(ctx, gen, tc)
if err != nil {
return ctx, nil, err
}
patched = append(patched, toolMsg)
}
}
nState := *state
nState.Messages = patched
return ctx, any(&nState).(*adk.TypedChatModelAgentState[M]), nil
}
func patchToolCallsForAgenticMessage[M adk.MessageType](ctx context.Context,
gen func(ctx context.Context, toolName, toolCallID string) (string, error),
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
_ *adk.TypedModelContext[M],
) (context.Context, *adk.TypedChatModelAgentState[M], error) {
patched := make([]*schema.AgenticMessage, 0, len(state.Messages))
for i, msg := range state.Messages {
patched = append(patched, msg)
if msg.Role != schema.AgenticRoleTypeAssistant {
continue
}
// Collect tool call IDs from this assistant message.
var toolCalls []struct {
callID string
name string
}
for _, block := range msg.ContentBlocks {
if block != nil && block.Type == schema.ContentBlockTypeFunctionToolCall && block.FunctionToolCall != nil {
toolCalls = append(toolCalls, struct {
callID string
name string
}{callID: block.FunctionToolCall.CallID, name: block.FunctionToolCall.Name})
}
}
if len(toolCalls) == 0 {
continue
}
for _, tc := range toolCalls {
if hasCorrespondingAgenticToolResult(state.Messages[i+1:], tc.callID) {
continue
}
toolMsg, err := createPatchedAgenticToolMessage(ctx, gen, tc.name, tc.callID)
if err != nil {
return ctx, nil, err
}
patched = append(patched, toolMsg)
}
}
nState := *state
nState.Messages = patched
return ctx, any(&nState).(*adk.TypedChatModelAgentState[M]), nil
}
func hasCorrespondingToolMessage(messages []*schema.Message, toolCallID string) bool {
for _, msg := range messages {
// Only consider successive tool messages after the tool call message
if msg.Role != schema.Tool {
return false
}
if msg.ToolCallID == toolCallID {
return true
}
}
return false
}
func hasCorrespondingAgenticToolResult(messages []*schema.AgenticMessage, toolCallID string) bool {
for _, msg := range messages {
// Only consider successive tool messages after the tool call message
if msg.Role != schema.AgenticRoleTypeUser {
return false
}
hasToolResult := false
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
if block.Type == schema.ContentBlockTypeFunctionToolResult {
hasToolResult = true
if block.FunctionToolResult != nil && block.FunctionToolResult.CallID == toolCallID {
return true
}
}
if block.Type == schema.ContentBlockTypeToolSearchResult {
hasToolResult = true
if block.ToolSearchFunctionToolResult != nil && block.ToolSearchFunctionToolResult.CallID == toolCallID {
return true
}
}
}
if !hasToolResult {
return false
}
}
return false
}
func createPatchedToolMessage(ctx context.Context, gen func(ctx context.Context, toolName, toolCallID string) (string, error), tc schema.ToolCall) (*schema.Message, error) {
if gen != nil {
content, err := gen(ctx, tc.Function.Name, tc.ID)
if err != nil {
return nil, err
}
return schema.ToolMessage(content, tc.ID, schema.WithToolName(tc.Function.Name)), nil
}
tpl := internal.SelectPrompt(internal.I18nPrompts{
English: defaultPatchedToolMessageTemplate,
Chinese: defaultPatchedToolMessageTemplateChinese,
})
return schema.ToolMessage(fmt.Sprintf(tpl, tc.Function.Name, tc.ID), tc.ID, schema.WithToolName(tc.Function.Name)), nil
}
func createPatchedAgenticToolMessage(ctx context.Context, gen func(ctx context.Context, toolName, toolCallID string) (string, error), toolName, callID string) (*schema.AgenticMessage, error) {
var content string
if gen != nil {
var err error
content, err = gen(ctx, toolName, callID)
if err != nil {
return nil, err
}
} else {
tpl := internal.SelectPrompt(internal.I18nPrompts{
English: defaultPatchedToolMessageTemplate,
Chinese: defaultPatchedToolMessageTemplateChinese,
})
content = fmt.Sprintf(tpl, toolName, callID)
}
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.FunctionToolResult{
CallID: callID,
Name: toolName,
Content: []*schema.FunctionToolResultContentBlock{
{Type: schema.FunctionToolResultContentBlockTypeText, Text: &schema.UserInputText{Text: content}},
},
}),
},
}, nil
}
const (
defaultPatchedToolMessageTemplate = "Tool call %s with id %s was canceled - another message came in before it could be completed."
defaultPatchedToolMessageTemplateChinese = "工具调用 %s(ID 为 %s)已被取消——在其完成之前收到了另一条消息。"
)