项目文件夹

文件
2026-07-13 12:49:10 +08:00

395 行
12 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
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestNewTypedAgenticMessage(t *testing.T) {
ctx := context.Background()
mw, err := NewTyped[*schema.AgenticMessage](ctx, nil)
assert.NoError(t, err)
assert.NotNil(t, mw)
var _ adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] = mw
}
type testToolCall struct {
ID string
Name string
Arguments string
}
func makeUserMsg[M adk.MessageType](content string) M {
var zero M
switch any(zero).(type) {
case *schema.Message:
return any(schema.UserMessage(content)).(M)
case *schema.AgenticMessage:
return any(schema.UserAgenticMessage(content)).(M)
}
panic("unreachable")
}
func makeAssistantMsgWithToolCalls[M adk.MessageType](content string, toolCalls []testToolCall) M {
var zero M
switch any(zero).(type) {
case *schema.Message:
tcs := make([]schema.ToolCall, len(toolCalls))
for i, tc := range toolCalls {
tcs[i] = schema.ToolCall{ID: tc.ID, Function: schema.FunctionCall{Name: tc.Name, Arguments: tc.Arguments}}
}
return any(schema.AssistantMessage(content, tcs)).(M)
case *schema.AgenticMessage:
blocks := make([]*schema.ContentBlock, 0, len(toolCalls)+1)
if content != "" {
blocks = append(blocks, schema.NewContentBlock(&schema.AssistantGenText{Text: content}))
}
for _, tc := range toolCalls {
blocks = append(blocks, schema.NewContentBlock(&schema.FunctionToolCall{CallID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}))
}
return any(&schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: blocks,
}).(M)
}
panic("unreachable")
}
func makeToolResultMsg[M adk.MessageType](content string, callID string, toolName string) M {
var zero M
switch any(zero).(type) {
case *schema.Message:
return any(schema.ToolMessage(content, callID, schema.WithToolName(toolName))).(M)
case *schema.AgenticMessage:
return any(&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}},
},
}),
},
}).(M)
}
panic("unreachable")
}
func assertMsgContent[M adk.MessageType](t *testing.T, msg M, expectedContent string) {
t.Helper()
switch m := any(msg).(type) {
case *schema.Message:
assert.Equal(t, expectedContent, m.Content)
case *schema.AgenticMessage:
for _, block := range m.ContentBlocks {
if block.Type == schema.ContentBlockTypeFunctionToolResult && block.FunctionToolResult != nil {
for _, b := range block.FunctionToolResult.Content {
if b.Text != nil {
assert.Equal(t, expectedContent, b.Text.Text)
return
}
}
}
}
t.Errorf("no text content found in agentic message, expected %q", expectedContent)
}
}
func assertToolResultID[M adk.MessageType](t *testing.T, msg M, expectedID string) {
t.Helper()
switch m := any(msg).(type) {
case *schema.Message:
assert.Equal(t, expectedID, m.ToolCallID)
case *schema.AgenticMessage:
for _, block := range m.ContentBlocks {
if block.Type == schema.ContentBlockTypeFunctionToolResult && block.FunctionToolResult != nil {
assert.Equal(t, expectedID, block.FunctionToolResult.CallID)
return
}
}
t.Errorf("no tool result found in agentic message, expected call ID %q", expectedID)
}
}
func assertToolResultName[M adk.MessageType](t *testing.T, msg M, expectedName string) {
t.Helper()
switch m := any(msg).(type) {
case *schema.Message:
assert.Equal(t, expectedName, m.ToolName)
case *schema.AgenticMessage:
for _, block := range m.ContentBlocks {
if block.Type == schema.ContentBlockTypeFunctionToolResult && block.FunctionToolResult != nil {
assert.Equal(t, expectedName, block.FunctionToolResult.Name)
return
}
}
t.Errorf("no tool result found in agentic message, expected tool name %q", expectedName)
}
}
func testPatchToolCallsGeneric[M adk.MessageType](t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
config *Config
messages []M
wantLen int
checkPatchedAt int // index of the patched message to check (-1 if no check needed)
wantCallID string
wantToolName string
wantContent string
}{
{
name: "empty messages",
config: nil,
messages: nil,
wantLen: 0,
checkPatchedAt: -1,
},
{
name: "no tool calls to patch",
config: nil,
messages: []M{
makeUserMsg[M]("hello"),
makeAssistantMsgWithToolCalls[M]("hi there", nil),
},
wantLen: 2,
checkPatchedAt: -1,
},
{
name: "missing tool result",
config: nil,
messages: []M{
makeUserMsg[M]("hello"),
makeAssistantMsgWithToolCalls[M]("", []testToolCall{
{ID: "call_1", Name: "tool_a", Arguments: "{}"},
{ID: "call_2", Name: "tool_b", Arguments: "{}"},
}),
makeToolResultMsg[M]("result_a", "call_1", "tool_a"),
},
wantLen: 4,
checkPatchedAt: 2,
wantCallID: "call_2",
wantToolName: "tool_b",
wantContent: fmt.Sprintf(defaultPatchedToolMessageTemplate, "tool_b", "call_2"),
},
{
name: "custom content generator",
config: &Config{
PatchedContentGenerator: func(ctx context.Context, toolName, toolCallID string) (string, error) {
return fmt.Sprintf("123 %s %s", toolName, toolCallID), nil
},
},
messages: []M{
makeUserMsg[M]("hello"),
makeAssistantMsgWithToolCalls[M]("", []testToolCall{
{ID: "call_1", Name: "tool_a", Arguments: "{}"},
{ID: "call_2", Name: "tool_b", Arguments: "{}"},
}),
makeToolResultMsg[M]("result_a", "call_1", "tool_a"),
},
wantLen: 4,
checkPatchedAt: 2,
wantCallID: "call_2",
wantToolName: "tool_b",
wantContent: "123 tool_b call_2",
},
{
name: "two consecutive assistant messages with tool calls",
config: nil,
messages: []M{
makeUserMsg[M]("hello"),
makeAssistantMsgWithToolCalls[M]("", []testToolCall{
{ID: "call_1", Name: "tool_a", Arguments: "{}"},
}),
makeAssistantMsgWithToolCalls[M]("continued...", nil),
},
wantLen: 4,
checkPatchedAt: 2,
wantCallID: "call_1",
wantToolName: "tool_a",
wantContent: fmt.Sprintf(defaultPatchedToolMessageTemplate, "tool_a", "call_1"),
},
{
name: "assistant message followed by user message without tool result",
config: nil,
messages: []M{
makeUserMsg[M]("hello"),
makeAssistantMsgWithToolCalls[M]("", []testToolCall{
{ID: "call_1", Name: "tool_a", Arguments: "{}"},
}),
makeUserMsg[M]("continued..."),
},
wantLen: 4,
checkPatchedAt: 2,
wantCallID: "call_1",
wantToolName: "tool_a",
wantContent: fmt.Sprintf(defaultPatchedToolMessageTemplate, "tool_a", "call_1"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mw, err := NewTyped[M](ctx, tt.config)
assert.NoError(t, err)
state := &adk.TypedChatModelAgentState[M]{
Messages: tt.messages,
}
_, newState, err := mw.BeforeModelRewriteState(ctx, state, nil)
assert.NoError(t, err)
assert.Len(t, newState.Messages, tt.wantLen)
if tt.checkPatchedAt >= 0 && tt.checkPatchedAt < len(newState.Messages) {
patched := newState.Messages[tt.checkPatchedAt]
assertToolResultID(t, patched, tt.wantCallID)
assertToolResultName(t, patched, tt.wantToolName)
assertMsgContent(t, patched, tt.wantContent)
}
})
}
}
func TestPatchToolCallsGeneric(t *testing.T) {
t.Run("Message", testPatchToolCallsGeneric[*schema.Message])
t.Run("AgenticMessage", testPatchToolCallsGeneric[*schema.AgenticMessage])
}
func TestPatchToolCallsAgenticToolSearchResult(t *testing.T) {
ctx := context.Background()
mw, err := NewTyped[*schema.AgenticMessage](ctx, nil)
require.NoError(t, err)
messages := []*schema.AgenticMessage{
makeAssistantMsgWithToolCalls[*schema.AgenticMessage]("", []testToolCall{
{ID: "call_1", Name: "tool_search", Arguments: `{"query":"dynamic"}`},
}),
{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.ToolSearchFunctionToolResult{
CallID: "call_1",
Name: "tool_search",
Result: &schema.ToolSearchResult{Tools: []*schema.ToolInfo{
{Name: "dynamic_tool", Desc: "dynamic tool"},
}},
}),
},
},
}
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: messages}
_, newState, err := mw.BeforeModelRewriteState(ctx, state, nil)
require.NoError(t, err)
assert.Len(t, newState.Messages, 2)
assert.Equal(t, schema.ContentBlockTypeToolSearchResult, newState.Messages[1].ContentBlocks[0].Type)
}
// TestPatchToolCalls_NilFunctionToolCallInBlock verifies the middleware handles
// a ContentBlock with Type=FunctionToolCall but FunctionToolCall=nil without panicking.
func TestPatchToolCalls_NilFunctionToolCallInBlock(t *testing.T) {
ctx := context.Background()
mw, err := NewTyped[*schema.AgenticMessage](ctx, nil)
require.NoError(t, err)
msgs := []*schema.AgenticMessage{
schema.UserAgenticMessage("hello"),
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
{
Type: schema.ContentBlockTypeFunctionToolCall,
FunctionToolCall: nil, // nil despite type indicating tool call
},
schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "call_1",
Name: "real_tool",
}),
},
},
}
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: msgs}
_, newState, err := mw.BeforeModelRewriteState(ctx, state, nil)
assert.NoError(t, err)
assert.Len(t, newState.Messages, 3, "should patch call_1 but skip nil FunctionToolCall block")
patchMsg := newState.Messages[2]
assert.Equal(t, schema.AgenticRoleTypeUser, patchMsg.Role)
foundResult := false
for _, block := range patchMsg.ContentBlocks {
if block != nil && block.Type == schema.ContentBlockTypeFunctionToolResult &&
block.FunctionToolResult != nil && block.FunctionToolResult.CallID == "call_1" {
foundResult = true
}
}
assert.True(t, foundResult, "patched message should contain tool result for call_1")
}
// TestPatchToolCalls_AgenticMessage_NilBlockInUserMessage verifies the middleware handles
// a User Agentic Message with nil ContentBlock without panicking.
func TestPatchToolCalls_AgenticMessage_NilBlockInUserMessage(t *testing.T) {
ctx := context.Background()
mw, err := NewTyped[*schema.AgenticMessage](ctx, nil)
require.NoError(t, err)
msgs := []*schema.AgenticMessage{
schema.UserAgenticMessage("hello"),
makeAssistantMsgWithToolCalls[*schema.AgenticMessage]("", []testToolCall{
{ID: "call_1", Name: "tool_a", Arguments: "{}"},
}),
{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{
nil, // nil block to test robustness
},
},
}
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: msgs}
_, newState, err := mw.BeforeModelRewriteState(ctx, state, nil)
assert.NoError(t, err, "should not panic when encountering nil block in user message")
assert.Len(t, newState.Messages, 4, "should patch call_1 and insert tool response")
// Verify the patched message is inserted at index 2
patchMsg := newState.Messages[2]
assert.Equal(t, schema.AgenticRoleTypeUser, patchMsg.Role)
foundResult := false
for _, block := range patchMsg.ContentBlocks {
if block != nil && block.Type == schema.ContentBlockTypeFunctionToolResult &&
block.FunctionToolResult != nil && block.FunctionToolResult.CallID == "call_1" {
foundResult = true
break
}
}
assert.True(t, foundResult, "patched message should contain tool result for call_1")
}