/* * 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 react import ( "context" "io" "sync" "testing" "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" ) func TestWithMessageFuture(t *testing.T) { ctx := context.Background() // Test with tool calls t.Run("test generate with tool calls", func(t *testing.T) { ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) fakeTool := &fakeToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) // Mock model response with tool call cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "test user"}`, }, }, }), nil). Times(1) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("final response", nil), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() // Create agent with MessageFuture option, future := WithMessageFuture() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) // Generate response response, err := a.Generate(ctx, []*schema.Message{ schema.UserMessage("use the greet tool"), }, option) assert.Nil(t, err) assert.Equal(t, "final response", response.Content) sIter := future.GetMessageStreams() // Should be no messages _, hasNext, err := sIter.Next() assert.Nil(t, err) assert.False(t, hasNext) iter := future.GetMessages() // First message should be the assistant message for tool calling msg1, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Assistant, msg1.Role) assert.Equal(t, 1, len(msg1.ToolCalls)) // Second message should be the tool response msg2, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Tool, msg2.Role) // Third message should be the final response msg3, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, "final response", msg3.Content) // Should be no more messages _, hasNext, err = iter.Next() assert.Nil(t, err) assert.False(t, hasNext) }) // Test with streaming tool calls t.Run("test generate with streaming tool calls", func(t *testing.T) { ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) fakeTool := &fakeStreamToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) // Mock model response with tool call cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "test user"}`, }, }, }), nil). Times(1) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("final response", nil), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() // Create agent with MessageFuture option, future := WithMessageFuture() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) // Generate response response, err := a.Generate(ctx, []*schema.Message{ schema.UserMessage("use the greet tool"), }, option) assert.Nil(t, err) assert.Equal(t, "final response", response.Content) // Get messages from future iter := future.GetMessages() // First message should be the assistant message for tool calling msg1, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Assistant, msg1.Role) assert.Equal(t, 1, len(msg1.ToolCalls)) // Second message should be the tool response msg2, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Tool, msg2.Role) // Third message should be the final response msg3, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, "final response", msg3.Content) // Should be no more messages _, hasNext, err = iter.Next() assert.Nil(t, err) assert.False(t, hasNext) }) // Test with non-streaming tool but using agent's Stream interface t.Run("test stream with tool calls", func(t *testing.T) { ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) fakeTool := &fakeToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) // Mock model response with tool call cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "test user"}`, }, }, })}), nil). Times(1) cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("final response", nil)}), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() // Create agent with MessageFuture option, future := WithMessageFuture() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) // Use Stream interface stream, err := a.Stream(ctx, []*schema.Message{ schema.UserMessage("use the greet tool"), }, option) assert.Nil(t, err) // Collect all chunks from stream finalResponse, err := schema.ConcatMessageStream(stream) assert.Nil(t, err) assert.Equal(t, "final response", finalResponse.Content) iter := future.GetMessages() // Should be no messages _, hasNext, err := iter.Next() assert.Nil(t, err) assert.False(t, hasNext) // Get message streams from future sIter := future.GetMessageStreams() // First message should be the assistant message for tool calling stream1, hasNext, err := sIter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.NotNil(t, stream1) msg1, err := schema.ConcatMessageStream(stream1) assert.Nil(t, err) assert.Equal(t, schema.Assistant, msg1.Role) assert.Equal(t, 1, len(msg1.ToolCalls)) // Second message should be the tool response stream2, hasNext, err := sIter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.NotNil(t, stream2) msg2, err := schema.ConcatMessageStream(stream2) assert.Nil(t, err) assert.Equal(t, schema.Tool, msg2.Role) // Third message should be the final response stream3, hasNext, err := sIter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.NotNil(t, stream3) msg3, err := schema.ConcatMessageStream(stream3) assert.Nil(t, err) assert.Equal(t, "final response", msg3.Content) // Should be no more messages _, hasNext, err = sIter.Next() assert.Nil(t, err) assert.False(t, hasNext) }) t.Run("test stream with streaming tool calls and with concurrent goroutines", func(t *testing.T) { ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) fakeTool := &fakeStreamToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) // Mock model response with tool call cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "test user"}`, }, }, })}), nil). Times(1) cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("final response", nil)}), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() // Create agent with MessageFuture option, future := WithMessageFuture() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() // Get message streams from future sIter := future.GetMessageStreams() // First message should be the assistant message for tool calling stream1, hasNext, err_ := sIter.Next() assert.Nil(t, err_) assert.True(t, hasNext) assert.NotNil(t, stream1) msg1, err_ := schema.ConcatMessageStream(stream1) assert.Nil(t, err_) assert.Equal(t, schema.Assistant, msg1.Role) assert.Equal(t, 1, len(msg1.ToolCalls)) // Second message should be the tool response stream2, hasNext, err_ := sIter.Next() assert.Nil(t, err_) assert.True(t, hasNext) assert.NotNil(t, stream2) msg2, err_ := schema.ConcatMessageStream(stream2) assert.Nil(t, err_) assert.Equal(t, schema.Tool, msg2.Role) // Third message should be the final response stream3, hasNext, err_ := sIter.Next() assert.Nil(t, err_) assert.True(t, hasNext) assert.NotNil(t, stream3) msg3, err_ := schema.ConcatMessageStream(stream3) assert.Nil(t, err_) assert.Equal(t, "final response", msg3.Content) // Should be no more messages _, hasNext, err_ = sIter.Next() assert.Nil(t, err_) assert.False(t, hasNext) }() // Use Stream interface stream, err := a.Stream(ctx, []*schema.Message{ schema.UserMessage("use the greet tool"), }, option) assert.Nil(t, err) // Collect all chunks from stream finalResponse, err := schema.ConcatMessageStream(stream) assert.Nil(t, err) assert.Equal(t, "final response", finalResponse.Content) wg.Wait() }) } func TestWithToolOptions(t *testing.T) { type dummyOpt struct{ val string } opt := tool.WrapImplSpecificOptFn(func(o *dummyOpt) { o.val = "mock" }) agentOpt := WithToolOptions(opt) assert.NotNil(t, agentOpt) // The returned value should be an agent.AgentOption (function) assert.IsType(t, agentOpt, agentOpt) } func TestWithChatModelOptions(t *testing.T) { opt := model.WithModel("mock-model") agentOpt := WithChatModelOptions(opt) assert.NotNil(t, agentOpt) assert.IsType(t, agentOpt, agentOpt) } // dummyBaseTool is a minimal implementation of tool.BaseTool for testing. type dummyBaseTool struct{} func (d *dummyBaseTool) Info(ctx context.Context) (*schema.ToolInfo, error) { return &schema.ToolInfo{Name: "dummy"}, nil } func (d *dummyBaseTool) InvokableRun(ctx context.Context, _ string, _ ...tool.Option) (string, error) { return "dummy-response", nil } type assertTool struct { toolOptVal string receivedToolOpt bool } type toolOpt struct{ val string } func (a *assertTool) Info(ctx context.Context) (*schema.ToolInfo, error) { return &schema.ToolInfo{Name: "assert_tool"}, nil } func (a *assertTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) { opt := tool.GetImplSpecificOptions(&toolOpt{}, opts...) if opt.val == a.toolOptVal { a.receivedToolOpt = true } return "tool-response", nil } func TestAgentWithAllOptions(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) // Prepare a tool that asserts it receives the tool option toolOptVal := "tool-opt-value" to := tool.WrapImplSpecificOptFn(func(o *toolOpt) { o.val = toolOptVal }) at := &assertTool{toolOptVal: toolOptVal} // Prepare a mock chat model that asserts it receives the model option cm := mockModel.NewMockToolCallingChatModel(ctrl) modelOpt := model.WithModel("test-model") modelOptReceived := false times := 0 cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, _ []*schema.Message, opts ...model.Option) (*schema.Message, error) { times++ if times == 1 { for _, o := range opts { opt := model.GetCommonOptions(&model.Options{}, o) if opt.Model != nil && *opt.Model == "test-model" { modelOptReceived = true } } info, _ := at.Info(ctx) return schema.AssistantMessage("hello max", []schema.ToolCall{ { ID: randStr(), Function: schema.FunctionCall{ Name: info.Name, Arguments: "", }, }, }), nil } return schema.AssistantMessage("ok", nil), nil }, ).AnyTimes() cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() agentOpt := WithToolOptions(to) agentOpt2 := WithChatModelOptions(modelOpt) agentOpt3, err := WithTools(context.Background(), at) assert.NoError(t, err) a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{&dummyBaseTool{}}, }, MaxStep: 20, }) assert.NoError(t, err) _, err = a.Generate(ctx, []*schema.Message{ schema.UserMessage("call the tool"), }, agentOpt, agentOpt2, agentOpt3[0], agentOpt3[1]) assert.NoError(t, err) assert.True(t, modelOptReceived, "model option should be received by chat model") assert.True(t, at.receivedToolOpt, "tool option should be received by tool") } type simpleToolForMiddlewareTest struct { name string result string } func (s *simpleToolForMiddlewareTest) Info(_ context.Context) (*schema.ToolInfo, error) { return &schema.ToolInfo{ Name: s.name, Desc: "simple tool for middleware test", ParamsOneOf: schema.NewParamsOneOfByParams( map[string]*schema.ParameterInfo{ "input": { Desc: "input", Required: true, Type: schema.String, }, }), }, nil } func (s *simpleToolForMiddlewareTest) InvokableRun(_ context.Context, _ string, _ ...tool.Option) (string, error) { return s.result, nil } func (s *simpleToolForMiddlewareTest) StreamableRun(_ context.Context, _ string, _ ...tool.Option) (*schema.StreamReader[string], error) { return schema.StreamReaderFromArray([]string{s.result}), nil } func TestMessageFuture_ToolResultMiddleware_EmitsFinalResult(t *testing.T) { originalResult := "original_result" modifiedResult := "modified_by_middleware" resultModifyingMiddleware := compose.ToolMiddleware{ Invokable: func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint { return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) { output, err := next(ctx, input) if err != nil { return nil, err } output.Result = modifiedResult return output, nil } }, Streamable: func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint { return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) { output, err := next(ctx, input) if err != nil { return nil, err } output.Result = schema.StreamReaderFromArray([]string{modifiedResult}) return output, nil } }, } t.Run("Invoke", func(t *testing.T) { ctx := context.Background() testTool := &simpleToolForMiddlewareTest{name: "test_tool", result: originalResult} ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) info, err := testTool.Info(ctx) assert.NoError(t, err) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"input": "test"}`, }, }, }), nil). Times(1) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("final response", nil), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() option, future := WithMessageFuture() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{testTool}, ToolCallMiddlewares: []compose.ToolMiddleware{resultModifyingMiddleware}, }, MaxStep: 3, }) assert.NoError(t, err) response, err := a.Generate(ctx, []*schema.Message{ schema.UserMessage("call the tool"), }, option) assert.NoError(t, err) assert.Equal(t, "final response", response.Content) iter := future.GetMessages() var allMsgs []*schema.Message for { msg, hasNext, err := iter.Next() if err != nil || !hasNext { break } allMsgs = append(allMsgs, msg) } assert.GreaterOrEqual(t, len(allMsgs), 3, "should have at least 3 messages") if len(allMsgs) >= 3 { assert.Equal(t, schema.Assistant, allMsgs[0].Role) assert.Equal(t, 1, len(allMsgs[0].ToolCalls)) assert.Equal(t, schema.Tool, allMsgs[1].Role) assert.Equal(t, modifiedResult, allMsgs[1].Content, "MessageFuture should receive the middleware-modified tool result") assert.NotEqual(t, originalResult, allMsgs[1].Content, "MessageFuture should NOT receive the original tool result") assert.Equal(t, "final response", allMsgs[2].Content) } }) t.Run("Stream", func(t *testing.T) { ctx := context.Background() testTool := &simpleToolForMiddlewareTest{name: "test_tool_stream", result: originalResult} ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) info, err := testTool.Info(ctx) assert.NoError(t, err) cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.StreamReaderFromArray([]*schema.Message{ schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"input": "test"}`, }, }, }), }), nil). Times(1) cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.StreamReaderFromArray([]*schema.Message{ schema.AssistantMessage("final response", nil), }), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() option, future := WithMessageFuture() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{testTool}, ToolCallMiddlewares: []compose.ToolMiddleware{resultModifyingMiddleware}, }, MaxStep: 3, }) assert.NoError(t, err) response, err := a.Stream(ctx, []*schema.Message{ schema.UserMessage("call the tool"), }, option) assert.NoError(t, err) var msgs []*schema.Message for { msg, err := response.Recv() if err != nil { break } msgs = append(msgs, msg) } finalMsg, err := schema.ConcatMessages(msgs) assert.NoError(t, err) assert.Equal(t, "final response", finalMsg.Content) iter := future.GetMessageStreams() var allMsgs []*schema.Message for { msgStream, hasNext, err := iter.Next() if err != nil || !hasNext { break } var streamMsgs []*schema.Message for { msg, err := msgStream.Recv() if err != nil { break } streamMsgs = append(streamMsgs, msg) } if len(streamMsgs) > 0 { concated, err := schema.ConcatMessages(streamMsgs) if err == nil { allMsgs = append(allMsgs, concated) } } } assert.GreaterOrEqual(t, len(allMsgs), 3, "should have at least 3 messages") if len(allMsgs) >= 3 { assert.Equal(t, schema.Assistant, allMsgs[0].Role) assert.Equal(t, 1, len(allMsgs[0].ToolCalls)) assert.Equal(t, schema.Tool, allMsgs[1].Role) assert.Equal(t, modifiedResult, allMsgs[1].Content, "MessageFuture should receive the middleware-modified tool result") assert.NotEqual(t, originalResult, allMsgs[1].Content, "MessageFuture should NOT receive the original tool result") assert.Equal(t, "final response", allMsgs[2].Content) } }) } func TestWithMessageFuture_NestedGraph(t *testing.T) { ctx := context.Background() t.Run("agent in nested graph", func(t *testing.T) { ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) fakeTool := &fakeToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "test user"}`, }, }, }), nil). Times(1) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("final response", nil), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) agentGraph, agentGraphOpts := a.ExportGraph() parentGraph := compose.NewGraph[[]*schema.Message, *schema.Message]() err = parentGraph.AddGraphNode("agent", agentGraph, agentGraphOpts...) assert.NoError(t, err) err = parentGraph.AddEdge(compose.START, "agent") assert.NoError(t, err) err = parentGraph.AddEdge("agent", compose.END) assert.NoError(t, err) runnable, err := parentGraph.Compile(ctx) assert.NoError(t, err) option, future := WithMessageFuture() response, err := runnable.Invoke(ctx, []*schema.Message{ schema.UserMessage("use the greet tool"), }, agent.GetComposeOptions(option)...) assert.Nil(t, err) assert.Equal(t, "final response", response.Content) iter := future.GetMessages() msg1, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Assistant, msg1.Role) assert.Equal(t, 1, len(msg1.ToolCalls)) msg2, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Tool, msg2.Role) msg3, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, "final response", msg3.Content) _, hasNext, err = iter.Next() assert.Nil(t, err) assert.False(t, hasNext) }) t.Run("agent in deeply nested graph", func(t *testing.T) { ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) fakeTool := &fakeToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "test user"}`, }, }, }), nil). Times(1) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("final response", nil), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) agentGraph, agentGraphOpts := a.ExportGraph() childGraph := compose.NewGraph[[]*schema.Message, *schema.Message]() err = childGraph.AddGraphNode("agent", agentGraph, agentGraphOpts...) assert.NoError(t, err) err = childGraph.AddEdge(compose.START, "agent") assert.NoError(t, err) err = childGraph.AddEdge("agent", compose.END) assert.NoError(t, err) parentGraph := compose.NewGraph[[]*schema.Message, *schema.Message]() err = parentGraph.AddGraphNode("child", childGraph) assert.NoError(t, err) err = parentGraph.AddEdge(compose.START, "child") assert.NoError(t, err) err = parentGraph.AddEdge("child", compose.END) assert.NoError(t, err) runnable, err := parentGraph.Compile(ctx) assert.NoError(t, err) option, future := WithMessageFuture() response, err := runnable.Invoke(ctx, []*schema.Message{ schema.UserMessage("use the greet tool"), }, agent.GetComposeOptions(option)...) assert.Nil(t, err) assert.Equal(t, "final response", response.Content) iter := future.GetMessages() msg1, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Assistant, msg1.Role) assert.Equal(t, 1, len(msg1.ToolCalls)) msg2, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Tool, msg2.Role) msg3, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, "final response", msg3.Content) _, hasNext, err = iter.Next() assert.Nil(t, err) assert.False(t, hasNext) }) t.Run("agent in nested graph with streaming", func(t *testing.T) { ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) fakeTool := &fakeToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) 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) { return schema.StreamReaderFromArray([]*schema.Message{ schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "stream user"}`, }, }, }), }), nil }). Times(1) 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) { return schema.StreamReaderFromArray([]*schema.Message{ schema.AssistantMessage("streaming", nil), schema.AssistantMessage(" final", nil), schema.AssistantMessage(" response", nil), }), nil }). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) agentGraph, agentGraphOpts := a.ExportGraph() parentGraph := compose.NewGraph[[]*schema.Message, *schema.Message]() err = parentGraph.AddGraphNode("agent", agentGraph, agentGraphOpts...) assert.NoError(t, err) err = parentGraph.AddEdge(compose.START, "agent") assert.NoError(t, err) err = parentGraph.AddEdge("agent", compose.END) assert.NoError(t, err) runnable, err := parentGraph.Compile(ctx) assert.NoError(t, err) option, future := WithMessageFuture() streamReader, err := runnable.Stream(ctx, []*schema.Message{ schema.UserMessage("use the greet tool"), }, agent.GetComposeOptions(option)...) assert.Nil(t, err) var finalContent string for { chunk, err := streamReader.Recv() if err == io.EOF { break } assert.Nil(t, err) finalContent += chunk.Content } assert.Contains(t, finalContent, "final") siter := future.GetMessageStreams() stream1, hasNext, err := siter.Next() assert.Nil(t, err) assert.True(t, hasNext) msg1, err := concatStreamMsg(stream1) assert.Nil(t, err) assert.Equal(t, schema.Assistant, msg1.Role) assert.Equal(t, 1, len(msg1.ToolCalls)) stream2, hasNext, err := siter.Next() assert.Nil(t, err) assert.True(t, hasNext) msg2, err := concatStreamMsg(stream2) assert.Nil(t, err) assert.Equal(t, schema.Tool, msg2.Role) stream3, hasNext, err := siter.Next() assert.Nil(t, err) assert.True(t, hasNext) msg3, err := concatStreamMsg(stream3) assert.Nil(t, err) assert.Contains(t, msg3.Content, "final") _, hasNext, err = siter.Next() assert.Nil(t, err) assert.False(t, hasNext) }) t.Run("agent with multiple tool calls in nested graph", func(t *testing.T) { ctrl := gomock.NewController(t) cm := mockModel.NewMockToolCallingChatModel(ctrl) fakeTool := &fakeToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "user1"}`, }, }, { ID: "tool-call-2", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "user2"}`, }, }, }), nil). Times(1) cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("greeted both users", nil), nil). Times(1) cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() a, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) agentGraph, agentGraphOpts := a.ExportGraph() parentGraph := compose.NewGraph[[]*schema.Message, *schema.Message]() err = parentGraph.AddGraphNode("agent", agentGraph, agentGraphOpts...) assert.NoError(t, err) err = parentGraph.AddEdge(compose.START, "agent") assert.NoError(t, err) err = parentGraph.AddEdge("agent", compose.END) assert.NoError(t, err) runnable, err := parentGraph.Compile(ctx) assert.NoError(t, err) option, future := WithMessageFuture() response, err := runnable.Invoke(ctx, []*schema.Message{ schema.UserMessage("greet multiple users"), }, agent.GetComposeOptions(option)...) assert.Nil(t, err) assert.Equal(t, "greeted both users", response.Content) iter := future.GetMessages() msg1, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Assistant, msg1.Role) assert.Equal(t, 2, len(msg1.ToolCalls)) msg2, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Tool, msg2.Role) msg3, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, schema.Tool, msg3.Role) msg4, hasNext, err := iter.Next() assert.Nil(t, err) assert.True(t, hasNext) assert.Equal(t, "greeted both users", msg4.Content) _, hasNext, err = iter.Next() assert.Nil(t, err) assert.False(t, hasNext) }) t.Run("direct agent invoke vs nested graph - same behavior", func(t *testing.T) { ctrl := gomock.NewController(t) fakeTool := &fakeToolGreetForTest{} info, err := fakeTool.Info(ctx) assert.NoError(t, err) cm1 := mockModel.NewMockToolCallingChatModel(ctrl) cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "test"}`, }, }, }), nil). Times(1) cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("done", nil), nil). Times(1) cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes() a1, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm1, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) option1, future1 := WithMessageFuture() _, err = a1.Generate(ctx, []*schema.Message{ schema.UserMessage("test direct"), }, option1) assert.Nil(t, err) directMsgs := collectAllMessages(future1.GetMessages()) cm2 := mockModel.NewMockToolCallingChatModel(ctrl) cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: info.Name, Arguments: `{"name": "test"}`, }, }, }), nil). Times(1) cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("done", nil), nil). Times(1) cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes() a2, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm2, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{fakeTool}, }, MaxStep: 3, }) assert.Nil(t, err) agentGraph, agentGraphOpts := a2.ExportGraph() parentGraph := compose.NewGraph[[]*schema.Message, *schema.Message]() err = parentGraph.AddGraphNode("agent", agentGraph, agentGraphOpts...) assert.NoError(t, err) err = parentGraph.AddEdge(compose.START, "agent") assert.NoError(t, err) err = parentGraph.AddEdge("agent", compose.END) assert.NoError(t, err) runnable, err := parentGraph.Compile(ctx) assert.NoError(t, err) option2, future2 := WithMessageFuture() _, err = runnable.Invoke(ctx, []*schema.Message{ schema.UserMessage("test nested"), }, agent.GetComposeOptions(option2)...) assert.Nil(t, err) nestedMsgs := collectAllMessages(future2.GetMessages()) assert.Equal(t, len(directMsgs), len(nestedMsgs), "should have same number of messages") for i := range directMsgs { assert.Equal(t, directMsgs[i].Role, nestedMsgs[i].Role, "message %d role should match", i) } }) t.Run("multiple react agents in graph - agent calls tool that invokes another agent", func(t *testing.T) { ctrl := gomock.NewController(t) cm1 := mockModel.NewMockToolCallingChatModel(ctrl) cm2 := mockModel.NewMockToolCallingChatModel(ctrl) cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes() cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("inner agent response", nil), nil). Times(1) innerAgent, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm2, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{&fakeToolGreetForTest{}}, }, MaxStep: 3, }) assert.Nil(t, err) innerAgentTool := &agentAsTool{ agent: innerAgent, name: "inner_agent", desc: "An inner agent that can greet users", } cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes() cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("", []schema.ToolCall{ { ID: "tool-call-1", Function: schema.FunctionCall{ Name: "inner_agent", Arguments: `{"query": "hello"}`, }, }, }), nil). Times(1) cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("outer agent final response", nil), nil). Times(1) outerAgent, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm1, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{innerAgentTool}, }, MaxStep: 3, }) assert.Nil(t, err) option, future := WithMessageFuture() response, err := outerAgent.Generate(ctx, []*schema.Message{ schema.UserMessage("use the inner agent"), }, option) assert.Nil(t, err) assert.Equal(t, "outer agent final response", response.Content) allMsgs := collectAllMessages(future.GetMessages()) assert.GreaterOrEqual(t, len(allMsgs), 3, "should have at least 3 messages") hasOuterToolCall := false hasInnerResponse := false hasOuterFinalResponse := false for _, msg := range allMsgs { if msg.Role == schema.Assistant && len(msg.ToolCalls) > 0 { hasOuterToolCall = true } if msg.Content == "inner agent response" { hasInnerResponse = true } if msg.Content == "outer agent final response" { hasOuterFinalResponse = true } } assert.True(t, hasOuterToolCall, "should have outer agent tool call") assert.True(t, hasInnerResponse, "should have inner agent response") assert.True(t, hasOuterFinalResponse, "should have outer agent final response") }) t.Run("two sequential react agents in same graph", func(t *testing.T) { ctrl := gomock.NewController(t) cm1 := mockModel.NewMockToolCallingChatModel(ctrl) cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes() cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("agent1 response", nil), nil). Times(1) cm2 := mockModel.NewMockToolCallingChatModel(ctrl) cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes() cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). Return(schema.AssistantMessage("agent2 response", nil), nil). Times(1) agent1, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm1, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{&fakeToolGreetForTest{}}, }, MaxStep: 3, }) assert.Nil(t, err) agent2, err := NewAgent(ctx, &AgentConfig{ ToolCallingModel: cm2, ToolsConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{&fakeToolGreetForTest{}}, }, MaxStep: 3, }) assert.Nil(t, err) agent1Graph, agent1Opts := agent1.ExportGraph() agent2Graph, agent2Opts := agent2.ExportGraph() parentGraph := compose.NewGraph[[]*schema.Message, *schema.Message]() err = parentGraph.AddGraphNode("agent1", agent1Graph, agent1Opts...) assert.NoError(t, err) err = parentGraph.AddLambdaNode("transform", compose.InvokableLambda(func(ctx context.Context, msg *schema.Message) ([]*schema.Message, error) { return []*schema.Message{schema.UserMessage("agent2 input: " + msg.Content)}, nil })) assert.NoError(t, err) err = parentGraph.AddGraphNode("agent2", agent2Graph, agent2Opts...) assert.NoError(t, err) err = parentGraph.AddEdge(compose.START, "agent1") assert.NoError(t, err) err = parentGraph.AddEdge("agent1", "transform") assert.NoError(t, err) err = parentGraph.AddEdge("transform", "agent2") assert.NoError(t, err) err = parentGraph.AddEdge("agent2", compose.END) assert.NoError(t, err) runnable, err := parentGraph.Compile(ctx) assert.NoError(t, err) option, future := WithMessageFuture() response, err := runnable.Invoke(ctx, []*schema.Message{ schema.UserMessage("hello"), }, agent.GetComposeOptions(option)...) assert.Nil(t, err) assert.Equal(t, "agent2 response", response.Content) allMsgs := collectAllMessages(future.GetMessages()) assert.Equal(t, 2, len(allMsgs)) responseContents := make(map[string]bool) for _, msg := range allMsgs { responseContents[msg.Content] = true } assert.True(t, responseContents["agent1 response"]) assert.True(t, responseContents["agent2 response"]) }) } type agentAsTool struct { agent *Agent name string desc string } func (a *agentAsTool) Info(ctx context.Context) (*schema.ToolInfo, error) { return &schema.ToolInfo{ Name: a.name, Desc: a.desc, ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ "query": { Type: "string", Desc: "The query to send to the inner agent", }, }), }, nil } func (a *agentAsTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) { response, err := a.agent.Generate(ctx, []*schema.Message{ schema.UserMessage(argumentsInJSON), }) if err != nil { return "", err } return response.Content, nil } func collectAllMessages(iter *Iterator[*schema.Message]) []*schema.Message { var msgs []*schema.Message for { msg, hasNext, err := iter.Next() if err != nil || !hasNext { break } msgs = append(msgs, msg) } return msgs } func concatStreamMsg(sr *schema.StreamReader[*schema.Message]) (*schema.Message, error) { var result *schema.Message for { chunk, err := sr.Recv() if err == io.EOF { break } if err != nil { return nil, err } if result == nil { result = chunk } else { result.Content += chunk.Content if len(chunk.ToolCalls) > 0 { result.ToolCalls = append(result.ToolCalls, chunk.ToolCalls...) } } } return result, nil }