cloudwego--eino
395 行
12 KiB
Go
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")
|
|
}
|