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

692 lines
17 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 host
import (
"context"
"io"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/cloudwego/eino/components/prompt"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent"
"github.com/cloudwego/eino/internal/generic"
"github.com/cloudwego/eino/internal/mock/components/model"
"github.com/cloudwego/eino/schema"
)
func TestHostMultiAgent(t *testing.T) {
ctrl := gomock.NewController(t)
mockHostLLM := model.NewMockToolCallingChatModel(ctrl)
mockSpecialistLLM1 := model.NewMockChatModel(ctrl)
specialist1 := &Specialist{
ChatModel: mockSpecialistLLM1,
SystemPrompt: "You are a helpful assistant.",
AgentMeta: AgentMeta{
Name: "specialist 1",
IntendedUse: "do stuff that works",
},
}
specialist2Msg1 := &schema.Message{
Role: schema.Assistant,
Content: "specialist2",
}
specialist2Msg2 := &schema.Message{
Role: schema.Assistant,
Content: " stream answer",
}
specialist2 := &Specialist{
Invokable: func(ctx context.Context, input []*schema.Message, opts ...agent.AgentOption) (*schema.Message, error) {
return &schema.Message{
Role: schema.Assistant,
Content: "specialist2 invoke answer",
}, nil
},
Streamable: func(ctx context.Context, input []*schema.Message, opts ...agent.AgentOption) (*schema.StreamReader[*schema.Message], error) {
return schema.StreamReaderFromArray([]*schema.Message{specialist2Msg1, specialist2Msg2}), nil
},
AgentMeta: AgentMeta{
Name: "specialist 2",
IntendedUse: "do stuff that works too",
},
}
ctx := context.Background()
mockHostLLM.EXPECT().WithTools(gomock.Any()).Return(mockHostLLM, nil).AnyTimes()
hostMA, err := NewMultiAgent(ctx, &MultiAgentConfig{
Host: Host{
ToolCallingModel: mockHostLLM,
},
Specialists: []*Specialist{
specialist1,
specialist2,
},
})
assert.NoError(t, err)
t.Run("generate direct answer from host", func(t *testing.T) {
directAnswerMsg := &schema.Message{
Role: schema.Assistant,
Content: "direct answer",
}
mockHostLLM.EXPECT().Generate(gomock.Any(), gomock.Any()).Return(directAnswerMsg, nil).Times(1)
mockCallback := newMockAgentCallback(0)
out, err := hostMA.Generate(ctx, nil, WithAgentCallbacks(mockCallback))
assert.NoError(t, err)
assert.Equal(t, "direct answer", out.Content)
assert.Empty(t, mockCallback.infos)
})
t.Run("stream direct answer from host", func(t *testing.T) {
directAnswerMsg1 := &schema.Message{
Role: schema.Assistant,
Content: "direct ",
}
directAnswerMsg2 := &schema.Message{
Role: schema.Assistant,
Content: "answer",
}
sr, sw := schema.Pipe[*schema.Message](0)
go func() {
sw.Send(directAnswerMsg1, nil)
sw.Send(directAnswerMsg2, nil)
sw.Close()
}()
mockHostLLM.EXPECT().Stream(gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
mockCallback := newMockAgentCallback(0)
outStream, err := hostMA.Stream(ctx, nil, WithAgentCallbacks(mockCallback))
assert.NoError(t, err)
assert.Empty(t, mockCallback.infos)
var msgs []*schema.Message
for {
msg, err := outStream.Recv()
if err == io.EOF {
break
}
assert.NoError(t, err)
msgs = append(msgs, msg)
}
outStream.Close()
assert.Equal(t, directAnswerMsg1, msgs[0])
assert.Equal(t, directAnswerMsg2, msgs[1])
})
t.Run("generate hand off", func(t *testing.T) {
handOffMsg := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
Function: schema.FunctionCall{
Name: specialist1.Name,
Arguments: `{"reason": "specialist 1 is the best"}`,
},
},
},
}
specialistMsg := &schema.Message{
Role: schema.Assistant,
Content: "specialist 1 answer",
}
mockHostLLM.EXPECT().Generate(gomock.Any(), gomock.Any()).Return(handOffMsg, nil).Times(1)
mockSpecialistLLM1.EXPECT().Generate(gomock.Any(), gomock.Any()).Return(specialistMsg, nil).Times(1)
mockCallback := newMockAgentCallback(1)
out, err := hostMA.Generate(ctx, nil, WithAgentCallbacks(mockCallback))
assert.NoError(t, err)
assert.Equal(t, "specialist 1 answer", out.Content)
mockCallback.wg.Wait()
assert.Equal(t, []*HandOffInfo{
{
ToAgentName: specialist1.Name,
Argument: `{"reason": "specialist 1 is the best"}`,
},
}, mockCallback.infos)
handOffMsg.ToolCalls[0].Function.Name = specialist2.Name
handOffMsg.ToolCalls[0].Function.Arguments = `{"reason": "specialist 2 is even better"}`
mockHostLLM.EXPECT().Generate(gomock.Any(), gomock.Any()).Return(handOffMsg, nil).Times(1)
mockCallback = newMockAgentCallback(1)
out, err = hostMA.Generate(ctx, nil, WithAgentCallbacks(mockCallback))
assert.NoError(t, err)
assert.Equal(t, "specialist2 invoke answer", out.Content)
mockCallback.wg.Wait()
assert.Equal(t, []*HandOffInfo{
{
ToAgentName: specialist2.Name,
Argument: `{"reason": "specialist 2 is even better"}`,
},
}, mockCallback.infos)
})
t.Run("stream hand off to chat model", func(t *testing.T) {
handOffMsg1 := &schema.Message{
Role: schema.Assistant,
Content: "need to call function",
}
handOffMsg2 := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
},
},
}
handOffMsg3 := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
Function: schema.FunctionCall{},
},
},
}
handOffMsg4 := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
Function: schema.FunctionCall{
Name: specialist1.Name,
Arguments: `{"reason": "specialist 1 is the best"}`,
},
},
},
}
sr, sw := schema.Pipe[*schema.Message](0)
go func() {
sw.Send(handOffMsg1, nil)
sw.Send(handOffMsg2, nil)
sw.Send(handOffMsg3, nil)
sw.Send(handOffMsg4, nil)
sw.Close()
}()
specialistMsg1 := &schema.Message{
Role: schema.Assistant,
Content: "specialist ",
}
specialistMsg2 := &schema.Message{
Role: schema.Assistant,
Content: "1 answer",
}
sr1, sw1 := schema.Pipe[*schema.Message](0)
go func() {
sw1.Send(specialistMsg1, nil)
sw1.Send(specialistMsg2, nil)
sw1.Close()
}()
streamToolCallChecker := func(ctx context.Context, modelOutput *schema.StreamReader[*schema.Message]) (bool, error) {
defer modelOutput.Close()
for {
msg, err := modelOutput.Recv()
if err != nil {
if err == io.EOF {
return false, nil
}
return false, err
}
if len(msg.ToolCalls) == 0 {
continue
}
if len(msg.ToolCalls) > 0 {
return true, nil
}
}
}
hostMA, err = NewMultiAgent(ctx, &MultiAgentConfig{
Host: Host{
ToolCallingModel: mockHostLLM,
},
Specialists: []*Specialist{
specialist1,
specialist2,
},
StreamToolCallChecker: streamToolCallChecker,
})
assert.NoError(t, err)
mockHostLLM.EXPECT().Stream(gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
mockSpecialistLLM1.EXPECT().Stream(gomock.Any(), gomock.Any()).Return(sr1, nil).Times(1)
mockCallback := newMockAgentCallback(1)
outStream, err := hostMA.Stream(ctx, nil, WithAgentCallbacks(mockCallback))
assert.NoError(t, err)
var msgs []*schema.Message
for {
msg, err := outStream.Recv()
if err == io.EOF {
break
}
assert.NoError(t, err)
msgs = append(msgs, msg)
}
outStream.Close()
assert.Equal(t, specialistMsg1, msgs[0])
assert.Equal(t, specialistMsg2, msgs[1])
mockCallback.wg.Wait()
assert.Equal(t, []*HandOffInfo{
{
ToAgentName: specialist1.Name,
Argument: `{"reason": "specialist 1 is the best"}`,
},
}, mockCallback.infos)
handOffMsg4.ToolCalls[0].Function.Name = specialist2.Name
handOffMsg4.ToolCalls[0].Function.Arguments = `{"reason": "specialist 2 is even better"}`
sr, sw = schema.Pipe[*schema.Message](0)
go func() {
sw.Send(handOffMsg1, nil)
sw.Send(handOffMsg2, nil)
sw.Send(handOffMsg3, nil)
sw.Send(handOffMsg4, nil)
sw.Close()
}()
mockHostLLM.EXPECT().Stream(gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
mockCallback = newMockAgentCallback(1)
outStream, err = hostMA.Stream(ctx, nil, WithAgentCallbacks(mockCallback))
assert.NoError(t, err)
msgs = nil
for {
msg, err := outStream.Recv()
if err == io.EOF {
break
}
assert.NoError(t, err)
msgs = append(msgs, msg)
}
outStream.Close()
assert.Equal(t, specialist2Msg1, msgs[0])
assert.Equal(t, specialist2Msg2, msgs[1])
mockCallback.wg.Wait()
assert.Equal(t, []*HandOffInfo{
{
ToAgentName: specialist2.Name,
Argument: `{"reason": "specialist 2 is even better"}`,
},
}, mockCallback.infos)
})
t.Run("multi-agent within graph", func(t *testing.T) {
handOffMsg := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
Function: schema.FunctionCall{
Name: specialist1.Name,
Arguments: `{"reason": "specialist 1 is the best"}`,
},
},
},
}
specialistMsg := &schema.Message{
Role: schema.Assistant,
Content: "Beijing",
}
mockHostLLM.EXPECT().Generate(gomock.Any(), gomock.Any()).Return(handOffMsg, nil).Times(1)
mockSpecialistLLM1.EXPECT().Generate(gomock.Any(), gomock.Any()).Return(specialistMsg, nil).Times(1)
mockCallback := newMockAgentCallback(1)
hostMA, err := NewMultiAgent(ctx, &MultiAgentConfig{
Host: Host{
ToolCallingModel: mockHostLLM,
},
Specialists: []*Specialist{
specialist1,
specialist2,
},
})
assert.NoError(t, err)
maGraph, opts := hostMA.ExportGraph()
fullGraph, err := compose.NewChain[map[string]any, *schema.Message]().
AppendChatTemplate(prompt.FromMessages(schema.FString, schema.UserMessage("what's the capital city of {country_name}"))).
AppendGraph(maGraph, append(opts, compose.WithNodeKey("host_ma_node"))...).
Compile(ctx)
assert.NoError(t, err)
out, err := fullGraph.Invoke(ctx, map[string]any{"country_name": "China"}, compose.WithCallbacks(ConvertCallbackHandlers(mockCallback)).DesignateNodeWithPath(compose.NewNodePath("host_ma_node", hostMA.HostNodeKey())))
assert.NoError(t, err)
assert.Equal(t, "Beijing", out.Content)
mockCallback.wg.Wait()
assert.Equal(t, []*HandOffInfo{
{
ToAgentName: specialist1.Name,
Argument: `{"reason": "specialist 1 is the best"}`,
},
}, mockCallback.infos)
})
t.Run("multiple intents", func(t *testing.T) {
handOffMsg1 := &schema.Message{
Role: schema.Assistant,
Content: "need to call function",
}
handOffMsg2 := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
},
},
}
handOffMsg3 := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
Function: schema.FunctionCall{},
},
},
}
handOffMsg4 := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
Function: schema.FunctionCall{
Name: specialist1.Name,
Arguments: `{"reason": "specialist 1 is good"}`,
},
}, {
Index: generic.PtrOf(1),
Function: schema.FunctionCall{
Name: specialist2.Name,
Arguments: `{"reason": "specialist 2`,
},
},
},
}
handOffMsg5 := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(1),
Function: schema.FunctionCall{
Arguments: ` is also good"}`,
},
},
},
}
sr := schema.StreamReaderFromArray([]*schema.Message{
handOffMsg1,
handOffMsg2,
handOffMsg3,
handOffMsg4,
handOffMsg5,
})
specialist1Msg1 := &schema.Message{
Role: schema.Assistant,
Content: "specialist ",
}
specialist1Msg2 := &schema.Message{
Role: schema.Assistant,
Content: "1 answer",
}
sr1 := schema.StreamReaderFromArray([]*schema.Message{
specialist1Msg1,
specialist1Msg2,
})
streamToolCallChecker := func(ctx context.Context, modelOutput *schema.StreamReader[*schema.Message]) (bool, error) {
defer modelOutput.Close()
for {
msg, err := modelOutput.Recv()
if err != nil {
if err == io.EOF {
return false, nil
}
return false, err
}
if len(msg.ToolCalls) == 0 {
continue
}
if len(msg.ToolCalls) > 0 {
return true, nil
}
}
}
hostMA, err = NewMultiAgent(ctx, &MultiAgentConfig{
Host: Host{
ToolCallingModel: mockHostLLM,
},
Specialists: []*Specialist{
specialist1,
specialist2,
},
StreamToolCallChecker: streamToolCallChecker,
})
assert.NoError(t, err)
mockHostLLM.EXPECT().Stream(gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
mockSpecialistLLM1.EXPECT().Stream(gomock.Any(), gomock.Any()).Return(sr1, nil).Times(1)
mockCallback := newMockAgentCallback(2)
outStream, err := hostMA.Stream(ctx, nil, WithAgentCallbacks(mockCallback))
assert.NoError(t, err)
var msgs []*schema.Message
for {
msg, err := outStream.Recv()
if err == io.EOF {
break
}
assert.NoError(t, err)
msgs = append(msgs, msg)
}
outStream.Close()
msg, err := schema.ConcatMessages(msgs)
assert.NoError(t, err)
if msg.Content != "specialist2 stream answer\nspecialist 1 answer\n" &&
msg.Content != "specialist 1 answer\nspecialist2 stream answer\n" {
t.Errorf("Unexpected message content: %s", msg.Content)
}
mockCallback.wg.Wait()
assert.Equal(t, []*HandOffInfo{
{
ToAgentName: specialist1.Name,
Argument: `{"reason": "specialist 1 is good"}`,
},
{
ToAgentName: specialist2.Name,
Argument: `{"reason": "specialist 2 is also good"}`,
},
}, mockCallback.infos)
})
t.Run("summarize multiple intents", func(t *testing.T) {
handOffMsg := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
Index: generic.PtrOf(0),
Function: schema.FunctionCall{
Name: specialist1.Name,
Arguments: `{"reason": "specialist 1 is good"}`,
},
}, {
Index: generic.PtrOf(1),
Function: schema.FunctionCall{
Name: specialist2.Name,
Arguments: `{"reason": "specialist 2 is also good"}`,
},
},
},
}
sr := schema.StreamReaderFromArray([]*schema.Message{
handOffMsg,
})
specialist1Msg1 := &schema.Message{
Role: schema.Assistant,
Content: "specialist 1 answer",
}
sr1 := schema.StreamReaderFromArray([]*schema.Message{
specialist1Msg1,
})
const summaryContent = "summarized answer"
sr2 := schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: summaryContent,
},
})
mockSumChatModel := model.NewMockChatModel(ctrl)
hostMA, err = NewMultiAgent(ctx, &MultiAgentConfig{
Host: Host{
ToolCallingModel: mockHostLLM,
},
Specialists: []*Specialist{
specialist1,
specialist2,
},
Summarizer: &Summarizer{
ChatModel: mockSumChatModel,
},
})
assert.NoError(t, err)
mockHostLLM.EXPECT().Stream(gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
mockSpecialistLLM1.EXPECT().Stream(gomock.Any(), gomock.Any()).Return(sr1, nil).Times(1)
mockSumChatModel.EXPECT().Stream(gomock.Any(), gomock.Cond(func(x any) bool {
return assert.Equal(t, defaultSummarizerPrompt, func() string {
if input := x.([]*schema.Message); len(input) > 0 {
return input[0].Content
}
return ""
}())
})).Return(sr2, nil).Times(1)
outStream, err := hostMA.Stream(ctx, nil)
assert.NoError(t, err)
var msgs []*schema.Message
for {
msg, err := outStream.Recv()
if err == io.EOF {
break
}
assert.NoError(t, err)
msgs = append(msgs, msg)
}
outStream.Close()
msg, err := schema.ConcatMessages(msgs)
assert.NoError(t, err)
if msg.Content != summaryContent {
t.Errorf("Unexpected message content: %s", msg.Content)
}
},
)
}
type mockAgentCallback struct {
infos []*HandOffInfo
wg sync.WaitGroup
}
func (m *mockAgentCallback) OnHandOff(ctx context.Context, info *HandOffInfo) context.Context {
m.infos = append(m.infos, info)
m.wg.Done()
return ctx
}
func newMockAgentCallback(expects int) *mockAgentCallback {
m := &mockAgentCallback{
infos: make([]*HandOffInfo, 0),
wg: sync.WaitGroup{},
}
m.wg.Add(expects)
return m
}