Files
cloudwego--eino/flow/agent/react/react_test.go
T
2026-07-13 12:49:10 +08:00

823 lines
21 KiB
Go

/*
* Copyright 2024 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 react
import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"testing"
"github.com/bytedance/sonic"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent"
mockModel "github.com/cloudwego/eino/internal/mock/components/model"
"github.com/cloudwego/eino/schema"
template "github.com/cloudwego/eino/utils/callbacks"
)
func TestReact(t *testing.T) {
ctx := context.Background()
fakeTool := &fakeToolGreetForTest{
tarCount: 3,
}
info, err := fakeTool.Info(ctx)
assert.NoError(t, err)
ctrl := gomock.NewController(t)
cm := mockModel.NewMockChatModel(ctrl)
times := 0
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
times++
if times <= 2 {
info, _ := fakeTool.Info(ctx)
return schema.AssistantMessage("hello max",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: info.Name,
Arguments: fmt.Sprintf(`{"name": "%s", "hh": "123"}`, randStr()),
},
},
}),
nil
}
return schema.AssistantMessage("bye", nil), nil
}).AnyTimes()
cm.EXPECT().BindTools(gomock.Any()).Return(nil).AnyTimes()
a, err := NewAgent(ctx, &AgentConfig{
Model: cm,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{fakeTool},
},
MessageModifier: func(ctx context.Context, input []*schema.Message) []*schema.Message {
assert.Equal(t, len(input), times*2+1)
return input
},
MaxStep: 40,
})
assert.Nil(t, err)
out, err := a.Generate(ctx, []*schema.Message{
{
Role: schema.User,
Content: "Use greet tool to continuously say hello until you get a bye response, greet names in the following order: max, bob, alice, john, marry, joe, ken, lily, please start directly! please start directly! please start directly!",
},
}, agent.WithComposeOptions(compose.WithCallbacks(callbackForTest)))
assert.Nil(t, err)
if out != nil {
t.Log(out.Content)
}
// test return directly
times = 0
a, err = NewAgent(ctx, &AgentConfig{
Model: cm,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{fakeTool},
},
MessageModifier: func(ctx context.Context, input []*schema.Message) []*schema.Message {
assert.Equal(t, len(input), times*2+1)
return input
},
MaxStep: 40,
ToolReturnDirectly: map[string]struct{}{info.Name: {}},
})
assert.Nil(t, err)
out, err = a.Generate(ctx, []*schema.Message{
{
Role: schema.User,
Content: "Use greet tool to continuously say hello until you get a bye response, greet names in the following order: max, bob, alice, john, marry, joe, ken, lily, please start directly! please start directly! please start directly!",
},
}, agent.WithComposeOptions(compose.WithCallbacks(callbackForTest)))
assert.Nil(t, err)
if out != nil {
t.Log(out.Content)
}
}
func TestReactWithMessageRewriterAndModifier(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
// This test simulates a single Generate call with a long history.
// The MessageRewriter should shorten the history.
// The MessageModifier should add a system prompt.
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
// Check messages passed to the model.
// Expected: [system prompt, user: "message 2", assistant: "response 2"]
assert.Len(t, input, 3)
assert.Equal(t, schema.System, input[0].Role)
assert.Equal(t, "system prompt", input[0].Content)
assert.Equal(t, schema.User, input[1].Role)
assert.Equal(t, "message 2", input[1].Content)
assert.Equal(t, schema.Assistant, input[2].Role)
assert.Equal(t, "response 2", input[2].Content)
return schema.AssistantMessage("final response", nil), nil
}).Times(1)
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
ra, err := NewAgent(ctx, &AgentConfig{
ToolCallingModel: cm,
MessageRewriter: func(ctx context.Context, messages []*schema.Message) []*schema.Message {
// Keep only the last 2 messages if history is longer.
assert.Len(t, messages, 4) // user1, assistant1, user2, assistant2
if len(messages) > 2 {
return messages[len(messages)-2:]
}
return messages
},
MessageModifier: func(ctx context.Context, messages []*schema.Message) []*schema.Message {
// messages should be the result from rewriter
assert.Len(t, messages, 2) // user2, assistant2
// Add a system prompt
res := make([]*schema.Message, 0, len(messages)+1)
res = append(res, schema.SystemMessage("system prompt"))
res = append(res, messages...)
return res
},
})
assert.NoError(t, err)
// Simulate a conversation history
history := []*schema.Message{
schema.UserMessage("message 1"),
schema.AssistantMessage("response 1", nil),
schema.UserMessage("message 2"),
schema.AssistantMessage("response 2", nil),
}
// Run the react agent
finalMsg, err := ra.Generate(ctx, history)
assert.NoError(t, err)
assert.Equal(t, "final response", finalMsg.Content)
}
func TestReactStream(t *testing.T) {
ctx := context.Background()
fakeTool := &fakeToolGreetForTest{
tarCount: 20,
}
fakeStreamTool := &fakeStreamToolGreetForTest{
tarCount: 20,
}
ctrl := gomock.NewController(t)
cm := mockModel.NewMockChatModel(ctrl)
times := 0
cm.EXPECT().BindTools(gomock.Any()).Return(nil).AnyTimes()
cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (
*schema.StreamReader[*schema.Message], error) {
sr, sw := schema.Pipe[*schema.Message](1)
defer sw.Close()
info, _ := fakeTool.Info(ctx)
streamInfo, _ := fakeStreamTool.Info(ctx)
times++
if times <= 2 {
sw.Send(schema.AssistantMessage("hello max",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: info.Name,
Arguments: fmt.Sprintf(`{"name": "%s", "hh": "tool"}`, randStr()),
},
},
}),
nil)
return sr, nil
} else if times == 3 {
sw.Send(schema.AssistantMessage("hello max",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: streamInfo.Name,
Arguments: fmt.Sprintf(`{"name": "%s", "hh": "stream tool"}`, randStr()),
},
},
}),
nil)
return sr, nil
} else if times == 4 { // parallel tool call
sw.Send(schema.AssistantMessage("hello max",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: info.Name,
Arguments: fmt.Sprintf(`{"name": "%s", "hh": "tool"}`, randStr()),
},
},
{
ID: randStr(),
Function: schema.FunctionCall{
Name: streamInfo.Name,
Arguments: fmt.Sprintf(`{"name": "%s", "hh": "stream tool"}`, randStr()),
},
},
}),
nil)
return sr, nil
}
sw.Send(schema.AssistantMessage("bye", nil), nil)
return sr, nil
}).AnyTimes()
a, err := NewAgent(ctx, &AgentConfig{
Model: cm,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{fakeTool, fakeStreamTool},
},
MaxStep: 40,
})
assert.Nil(t, err)
out, err := a.Stream(ctx, []*schema.Message{
{
Role: schema.User,
Content: "Use greet tool to continuously say hello until you get a bye response, greet names in the following order: max, bob, alice, john, marry, joe, ken, lily, please start directly! please start directly! please start directly!",
},
}, agent.WithComposeOptions(compose.WithCallbacks(callbackForTest)))
if err != nil {
t.Fatal(err)
}
defer out.Close()
msgs := make([]*schema.Message, 0)
for {
msg, err := out.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
t.Fatal(err)
}
msgs = append(msgs, msg)
}
assert.Equal(t, 1, len(msgs))
msg, err := schema.ConcatMessages(msgs)
if err != nil {
t.Fatal(err)
}
t.Log(msg.Content)
info, err := fakeStreamTool.Info(ctx)
assert.NoError(t, err)
// test return directly
a, err = NewAgent(ctx, &AgentConfig{
Model: cm,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{fakeTool, fakeStreamTool},
},
MaxStep: 40,
ToolReturnDirectly: map[string]struct{}{info.Name: {}}, // one of the two tools is return directly
})
assert.Nil(t, err)
times = 0
out, err = a.Stream(ctx, []*schema.Message{
{
Role: schema.User,
Content: "Use greet tool to continuously say hello until you get a bye response, greet names in the following order: max, bob, alice, john, marry, joe, ken, lily, please start directly! please start directly! please start directly!",
},
}, agent.WithComposeOptions(compose.WithCallbacks(callbackForTest)))
if err != nil {
t.Fatal(err)
}
defer out.Close()
msgs = make([]*schema.Message, 0)
for {
msg, err := out.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
t.Fatal(err)
}
msgs = append(msgs, msg)
}
assert.Equal(t, 1, len(msgs))
msg, err = schema.ConcatMessages(msgs)
if err != nil {
t.Fatal(err)
}
t.Log(msg.Content)
// return directly tool call within parallel tool calls
out, err = a.Stream(ctx, []*schema.Message{
{
Role: schema.User,
Content: "Use greet tool to continuously say hello until you get a bye response, greet names in the following order: max, bob, alice, john, marry, joe, ken, lily, please start directly! please start directly! please start directly!",
},
}, agent.WithComposeOptions(compose.WithCallbacks(callbackForTest)))
assert.NoError(t, err)
defer out.Close()
msgs = make([]*schema.Message, 0)
for {
msg, err := out.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
assert.NoError(t, err)
}
msgs = append(msgs, msg)
}
assert.Equal(t, 1, len(msgs))
msg, err = schema.ConcatMessages(msgs)
assert.NoError(t, err)
t.Log("parallel tool call with return directly: ", msg.Content)
}
func TestReactWithModifier(t *testing.T) {
ctx := context.Background()
fakeTool := &fakeToolGreetForTest{}
ctrl := gomock.NewController(t)
cm := mockModel.NewMockChatModel(ctrl)
times := 0
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
times++
if times <= 2 {
info, _ := fakeTool.Info(ctx)
return schema.AssistantMessage("hello max",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: info.Name,
Arguments: fmt.Sprintf(`{"name": "%s", "hh": "123"}`, randStr()),
},
},
}),
nil
}
return schema.AssistantMessage("bye", nil), nil
}).AnyTimes()
cm.EXPECT().BindTools(gomock.Any()).Return(nil).AnyTimes()
a, err := NewAgent(ctx, &AgentConfig{
Model: cm,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{fakeTool},
},
MessageModifier: func(ctx context.Context, input []*schema.Message) []*schema.Message {
res := make([]*schema.Message, 0, len(input)+1)
res = append(res, schema.SystemMessage("you are a helpful assistant"))
res = append(res, input...)
return res
},
MaxStep: 40,
})
assert.Nil(t, err)
out, err := a.Generate(ctx, []*schema.Message{
{
Role: schema.User,
Content: "hello",
},
}, agent.WithComposeOptions(compose.WithCallbacks(callbackForTest)))
if err != nil {
t.Fatal(err)
}
if out != nil {
t.Log(out.Content)
}
}
func TestAgentInGraph(t *testing.T) {
t.Run("agent generate in chain", func(t *testing.T) {
ctx := context.Background()
fakeTool := &fakeToolGreetForTest{}
ctrl := gomock.NewController(t)
cm := mockModel.NewMockChatModel(ctrl)
times := 0
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
times += 1
if times <= 2 {
info, _ := fakeTool.Info(ctx)
return schema.AssistantMessage("hello max",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: info.Name,
Arguments: fmt.Sprintf(`{"name": "%s", "hh": "123"}`, randStr()),
},
},
}),
nil
}
return schema.AssistantMessage("bye", nil), nil
}).Times(3)
cm.EXPECT().BindTools(gomock.Any()).Return(nil).AnyTimes()
a, err := NewAgent(ctx, &AgentConfig{
Model: cm,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{fakeTool, &fakeStreamToolGreetForTest{}},
},
MaxStep: 40,
})
assert.Nil(t, err)
chain := compose.NewChain[[]*schema.Message, string]()
agentLambda, err := compose.AnyLambda(a.Generate, a.Stream, nil, nil)
assert.Nil(t, err)
chain.
AppendLambda(agentLambda).
AppendLambda(compose.InvokableLambda(func(ctx context.Context, input *schema.Message) (string, error) {
t.Log("got agent response: ", input.Content)
return input.Content, nil
}))
r, err := chain.Compile(ctx)
assert.Nil(t, err)
res, err := r.Invoke(ctx, []*schema.Message{{Role: schema.User, Content: "hello"}},
compose.WithCallbacks(callbackForTest))
assert.Nil(t, err)
t.Log(res)
})
t.Run("agent stream in chain", func(t *testing.T) {
fakeStreamTool := &fakeStreamToolGreetForTest{}
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockChatModel(ctrl)
times := 0
cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (
*schema.StreamReader[*schema.Message], error) {
sr, sw := schema.Pipe[*schema.Message](1)
defer sw.Close()
times += 1
if times <= 2 {
info, _ := fakeStreamTool.Info(ctx)
sw.Send(schema.AssistantMessage("hello max",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: info.Name,
Arguments: fmt.Sprintf(`{"name": "%s", "hh": "123"}`, randStr()),
},
},
}),
nil)
return sr, nil
}
sw.Send(schema.AssistantMessage("bye", nil), nil)
return sr, nil
}).Times(3)
cm.EXPECT().BindTools(gomock.Any()).Return(nil).AnyTimes()
a, err := NewAgent(ctx, &AgentConfig{
Model: cm,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{&fakeToolGreetForTest{}, fakeStreamTool},
},
MaxStep: 40,
})
assert.Nil(t, err)
chain := compose.NewChain[[]*schema.Message, string]()
agentGraph, opts := a.ExportGraph()
assert.Nil(t, err)
chain.
AppendGraph(agentGraph, opts...).
AppendLambda(compose.InvokableLambda(func(ctx context.Context, input *schema.Message) (string, error) {
t.Log("got agent response: ", input.Content)
return input.Content, nil
}))
r, err := chain.Compile(ctx)
assert.Nil(t, err)
outStream, err := r.Stream(ctx, []*schema.Message{{Role: schema.User, Content: "hello"}},
compose.WithCallbacks(callbackForTest))
if err != nil {
t.Fatal(err)
}
defer outStream.Close()
msg := ""
for {
msgItem, err := outStream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
t.Fatal(err)
}
msg += msgItem
}
t.Log(msg)
})
}
func TestWithTools(t *testing.T) {
ctx := context.Background()
fakeTool := &fakeToolGreetForTest{
tarCount: 2,
}
fakeStreamTool := &fakeStreamToolGreetForTest{
tarCount: 2,
}
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
times := 0
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
times++
if times <= 1 {
info, _ := fakeTool.Info(ctx)
return schema.AssistantMessage("calling tool",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: info.Name,
Arguments: `{"name": "test"}`,
},
},
}),
nil
}
return schema.AssistantMessage("done", nil), nil
}).AnyTimes()
cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (
*schema.StreamReader[*schema.Message], error) {
sr, sw := schema.Pipe[*schema.Message](1)
defer sw.Close()
times++
if times <= 2 {
info, _ := fakeStreamTool.Info(ctx)
sw.Send(schema.AssistantMessage("calling stream tool",
[]schema.ToolCall{
{
ID: randStr(),
Function: schema.FunctionCall{
Name: info.Name,
Arguments: `{"name": "test"}`,
},
},
}),
nil)
return sr, nil
}
sw.Send(schema.AssistantMessage("stream done", nil), nil)
return sr, nil
}).AnyTimes()
// Test WithTools function
toolOptions, err := WithTools(ctx, fakeTool, fakeStreamTool)
assert.NoError(t, err)
assert.Len(t, toolOptions, 2, "WithTools should return exactly 2 options")
// Create agent without tools in config
a, err := NewAgent(ctx, &AgentConfig{
ToolCallingModel: cm,
MaxStep: 10,
})
assert.NoError(t, err)
// Test Generate with WithTools options
times = 0
msg, err := a.Generate(ctx, []*schema.Message{
schema.UserMessage("test generate with tools"),
}, toolOptions...)
assert.NoError(t, err)
assert.Equal(t, "done", msg.Content)
// Test Stream with WithTools options
times = 0
stream, err := a.Stream(ctx, []*schema.Message{
schema.UserMessage("test stream with tools"),
}, toolOptions...)
assert.NoError(t, err)
defer stream.Close()
msgs := make([]*schema.Message, 0)
for {
msg, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
assert.NoError(t, err)
}
msgs = append(msgs, msg)
}
assert.Len(t, msgs, 1)
concatMsg, err := schema.ConcatMessages(msgs)
assert.NoError(t, err)
assert.Equal(t, "stream done", concatMsg.Content)
// Test error case - tool Info() returns error
errorTool := &errorToolForTest{}
_, err = WithTools(ctx, errorTool)
assert.Error(t, err)
assert.Contains(t, err.Error(), "info error")
}
// Helper tool for testing error cases
type errorToolForTest struct{}
func (t *errorToolForTest) Info(_ context.Context) (*schema.ToolInfo, error) {
return nil, errors.New("info error")
}
func (t *errorToolForTest) InvokableRun(_ context.Context, _ string, _ ...tool.Option) (string, error) {
return "", nil
}
type fakeStreamToolGreetForTest struct {
tarCount int
curCount int
}
func (t *fakeStreamToolGreetForTest) StreamableRun(_ context.Context, argumentsInJSON string, _ ...tool.Option) (
*schema.StreamReader[string], error) {
p := &fakeToolInput{}
err := sonic.UnmarshalString(argumentsInJSON, p)
if err != nil {
return nil, err
}
if t.curCount >= t.tarCount {
s := schema.StreamReaderFromArray([]string{`{"say": "bye"}`})
return s, nil
}
t.curCount++
s := schema.StreamReaderFromArray([]string{fmt.Sprintf(`{"say": "hello %v"}`, p.Name)})
return s, nil
}
type fakeToolGreetForTest struct {
tarCount int
curCount int
}
func (t *fakeToolGreetForTest) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "greet",
Desc: "greet with name",
ParamsOneOf: schema.NewParamsOneOfByParams(
map[string]*schema.ParameterInfo{
"name": {
Desc: "user name who to greet",
Required: true,
Type: schema.String,
},
}),
}, nil
}
func (t *fakeStreamToolGreetForTest) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "greet in stream",
Desc: "greet with name in stream",
ParamsOneOf: schema.NewParamsOneOfByParams(
map[string]*schema.ParameterInfo{
"name": {
Desc: "user name who to greet",
Required: true,
Type: schema.String,
},
}),
}, nil
}
func (t *fakeToolGreetForTest) InvokableRun(_ context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
p := &fakeToolInput{}
err := sonic.UnmarshalString(argumentsInJSON, p)
if err != nil {
return "", err
}
if t.curCount >= t.tarCount {
return `{"say": "bye"}`, nil
}
t.curCount++
return fmt.Sprintf(`{"say": "hello %v"}`, p.Name), nil
}
type fakeToolInput struct {
Name string `json:"name"`
}
func randStr() string {
seeds := []rune("this is a seed")
b := make([]rune, 8)
for i := range b {
b[i] = seeds[rand.Intn(len(seeds))]
}
return string(b)
}
var callbackForTest = BuildAgentCallback(&template.ModelCallbackHandler{}, &template.ToolCallbackHandler{})