/* * 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 }