Repository: cloudwego/eino
Pull request #157: feat: add ToolCallingChatModel interface & react agent: add MessageFuture option
Author: login: luohq-bytedance
Created: 2025-04-10T08:58:49Z | Merged: 2025-04-15T14:24:28Z

Change-Id: Iff947c8c64754aa8a0fe48131d9dad1ee988da00

#### What type of PR is this?
<!--
Add one of the following kinds:

build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
docs: Documentation only changes
feat: A new feature
optimize: A new optimization
fix: A bug fix
perf: A code change that improves performance
refactor: A code change that neither fixes a bug nor adds a feature
style: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc)
test: Adding missing tests or correcting existing tests
chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
-->

#### Check the PR title.
<!--
The description of the title will be attached in Release Notes, 
so please describe it from user-oriented, what this PR does / why we need it.
Please check your PR title with the below requirements:
-->
- [ ] This PR title match the format: \<type\>(optional scope): \<description\>
- [ ] The description of this PR title is user-oriented and clear enough for others to understand.
- [ ] Attach the PR updating the user documentation if the current PR requires user awareness at the usage level. [User docs repo](https://github.com/cloudwego/cloudwego.github.io)


#### (Optional) Translate the PR title into Chinese.


#### (Optional) More detailed description for this PR(en: English/zh: Chinese).
<!--
Provide more detailed info for review(e.g., it's recommended to provide perf data if this is a perf type PR).
-->
en:
zh(optional): 


#### (Optional) Which issue(s) this PR fixes:
<!--
Automatically closes linked issue when PR is merged.
Eg: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->

#### (optional) The PR that updates user documentation:
<!--
If the current PR requires user awareness at the usage level, please submit a PR to update user docs. [User docs repo](https://github.com/cloudwego/cloudwego.github.io)
-->



Diff:
diff --git a/components/model/interface.go b/components/model/interface.go
index 220473fb0..e0786d85d 100644
--- a/components/model/interface.go
+++ b/components/model/interface.go
@@ -22,17 +22,35 @@ import (
 	"github.com/cloudwego/eino/schema"
 )
 
-// ChatModel support openai and maas.
-// use Generate for completed output, use Stream as for stream output.
+// BaseChatModel defines the basic interface for chat models.
+// It provides methods for generating complete outputs and streaming outputs.
+// This interface serves as the foundation for all chat model implementations.
 //
 //go:generate  mockgen -destination ../../internal/mock/components/model/ChatModel_mock.go --package model -source interface.go
-type ChatModel interface {
+type BaseChatModel interface {
 	Generate(ctx context.Context, input []*schema.Message, opts ...Option) (*schema.Message, error)
 	Stream(ctx context.Context, input []*schema.Message, opts ...Option) (
 		*schema.StreamReader[*schema.Message], error)
+}
+
+// Deprecated: Please use ToolCallingChatModel interface instead, which provides a safer way to bind tools
+// without the concurrency issues and tool overwriting problems that may arise from the BindTools method.
+type ChatModel interface {
+	BaseChatModel
 
 	// BindTools bind tools to the model.
 	// BindTools before requesting ChatModel generally.
 	// notice the non-atomic problem of BindTools and Generate.
 	BindTools(tools []*schema.ToolInfo) error
 }
+
+// ToolCallingChatModel extends BaseChatModel with tool calling capabilities.
+// It provides a WithTools method that returns a new instance with
+// the specified tools bound, avoiding state mutation and concurrency issues.
+type ToolCallingChatModel interface {
+	BaseChatModel
+
+	// WithTools returns a new ToolCallingChatModel instance with the specified tools bound.
+	// This method does not modify the current instance, making it safer for concurrent use.
+	WithTools(tools []*schema.ToolInfo) (ToolCallingChatModel, error)
+}
diff --git a/compose/chain.go b/compose/chain.go
index 9a7a7ac3e..0f9c520d7 100644
--- a/compose/chain.go
+++ b/compose/chain.go
@@ -168,7 +168,7 @@ func (c *Chain[I, O]) Compile(ctx context.Context, opts ...GraphCompileOption) (
 //	model, err := openai.NewChatModel(ctx, config)
 //	if err != nil {...}
 //	chain.AppendChatModel(model)
-func (c *Chain[I, O]) AppendChatModel(node model.ChatModel, opts ...GraphAddNodeOpt) *Chain[I, O] {
+func (c *Chain[I, O]) AppendChatModel(node model.BaseChatModel, opts ...GraphAddNodeOpt) *Chain[I, O] {
 	gNode, options := toChatModelNode(node, opts...)
 	c.addNode(gNode, options)
 	return c
diff --git a/compose/chain_branch.go b/compose/chain_branch.go
index 74a90c13f..e7ca34147 100644
--- a/compose/chain_branch.go
+++ b/compose/chain_branch.go
@@ -137,7 +137,7 @@ func NewStreamChainBranch[T any](cond StreamGraphBranchCondition[T]) *ChainBranc
 //	})
 //	cb.AddChatModel("chat_model_key_01", chatModel01)
 //	cb.AddChatModel("chat_model_key_02", chatModel02)
-func (cb *ChainBranch) AddChatModel(key string, node model.ChatModel, opts ...GraphAddNodeOpt) *ChainBranch {
+func (cb *ChainBranch) AddChatModel(key string, node model.BaseChatModel, opts ...GraphAddNodeOpt) *ChainBranch {
 	gNode, options := toChatModelNode(node, opts...)
 	return cb.addNode(key, gNode, options)
 }
diff --git a/compose/chain_parallel.go b/compose/chain_parallel.go
index 049bd596d..65b252dd4 100644
--- a/compose/chain_parallel.go
+++ b/compose/chain_parallel.go
@@ -65,7 +65,7 @@ type Parallel struct {
 //
 //	p.AddChatModel("output_key01", chatModel01)
 //	p.AddChatModel("output_key02", chatModel02)
-func (p *Parallel) AddChatModel(outputKey string, node model.ChatModel, opts ...GraphAddNodeOpt) *Parallel {
+func (p *Parallel) AddChatModel(outputKey string, node model.BaseChatModel, opts ...GraphAddNodeOpt) *Parallel {
 	gNode, options := toChatModelNode(node, append(opts, WithOutputKey(outputKey))...)
 	return p.addNode(outputKey, gNode, options)
 }
diff --git a/compose/component_to_graph_node.go b/compose/component_to_graph_node.go
index f896df27b..bec2aa216 100644
--- a/compose/component_to_graph_node.go
+++ b/compose/component_to_graph_node.go
@@ -90,7 +90,7 @@ func toIndexerNode(node indexer.Indexer, opts ...GraphAddNodeOpt) (*graphNode, *
 		opts...)
 }
 
-func toChatModelNode(node model.ChatModel, opts ...GraphAddNodeOpt) (*graphNode, *graphAddNodeOpts) {
+func toChatModelNode(node model.BaseChatModel, opts ...GraphAddNodeOpt) (*graphNode, *graphAddNodeOpts) {
 	return toComponentNode(
 		node,
 		components.ComponentOfChatModel,
diff --git a/compose/graph.go b/compose/graph.go
index e7f09a38c..5bd7d9828 100644
--- a/compose/graph.go
+++ b/compose/graph.go
@@ -338,7 +338,7 @@ func (g *graph) AddIndexerNode(key string, node indexer.Indexer, opts ...GraphAd
 	return g.addNode(key, gNode, options)
 }
 
-// AddChatModelNode add node that implements model.ChatModel.
+// AddChatModelNode add node that implements model.BaseChatModel.
 // e.g.
 //
 //	chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
@@ -346,7 +346,7 @@ func (g *graph) AddIndexerNode(key string, node indexer.Indexer, opts ...GraphAd
 //	})
 //
 //	graph.AddChatModelNode("chat_model_node_key", chatModel)
-func (g *graph) AddChatModelNode(key string, node model.ChatModel, opts ...GraphAddNodeOpt) error {
+func (g *graph) AddChatModelNode(key string, node model.BaseChatModel, opts ...GraphAddNodeOpt) error {
 	gNode, options := toChatModelNode(node, opts...)
 	return g.addNode(key, gNode, options)
 }
diff --git a/compose/workflow.go b/compose/workflow.go
index f17b003f9..61807a034 100644
--- a/compose/workflow.go
+++ b/compose/workflow.go
@@ -82,7 +82,7 @@ func (wf *Workflow[I, O]) Compile(ctx context.Context, opts ...GraphCompileOptio
 	return compileAnyGraph[I, O](ctx, wf, opts...)
 }
 
-func (wf *Workflow[I, O]) AddChatModelNode(key string, chatModel model.ChatModel, opts ...GraphAddNodeOpt) *WorkflowNode {
+func (wf *Workflow[I, O]) AddChatModelNode(key string, chatModel model.BaseChatModel, opts ...GraphAddNodeOpt) *WorkflowNode {
 	_ = wf.g.AddChatModelNode(key, chatModel, opts...)
 	return wf.initNode(key)
 }
diff --git a/flow/agent/multiagent/host/compose.go b/flow/agent/multiagent/host/compose.go
index 83f61087d..89b4945b6 100644
--- a/flow/agent/multiagent/host/compose.go
+++ b/flow/agent/multiagent/host/compose.go
@@ -22,6 +22,7 @@ import (
 
 	"github.com/cloudwego/eino/components/model"
 	"github.com/cloudwego/eino/compose"
+	"github.com/cloudwego/eino/flow/agent"
 	"github.com/cloudwego/eino/schema"
 )
 
@@ -88,20 +89,25 @@ func NewMultiAgent(ctx context.Context, config *MultiAgentConfig) (*MultiAgent,
 		agentMap[specialist.Name] = true
 	}
 
-	if err := addHostAgent(config.Host.ChatModel, hostPrompt, agentTools, g); err != nil {
+	chatModel, err := agent.ChatModelWithTools(config.Host.ChatModel, config.Host.ToolCallingModel, agentTools)
+	if err != nil {
+		return nil, err
+	}
+
+	if err = addHostAgent(chatModel, hostPrompt, g); err != nil {
 		return nil, err
 	}
 
 	const convertorName = "msg2MsgList"
-	if err := g.AddLambdaNode(convertorName, compose.ToList[*schema.Message](), compose.WithNodeName("converter")); err != nil {
+	if err = g.AddLambdaNode(convertorName, compose.ToList[*schema.Message](), compose.WithNodeName("converter")); err != nil {
 		return nil, err
 	}
 
-	if err := addDirectAnswerBranch(convertorName, g, toolCallChecker); err != nil {
+	if err = addDirectAnswerBranch(convertorName, g, toolCallChecker); err != nil {
 		return nil, err
 	}
 
-	if err := addSpecialistsBranch(convertorName, agentMap, g); err != nil {
+	if err = addSpecialistsBranch(convertorName, agentMap, g); err != nil {
 		return nil, err
 	}
 
@@ -149,11 +155,7 @@ func addSpecialistAgent(specialist *Specialist, g *compose.Graph[[]*schema.Messa
 	return g.AddEdge(specialist.Name, compose.END)
 }
 
-func addHostAgent(model model.ChatModel, prompt string, agentTools []*schema.ToolInfo, g *compose.Graph[[]*schema.Message, *schema.Message]) error {
-	if err := model.BindTools(agentTools); err != nil {
-		return err
-	}
-
+func addHostAgent(model model.BaseChatModel, prompt string, g *compose.Graph[[]*schema.Message, *schema.Message]) error {
 	preHandler := func(_ context.Context, input []*schema.Message, state *state) ([]*schema.Message, error) {
 		state.msgs = input
 		if len(prompt) == 0 {
diff --git a/flow/agent/multiagent/host/types.go b/flow/agent/multiagent/host/types.go
index c7f38c4e6..335166665 100644
--- a/flow/agent/multiagent/host/types.go
+++ b/flow/agent/multiagent/host/types.go
@@ -139,6 +139,9 @@ func (am AgentMeta) validate() error {
 // Host is the host agent within a multi-agent system.
 // Currently, it can only be a model.ChatModel.
 type Host struct {
+	ToolCallingModel model.ToolCallingChatModel
+	// Deprecated: ChatModel is deprecated, please use ToolCallingModel instead.
+	// This field will be removed in a future release.
 	ChatModel    model.ChatModel
 	SystemPrompt string
 }
diff --git a/flow/agent/react/option.go b/flow/agent/react/option.go
index 26bfb520f..8beafd687 100644
--- a/flow/agent/react/option.go
+++ b/flow/agent/react/option.go
@@ -17,12 +17,216 @@
 package react
 
 import (
+	"context"
+
+	"github.com/cloudwego/eino/callbacks"
+	"github.com/cloudwego/eino/components/model"
 	"github.com/cloudwego/eino/components/tool"
 	"github.com/cloudwego/eino/compose"
 	"github.com/cloudwego/eino/flow/agent"
+	"github.com/cloudwego/eino/internal"
+	"github.com/cloudwego/eino/schema"
+	ub "github.com/cloudwego/eino/utils/callbacks"
 )
 
 // WithToolOptions returns an agent option that specifies tool.Option for the tools in agent.
 func WithToolOptions(opts ...tool.Option) agent.AgentOption {
 	return agent.WithComposeOptions(compose.WithToolsNodeOption(compose.WithToolOption(opts...)))
 }
+
+type Iterator[T any] struct {
+	ch *internal.UnboundedChan[item[T]]
+}
+
+func (iter *Iterator[T]) Next() (T, bool, error) {
+	ch := iter.ch
+	if ch == nil {
+		var zero T
+		return zero, false, nil
+	}
+
+	i, ok := ch.Receive()
+	if !ok {
+		var zero T
+		return zero, false, nil
+	}
+
+	return i.v, true, i.err
+}
+
+type MessageFuture interface {
+	// GetMessages returns an iterator for retrieving messages generated during "agent.Generate" calls.
+	GetMessages() *Iterator[*schema.Message]
+
+	// GetMessageStreams returns an iterator for retrieving streaming messages generated during "agent.Stream" calls.
+	GetMessageStreams() *Iterator[*schema.StreamReader[*schema.Message]]
+}
+
+// WithMessageFuture returns an agent option and a MessageFuture interface instance.
+// The option configures the agent to collect messages generated during execution,
+// while the MessageFuture interface allows users to asynchronously retrieve these messages.
+func WithMessageFuture() (agent.AgentOption, MessageFuture) {
+	h := &cbHandler{started: make(chan struct{})}
+
+	cmHandler := &ub.ModelCallbackHandler{
+		OnEnd:                 h.onChatModelEnd,
+		OnEndWithStreamOutput: h.onChatModelEndWithStreamOutput,
+	}
+	toolHandler := &ub.ToolCallbackHandler{
+		OnEnd:                 h.onToolEnd,
+		OnEndWithStreamOutput: h.onToolEndWithStreamOutput,
+	}
+	graphHandler := callbacks.NewHandlerBuilder().
+		OnStartFn(h.onGraphStart).
+		OnStartWithStreamInputFn(h.onGraphStartWithStreamInput).
+		OnEndFn(h.onGraphEnd).
+		OnEndWithStreamOutputFn(h.onGraphEndWithStreamOutput).
+		OnErrorFn(h.onGraphError).Build()
+	cb := ub.NewHandlerHelper().ChatModel(cmHandler).Tool(toolHandler).Graph(graphHandler).Handler()
+
+	option := agent.WithComposeOptions(compose.WithCallbacks(cb))
+
+	return option, h
+}
+
+type item[T any] struct {
+	v   T
+	err error
+}
+
+type cbHandler struct {
+	msgs  *internal.UnboundedChan[item[*schema.Message]]
+	sMsgs *internal.UnboundedChan[item[*schema.StreamReader[*schema.Message]]]
+
+	started chan struct{}
+}
+
+func (h *cbHandler) GetMessages() *Iterator[*schema.Message] {
+	<-h.started
+
+	return &Iterator[*schema.Message]{ch: h.msgs}
+}
+
+func (h *cbHandler) GetMessageStreams() *Iterator[*schema.StreamReader[*schema.Message]] {
+	<-h.started
+
+	return &Iterator[*schema.StreamReader[*schema.Message]]{ch: h.sMsgs}
+}
+
+func (h *cbHandler) onChatModelEnd(ctx context.Context,
+	_ *callbacks.RunInfo, input *model.CallbackOutput) context.Context {
+
+	h.sendMessage(input.Message)
+
+	return ctx
+}
+
+func (h *cbHandler) onChatModelEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, input *schema.StreamReader[*model.CallbackOutput]) context.Context {
+
+	c := func(output *model.CallbackOutput) (*schema.Message, error) {
+		return output.Message, nil
+	}
+	s := schema.StreamReaderWithConvert(input, c)
+
+	h.sendMessageStream(s)
+
+	return ctx
+}
+
+func (h *cbHandler) onToolEnd(ctx context.Context,
+	_ *callbacks.RunInfo, input *tool.CallbackOutput) context.Context {
+
+	toolCallID := compose.GetToolCallID(ctx)
+	msg := schema.ToolMessage(input.Response, toolCallID)
+
+	h.sendMessage(msg)
+
+	return ctx
+}
+
+func (h *cbHandler) onToolEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, input *schema.StreamReader[*tool.CallbackOutput]) context.Context {
+
+	toolCallID := compose.GetToolCallID(ctx)
+	c := func(output *tool.CallbackOutput) (*schema.Message, error) {
+		return schema.ToolMessage(output.Response, toolCallID), nil
+	}
+	s := schema.StreamReaderWithConvert(input, c)
+
+	h.sendMessageStream(s)
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphError(ctx context.Context,
+	_ *callbacks.RunInfo, err error) context.Context {
+
+	if h.msgs != nil {
+		h.msgs.Send(item[*schema.Message]{err: err})
+	} else {
+		h.sMsgs.Send(item[*schema.StreamReader[*schema.Message]]{err: err})
+	}
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphEnd(ctx context.Context,
+	_ *callbacks.RunInfo, _ callbacks.CallbackOutput) context.Context {
+
+	h.msgs.Close()
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, _ *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
+
+	h.sMsgs.Close()
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphStart(ctx context.Context,
+	_ *callbacks.RunInfo, _ callbacks.CallbackInput) context.Context {
+
+	h.msgs = internal.NewUnboundedChan[item[*schema.Message]]()
+
+	close(h.started)
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphStartWithStreamInput(ctx context.Context, _ *callbacks.RunInfo,
+	_ *schema.StreamReader[callbacks.CallbackInput]) context.Context {
+
+	h.sMsgs = internal.NewUnboundedChan[item[*schema.StreamReader[*schema.Message]]]()
+
+	close(h.started)
+
+	return ctx
+}
+
+func (h *cbHandler) sendMessage(msg *schema.Message) {
+	if h.msgs != nil {
+		h.msgs.Send(item[*schema.Message]{v: msg})
+	} else {
+		sMsg := schema.StreamReaderFromArray([]*schema.Message{msg})
+		h.sMsgs.Send(item[*schema.StreamReader[*schema.Message]]{v: sMsg})
+	}
+}
+
+func (h *cbHandler) sendMessageStream(sMsg *schema.StreamReader[*schema.Message]) {
+	if h.sMsgs != nil {
+		h.sMsgs.Send(item[*schema.StreamReader[*schema.Message]]{v: sMsg})
+	} else {
+		// concat
+		msg, err := schema.ConcatMessageStream(sMsg)
+
+		if err != nil {
+			h.msgs.Send(item[*schema.Message]{err: err})
+		} else {
+			h.msgs.Send(item[*schema.Message]{v: msg})
+		}
+	}
+}
diff --git a/flow/agent/react/option_test.go b/flow/agent/react/option_test.go
index 85b5c00d9..c32824b52 100644
--- a/flow/agent/react/option_test.go
+++ b/flow/agent/react/option_test.go
@@ -17,26 +17,353 @@
 package react
 
 import (
+	"context"
+	"sync"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"go.uber.org/mock/gomock"
 
 	"github.com/cloudwego/eino/components/tool"
-	"github.com/cloudwego/eino/flow/agent"
+	"github.com/cloudwego/eino/compose"
+	mockModel "github.com/cloudwego/eino/internal/mock/components/model"
+	"github.com/cloudwego/eino/schema"
 )
 
-type myToolOptions struct {
-	field1 string
-}
+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)
 
-func WithField1(field1 string) tool.Option {
-	return tool.WrapImplSpecificOptFn(func(o *myToolOptions) {
-		o.field1 = field1
+		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()
 
-func TestGetComposeOptions(t *testing.T) {
-	opt := WithToolOptions(WithField1("field1"))
-	options := agent.GetComposeOptions(opt)
-	assert.Equal(t, 1, len(options))
+		// 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()
+	})
 }
diff --git a/flow/agent/react/react.go b/flow/agent/react/react.go
index 38b7a161c..0f6629a43 100644
--- a/flow/agent/react/react.go
+++ b/flow/agent/react/react.go
@@ -41,8 +41,13 @@ type MessageModifier func(ctx context.Context, input []*schema.Message) []*schem
 
 // AgentConfig is the config for ReAct agent.
 type AgentConfig struct {
-	// Model is the chat model to be used for handling user messages.
+	// ToolCallingModel is the chat model to be used for handling user messages with tool calling capability.
+	// This is the recommended model field to use.
+	ToolCallingModel model.ToolCallingChatModel
+
+	// Deprecated: Use ToolCallingModel instead.
 	Model model.ChatModel
+
 	// ToolsConfig is the config for tools node.
 	ToolsConfig compose.ToolsNodeConfig
 
@@ -155,7 +160,7 @@ type Agent struct {
 // In such cases, you need to implement a custom StreamToolCallChecker that can properly detect tool calls.
 func NewAgent(ctx context.Context, config *AgentConfig) (_ *Agent, err error) {
 	var (
-		chatModel       = config.Model
+		chatModel       model.BaseChatModel
 		toolsNode       *compose.ToolsNode
 		toolInfos       []*schema.ToolInfo
 		toolCallChecker = config.StreamToolCallChecker
@@ -170,7 +175,7 @@ func NewAgent(ctx context.Context, config *AgentConfig) (_ *Agent, err error) {
 		return nil, err
 	}
 
-	if err = chatModel.BindTools(toolInfos); err != nil {
+	if chatModel, err = agent.ChatModelWithTools(config.Model, config.ToolCallingModel, toolInfos); err != nil {
 		return nil, err
 	}
 
diff --git a/flow/agent/utils.go b/flow/agent/utils.go
new file mode 100644
index 000000000..801f9245a
--- /dev/null
+++ b/flow/agent/utils.go
@@ -0,0 +1,43 @@
+/*
+ * 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 agent
+
+import (
+	"errors"
+
+	"github.com/cloudwego/eino/components/model"
+	"github.com/cloudwego/eino/schema"
+)
+
+func ChatModelWithTools(model_ model.ChatModel, toolCallingModel model.ToolCallingChatModel,
+	toolInfos []*schema.ToolInfo) (model.BaseChatModel, error) {
+
+	if toolCallingModel != nil {
+		return toolCallingModel.WithTools(toolInfos)
+	}
+
+	if model_ != nil {
+		err := model_.BindTools(toolInfos)
+		if err != nil {
+			return nil, err
+		}
+
+		return model_, nil
+	}
+
+	return nil, errors.New("no chat model provided")
+}
diff --git a/internal/channel.go b/internal/channel.go
new file mode 100644
index 000000000..2351c87e9
--- /dev/null
+++ b/internal/channel.go
@@ -0,0 +1,78 @@
+/*
+ * 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 internal
+
+import "sync"
+
+// UnboundedChan represents a channel with unlimited capacity
+type UnboundedChan[T any] struct {
+	buffer   []T        // Internal buffer to store data
+	mutex    sync.Mutex // Mutex to protect buffer access
+	notEmpty *sync.Cond // Condition variable to wait for data
+	closed   bool       // Indicates if the channel has been closed
+}
+
+// NewUnboundedChan initializes and returns an UnboundedChan
+func NewUnboundedChan[T any]() *UnboundedChan[T] {
+	ch := &UnboundedChan[T]{}
+	ch.notEmpty = sync.NewCond(&ch.mutex)
+	return ch
+}
+
+// Send puts an item into the channel
+func (ch *UnboundedChan[T]) Send(value T) {
+	ch.mutex.Lock()
+	defer ch.mutex.Unlock()
+
+	if ch.closed {
+		panic("send on closed channel")
+	}
+
+	ch.buffer = append(ch.buffer, value)
+	ch.notEmpty.Signal() // Wake up one goroutine waiting to receive
+}
+
+// Receive gets an item from the channel (blocks if empty)
+func (ch *UnboundedChan[T]) Receive() (T, bool) {
+	ch.mutex.Lock()
+	defer ch.mutex.Unlock()
+
+	for len(ch.buffer) == 0 && !ch.closed {
+		ch.notEmpty.Wait() // Wait until data is available
+	}
+
+	if len(ch.buffer) == 0 {
+		// Channel is closed and empty
+		var zero T
+		return zero, false
+	}
+
+	val := ch.buffer[0]
+	ch.buffer = ch.buffer[1:]
+	return val, true
+}
+
+// Close marks the channel as closed
+func (ch *UnboundedChan[T]) Close() {
+	ch.mutex.Lock()
+	defer ch.mutex.Unlock()
+
+	if !ch.closed {
+		ch.closed = true
+		ch.notEmpty.Broadcast() // Wake up all waiting goroutines
+	}
+}
diff --git a/internal/channel_test.go b/internal/channel_test.go
new file mode 100644
index 000000000..736a27413
--- /dev/null
+++ b/internal/channel_test.go
@@ -0,0 +1,221 @@
+/*
+ * 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 internal
+
+import (
+	"sync"
+	"testing"
+	"time"
+)
+
+func TestUnboundedChan_Send(t *testing.T) {
+	ch := NewUnboundedChan[string]()
+
+	// Test sending a value
+	ch.Send("test")
+	if len(ch.buffer) != 1 {
+		t.Errorf("buffer length should be 1, got %d", len(ch.buffer))
+	}
+	if ch.buffer[0] != "test" {
+		t.Errorf("expected 'test', got '%s'", ch.buffer[0])
+	}
+
+	// Test sending multiple values
+	ch.Send("test2")
+	ch.Send("test3")
+	if len(ch.buffer) != 3 {
+		t.Errorf("buffer length should be 3, got %d", len(ch.buffer))
+	}
+}
+
+func TestUnboundedChan_SendPanic(t *testing.T) {
+	ch := NewUnboundedChan[int]()
+	ch.Close()
+
+	// Test sending to closed channel should panic
+	defer func() {
+		if r := recover(); r == nil {
+			t.Error("sending to closed channel should panic")
+		}
+	}()
+
+	ch.Send(1)
+}
+
+func TestUnboundedChan_Receive(t *testing.T) {
+	ch := NewUnboundedChan[int]()
+
+	// Send values
+	ch.Send(1)
+	ch.Send(2)
+
+	// Test receiving values
+	val, ok := ch.Receive()
+	if !ok {
+		t.Error("receive should succeed")
+	}
+	if val != 1 {
+		t.Errorf("expected 1, got %d", val)
+	}
+
+	val, ok = ch.Receive()
+	if !ok {
+		t.Error("receive should succeed")
+	}
+	if val != 2 {
+		t.Errorf("expected 2, got %d", val)
+	}
+}
+
+func TestUnboundedChan_ReceiveFromClosed(t *testing.T) {
+	ch := NewUnboundedChan[int]()
+	ch.Close()
+
+	// Test receiving from closed, empty channel
+	val, ok := ch.Receive()
+	if ok {
+		t.Error("receive from closed, empty channel should return ok=false")
+	}
+	if val != 0 {
+		t.Errorf("expected zero value, got %d", val)
+	}
+
+	// Test receiving from closed channel with values
+	ch = NewUnboundedChan[int]()
+	ch.Send(42)
+	ch.Close()
+
+	val, ok = ch.Receive()
+	if !ok {
+		t.Error("receive should succeed")
+	}
+	if val != 42 {
+		t.Errorf("expected 42, got %d", val)
+	}
+
+	// After consuming all values
+	val, ok = ch.Receive()
+	if ok {
+		t.Error("receive from closed, empty channel should return ok=false")
+	}
+}
+
+func TestUnboundedChan_Close(t *testing.T) {
+	ch := NewUnboundedChan[int]()
+
+	// Test closing
+	ch.Close()
+	if !ch.closed {
+		t.Error("channel should be marked as closed")
+	}
+
+	// Test double closing (should not panic)
+	ch.Close()
+}
+
+func TestUnboundedChan_Concurrency(t *testing.T) {
+	ch := NewUnboundedChan[int]()
+	const numSenders = 5
+	const numReceivers = 3
+	const messagesPerSender = 100
+
+	var rwg, swg sync.WaitGroup
+	rwg.Add(numReceivers)
+	swg.Add(numSenders)
+
+	// Start senders
+	for i := 0; i < numSenders; i++ {
+		go func(id int) {
+			defer swg.Done()
+			for j := 0; j < messagesPerSender; j++ {
+				ch.Send(id*messagesPerSender + j)
+				time.Sleep(time.Microsecond) // Small delay to increase concurrency chance
+			}
+		}(i)
+	}
+
+	// Start receivers
+	received := make([]int, 0, numSenders*messagesPerSender)
+	var mu sync.Mutex
+
+	for i := 0; i < numReceivers; i++ {
+		go func() {
+			defer rwg.Done()
+			for {
+				val, ok := ch.Receive()
+				if !ok {
+					return
+				}
+				mu.Lock()
+				received = append(received, val)
+				mu.Unlock()
+			}
+		}()
+	}
+
+	// Wait for senders to finish
+	swg.Wait()
+	ch.Close()
+
+	// Wait for all goroutines to finish
+	rwg.Wait()
+
+	// Verify we received all messages
+	if len(received) != numSenders*messagesPerSender {
+		t.Errorf("expected %d messages, got %d", numSenders*messagesPerSender, len(received))
+	}
+
+	// Create a map to check for duplicates and missing values
+	receivedMap := make(map[int]bool)
+	for _, val := range received {
+		receivedMap[val] = true
+	}
+
+	if len(receivedMap) != numSenders*messagesPerSender {
+		t.Error("duplicate or missing messages detected")
+	}
+}
+
+func TestUnboundedChan_BlockingReceive(t *testing.T) {
+	ch := NewUnboundedChan[int]()
+
+	// Test that Receive blocks when channel is empty
+	receiveDone := make(chan bool)
+	go func() {
+		ch.Receive()
+		receiveDone <- true
+	}()
+
+	// Check that receive is blocked
+	select {
+	case <-receiveDone:
+		t.Error("Receive should block on empty channel")
+	case <-time.After(50 * time.Millisecond):
+		// This is expected
+	}
+
+	// Send a value to unblock
+	ch.Send(1)
+
+	// Now receive should complete
+	select {
+	case <-receiveDone:
+		// This is expected
+	case <-time.After(50 * time.Millisecond):
+		t.Error("Receive should have unblocked")
+	}
+}
diff --git a/internal/mock/components/model/ChatModel_mock.go b/internal/mock/components/model/ChatModel_mock.go
index b56dcc17e..c597d7b46 100644
--- a/internal/mock/components/model/ChatModel_mock.go
+++ b/internal/mock/components/model/ChatModel_mock.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2024 CloudWeGo Authors
+ * 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.
@@ -34,10 +34,75 @@ import (
 	gomock "go.uber.org/mock/gomock"
 )
 
+// MockBaseChatModel is a mock of BaseChatModel interface.
+type MockBaseChatModel struct {
+	ctrl     *gomock.Controller
+	recorder *MockBaseChatModelMockRecorder
+	isgomock struct{}
+}
+
+// MockBaseChatModelMockRecorder is the mock recorder for MockBaseChatModel.
+type MockBaseChatModelMockRecorder struct {
+	mock *MockBaseChatModel
+}
+
+// NewMockBaseChatModel creates a new mock instance.
+func NewMockBaseChatModel(ctrl *gomock.Controller) *MockBaseChatModel {
+	mock := &MockBaseChatModel{ctrl: ctrl}
+	mock.recorder = &MockBaseChatModelMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockBaseChatModel) EXPECT() *MockBaseChatModelMockRecorder {
+	return m.recorder
+}
+
+// Generate mocks base method.
+func (m *MockBaseChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
+	m.ctrl.T.Helper()
+	varargs := []any{ctx, input}
+	for _, a := range opts {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Generate", varargs...)
+	ret0, _ := ret[0].(*schema.Message)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Generate indicates an expected call of Generate.
+func (mr *MockBaseChatModelMockRecorder) Generate(ctx, input any, opts ...any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]any{ctx, input}, opts...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockBaseChatModel)(nil).Generate), varargs...)
+}
+
+// Stream mocks base method.
+func (m *MockBaseChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
+	m.ctrl.T.Helper()
+	varargs := []any{ctx, input}
+	for _, a := range opts {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Stream", varargs...)
+	ret0, _ := ret[0].(*schema.StreamReader[*schema.Message])
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Stream indicates an expected call of Stream.
+func (mr *MockBaseChatModelMockRecorder) Stream(ctx, input any, opts ...any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]any{ctx, input}, opts...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stream", reflect.TypeOf((*MockBaseChatModel)(nil).Stream), varargs...)
+}
+
 // MockChatModel is a mock of ChatModel interface.
 type MockChatModel struct {
 	ctrl     *gomock.Controller
 	recorder *MockChatModelMockRecorder
+	isgomock struct{}
 }
 
 // MockChatModelMockRecorder is the mock recorder for MockChatModel.
@@ -110,3 +175,82 @@ func (mr *MockChatModelMockRecorder) Stream(ctx, input any, opts ...any) *gomock
 	varargs := append([]any{ctx, input}, opts...)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stream", reflect.TypeOf((*MockChatModel)(nil).Stream), varargs...)
 }
+
+// MockToolCallingChatModel is a mock of ToolCallingChatModel interface.
+type MockToolCallingChatModel struct {
+	ctrl     *gomock.Controller
+	recorder *MockToolCallingChatModelMockRecorder
+	isgomock struct{}
+}
+
+// MockToolCallingChatModelMockRecorder is the mock recorder for MockToolCallingChatModel.
+type MockToolCallingChatModelMockRecorder struct {
+	mock *MockToolCallingChatModel
+}
+
+// NewMockToolCallingChatModel creates a new mock instance.
+func NewMockToolCallingChatModel(ctrl *gomock.Controller) *MockToolCallingChatModel {
+	mock := &MockToolCallingChatModel{ctrl: ctrl}
+	mock.recorder = &MockToolCallingChatModelMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockToolCallingChatModel) EXPECT() *MockToolCallingChatModelMockRecorder {
+	return m.recorder
+}
+
+// Generate mocks base method.
+func (m *MockToolCallingChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
+	m.ctrl.T.Helper()
+	varargs := []any{ctx, input}
+	for _, a := range opts {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Generate", varargs...)
+	ret0, _ := ret[0].(*schema.Message)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Generate indicates an expected call of Generate.
+func (mr *MockToolCallingChatModelMockRecorder) Generate(ctx, input any, opts ...any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]any{ctx, input}, opts...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockToolCallingChatModel)(nil).Generate), varargs...)
+}
+
+// Stream mocks base method.
+func (m *MockToolCallingChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
+	m.ctrl.T.Helper()
+	varargs := []any{ctx, input}
+	for _, a := range opts {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Stream", varargs...)
+	ret0, _ := ret[0].(*schema.StreamReader[*schema.Message])
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Stream indicates an expected call of Stream.
+func (mr *MockToolCallingChatModelMockRecorder) Stream(ctx, input any, opts ...any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]any{ctx, input}, opts...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stream", reflect.TypeOf((*MockToolCallingChatModel)(nil).Stream), varargs...)
+}
+
+// WithTools mocks base method.
+func (m *MockToolCallingChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "WithTools", tools)
+	ret0, _ := ret[0].(model.ToolCallingChatModel)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// WithTools indicates an expected call of WithTools.
+func (mr *MockToolCallingChatModelMockRecorder) WithTools(tools any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithTools", reflect.TypeOf((*MockToolCallingChatModel)(nil).WithTools), tools)
+}
diff --git a/schema/message.go b/schema/message.go
index 705e4f2fc..15a2ac23c 100644
--- a/schema/message.go
+++ b/schema/message.go
@@ -18,7 +18,9 @@ package schema
 
 import (
 	"context"
+	"errors"
 	"fmt"
+	"io"
 	"reflect"
 	"sort"
 	"strings"
@@ -609,13 +611,6 @@ func concatToolCalls(chunks []ToolCall) ([]ToolCall, error) {
 //
 // concatedMsg, err := ConcatMessages(msgs) // concatedMsg.Content will be full content of all messages
 func ConcatMessages(msgs []*Message) (*Message, error) {
-
-	for idx, m := range msgs {
-		if m == nil {
-			return nil, fmt.Errorf("unexpected nil chunk in message stream, index: %d", idx)
-		}
-	}
-
 	var (
 		contents   []string
 		contentLen int
@@ -624,7 +619,11 @@ func ConcatMessages(msgs []*Message) (*Message, error) {
 		extraList  = make([]map[string]any, 0, len(msgs))
 	)
 
-	for _, msg := range msgs {
+	for idx, msg := range msgs {
+		if msg == nil {
+			return nil, fmt.Errorf("unexpected nil chunk in message stream, index: %d", idx)
+		}
+
 		if msg.Role != "" {
 			if ret.Role == "" {
 				ret.Role = msg.Role
@@ -742,6 +741,32 @@ func ConcatMessages(msgs []*Message) (*Message, error) {
 	return &ret, nil
 }
 
+func ConcatMessageStream(s *StreamReader[*Message]) (*Message, error) {
+	var msgs []*Message
+	for {
+		msg, err := s.Recv()
+		if err != nil {
+			if err == io.EOF {
+				break
+			}
+
+			return nil, err
+		}
+
+		msgs = append(msgs, msg)
+	}
+
+	if len(msgs) == 0 {
+		return nil, errors.New("no messages in stream")
+	}
+
+	if len(msgs) == 1 {
+		return msgs[0], nil
+	}
+
+	return ConcatMessages(msgs)
+}
+
 // custom jinja env
 var jinjaEnvOnce sync.Once
 var jinjaEnv *gonja.Environment


Reviews:
user: login: github-actions[bot]
body: 
<!-- license-eye hidden identification -->
[license-eye](https://github.com/apache/skywalking-eyes/tree/main/cmd/license-eye) has totally checked 171 files.
| Valid | Invalid | Ignored | Fixed |
| --- | --- | --- | --- |
| 170 | 1 | 0 | 0 |
<details>
  <summary>Click to see the invalid file list</summary>

  - flow/agent/utils.go
</details>

state: COMMENTED
author_association: NONE
submitted_at: 2025-04-10T08:59:30Z
user: login: github-actions[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-10T08:59:39Z
user: login: github-actions[bot]
body: 
<!-- license-eye hidden identification -->
[license-eye](https://github.com/apache/skywalking-eyes/tree/main/cmd/license-eye) has totally checked 173 files.
| Valid | Invalid | Ignored | Fixed |
| --- | --- | --- | --- |
| 172 | 1 | 0 | 0 |
<details>
  <summary>Click to see the invalid file list</summary>

  - internal/mock/components/model/ChatModel_mock.go
</details>

state: COMMENTED
author_association: NONE
submitted_at: 2025-04-11T08:57:48Z
user: login: meguminnnnnnnnn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:33:49Z
user: login: meguminnnnnnnnn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:37:33Z
user: login: luohq-bytedance
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:39:57Z
user: login: luohq-bytedance
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:40:21Z
user: login: luohq-bytedance
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:42:29Z
user: login: meguminnnnnnnnn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T08:05:24Z
user: login: luohq-bytedance
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T08:08:49Z
user: login: meguminnnnnnnnn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T08:13:53Z
user: login: meguminnnnnnnnn
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:20:40Z

Review comments:
diff_hunk: @@ -0,0 +1,34 @@
+package agent
path: flow/agent/utils.go
user: login: github-actions[bot]
body: ```suggestion
// 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 agent
```
<!-- license-eye hidden identification -->
created_at: 2025-04-10T08:59:30Z
author_association: NONE
diff_hunk: @@ -168,7 +168,7 @@ func (c *Chain[I, O]) Compile(ctx context.Context, opts ...GraphCompileOption) (
 //	model, err := openai.NewChatModel(ctx, config)
 //	if err != nil {...}
 //	chain.AppendChatModel(model)
-func (c *Chain[I, O]) AppendChatModel(node model.ChatModel, opts ...GraphAddNodeOpt) *Chain[I, O] {
+func (c *Chain[I, O]) AppendChatModel(node model.BaseChatModel, opts ...GraphAddNodeOpt) *Chain[I, O] {
 	gNode, options := toChatModelNode(node, opts...)
path: compose/chain.go
user: login: github-actions[bot]
body: 🚨 **Breaking API Changes Detected**

Package: `github.com/cloudwego/eino/compose`

Incompatible changes:
- **(*ChainBranch).AddChatModel:** changed from func(string, github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *ChainBranch to func(string, github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *ChainBranch
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Chain[I, O]).AppendChatModel:** changed from func(github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Chain[I, O] to func(github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Chain[I, O]
- **(*Parallel).AddChatModel:** changed from func(string, github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *Parallel to func(string, github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *Parallel
- **(*Workflow[I, O]).AddChatModelNode:** changed from func(string, github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *WorkflowNode to func(string, github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *WorkflowNode
- **(*Workflow[I, O]).AddChatModelNode:** changed from func(string, github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *WorkflowNode to func(string, github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *WorkflowNode
- **(*Workflow[I, O]).AddChatModelNode:** changed from func(string, github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) *WorkflowNode to func(string, github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) *WorkflowNode
- **(*graph).AddChatModelNode:** changed from func(string, github.com/cloudwego/eino/components/model.ChatModel, ...GraphAddNodeOpt) error to func(string, github.com/cloudwego/eino/components/model.BaseChatModel, ...GraphAddNodeOpt) error

<details>
<summary>Review Guidelines</summary>

Please ensure that:
- The changes are absolutely necessary
- They are properly documented
- Migration guides are provided if needed
</details>

⚠️ Please resolve this thread after reviewing the breaking changes.
created_at: 2025-04-10T08:59:39Z
author_association: NONE
diff_hunk: @@ -1,19 +1,3 @@
-/*
- * 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.
- */
-
 // Code generated by MockGen. DO NOT EDIT.
path: internal/mock/components/model/ChatModel_mock.go
user: login: github-actions[bot]
body: ```suggestion
// 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.

// Code generated by MockGen. DO NOT EDIT.
```
<!-- license-eye hidden identification -->
created_at: 2025-04-11T08:57:48Z
author_association: NONE
diff_hunk: @@ -88,20 +89,25 @@ func NewMultiAgent(ctx context.Context, config *MultiAgentConfig) (*MultiAgent,
 		agentMap[specialist.Name] = true
 	}
 
-	if err := addHostAgent(config.Host.ChatModel, hostPrompt, agentTools, g); err != nil {
+	chatModel, err := agent.ChatModelWithTools(config.Host.ChatModel, config.Host.ToolCallingModel, agentTools)
path: flow/agent/multiagent/host/compose.go
user: login: meguminnnnnnnnn
body: 这个传了 ChatModel 但是先尝试 WithTool 感觉容易出问题，已经传了 ChatModel 应该直接 BindTool 吧
created_at: 2025-04-15T07:26:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+/*
+ * 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 agent
+
+import (
+	"errors"
+
+	"github.com/cloudwego/eino/components/model"
+	"github.com/cloudwego/eino/schema"
+)
+
+// ChatModelWithTools returns a BaseChatModel with tools bound. It prioritizes toolCallingModel over model_.
+// If toolCallingModel is provided, it will be used with WithTools method.
+// If only model_ is provided, it will try to cast it to ToolCallingChatModel first, otherwise use BindTools.
+func ChatModelWithTools(model_ model.ChatModel, toolCallingModel model.ToolCallingChatModel,
path: flow/agent/utils.go
user: login: meguminnnnnnnnn
body: 这个要导出吗
created_at: 2025-04-15T07:26:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,12 +17,216 @@
 package react
 
 import (
+	"context"
+
+	"github.com/cloudwego/eino/callbacks"
+	"github.com/cloudwego/eino/components/model"
 	"github.com/cloudwego/eino/components/tool"
 	"github.com/cloudwego/eino/compose"
 	"github.com/cloudwego/eino/flow/agent"
+	"github.com/cloudwego/eino/internal"
+	"github.com/cloudwego/eino/schema"
+	ub "github.com/cloudwego/eino/utils/callbacks"
 )
 
 // WithToolOptions returns an agent option that specifies tool.Option for the tools in agent.
 func WithToolOptions(opts ...tool.Option) agent.AgentOption {
 	return agent.WithComposeOptions(compose.WithToolsNodeOption(compose.WithToolOption(opts...)))
 }
+
+type Iterator[T any] struct {
+	ch *internal.UnboundedChan[item[T]]
+}
+
+func (iter *Iterator[T]) Next() (T, bool, error) {
+	ch := iter.ch
+	if ch == nil {
+		var zero T
+		return zero, false, nil
+	}
+
+	i, ok := ch.Receive()
+	if !ok {
+		var zero T
+		return zero, false, nil
+	}
+
+	return i.v, true, i.err
+}
+
+type MessageFuture interface {
+	// GetMessages returns an iterator for retrieving messages generated during "agent.Generate" calls.
+	GetMessages() *Iterator[*schema.Message]
+
+	// GetMessageStreams returns an iterator for retrieving streaming messages generated during "agent.Stream" calls.
+	GetMessageStreams() *Iterator[*schema.StreamReader[*schema.Message]]
+}
+
+// WithMessageFuture returns an agent option and a MessageFuture interface instance.
+// The option configures the agent to collect messages generated during execution,
+// while the MessageFuture interface allows users to asynchronously retrieve these messages.
+func WithMessageFuture() (agent.AgentOption, MessageFuture) {
+	h := &cbHandler{started: make(chan struct{})}
+
+	cmHandler := &ub.ModelCallbackHandler{
+		OnEnd:                 h.onChatModelEnd,
+		OnEndWithStreamOutput: h.onChatModelEndWithStreamOutput,
+	}
+	toolHandler := &ub.ToolCallbackHandler{
+		OnEnd:                 h.onToolEnd,
+		OnEndWithStreamOutput: h.onToolEndWithStreamOutput,
+	}
+	graphHandler := callbacks.NewHandlerBuilder().
+		OnStartFn(h.onGraphStart).
+		OnStartWithStreamInputFn(h.onGraphStartWithStreamInput).
+		OnEndFn(h.onGraphEnd).
+		OnEndWithStreamOutputFn(h.onGraphEndWithStreamOutput).
+		OnErrorFn(h.onGraphError).Build()
+	cb := ub.NewHandlerHelper().ChatModel(cmHandler).Tool(toolHandler).Graph(graphHandler).Handler()
+
+	option := agent.WithComposeOptions(compose.WithCallbacks(cb))
+
+	return option, h
+}
+
+type item[T any] struct {
+	v   T
+	err error
+}
+
+type cbHandler struct {
+	msgs  *internal.UnboundedChan[item[*schema.Message]]
+	sMsgs *internal.UnboundedChan[item[*schema.StreamReader[*schema.Message]]]
+
+	started chan struct{}
+}
+
+func (h *cbHandler) GetMessages() *Iterator[*schema.Message] {
+	<-h.started
+
+	return &Iterator[*schema.Message]{ch: h.msgs}
+}
+
+func (h *cbHandler) GetMessageStreams() *Iterator[*schema.StreamReader[*schema.Message]] {
+	<-h.started
+
+	return &Iterator[*schema.StreamReader[*schema.Message]]{ch: h.sMsgs}
+}
+
+func (h *cbHandler) onChatModelEnd(ctx context.Context,
+	_ *callbacks.RunInfo, input *model.CallbackOutput) context.Context {
+
+	h.sendMessage(input.Message)
+
+	return ctx
+}
+
+func (h *cbHandler) onChatModelEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, input *schema.StreamReader[*model.CallbackOutput]) context.Context {
+
+	c := func(output *model.CallbackOutput) (*schema.Message, error) {
+		return output.Message, nil
+	}
+	s := schema.StreamReaderWithConvert(input, c)
+
+	h.sendMessageStream(s)
+
+	return ctx
+}
+
+func (h *cbHandler) onToolEnd(ctx context.Context,
+	_ *callbacks.RunInfo, input *tool.CallbackOutput) context.Context {
+
+	toolCallID := compose.GetToolCallID(ctx)
+	msg := schema.ToolMessage(input.Response, toolCallID)
+
+	h.sendMessage(msg)
+
+	return ctx
+}
+
+func (h *cbHandler) onToolEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, input *schema.StreamReader[*tool.CallbackOutput]) context.Context {
+
+	toolCallID := compose.GetToolCallID(ctx)
+	c := func(output *tool.CallbackOutput) (*schema.Message, error) {
+		return schema.ToolMessage(output.Response, toolCallID), nil
+	}
+	s := schema.StreamReaderWithConvert(input, c)
+
+	h.sendMessageStream(s)
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphError(ctx context.Context,
+	_ *callbacks.RunInfo, err error) context.Context {
+
+	if h.msgs != nil {
+		h.msgs.Send(item[*schema.Message]{err: err})
+	} else {
+		h.sMsgs.Send(item[*schema.StreamReader[*schema.Message]]{err: err})
+	}
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphEnd(ctx context.Context,
+	_ *callbacks.RunInfo, _ callbacks.CallbackOutput) context.Context {
+
+	h.msgs.Close()
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, _ *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
+
+	h.sMsgs.Close()
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphStart(ctx context.Context,
+	_ *callbacks.RunInfo, _ callbacks.CallbackInput) context.Context {
+
+	h.msgs = internal.NewUnboundedChan[item[*schema.Message]]()
path: flow/agent/react/option.go
user: login: meguminnnnnnnnn
body: sMsgs也得初始化吧， 如果有个节点只有 stream 输出，应该会写 sMsgs？
created_at: 2025-04-15T07:37:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -88,20 +89,25 @@ func NewMultiAgent(ctx context.Context, config *MultiAgentConfig) (*MultiAgent,
 		agentMap[specialist.Name] = true
 	}
 
-	if err := addHostAgent(config.Host.ChatModel, hostPrompt, agentTools, g); err != nil {
+	chatModel, err := agent.ChatModelWithTools(config.Host.ChatModel, config.Host.ToolCallingModel, agentTools)
path: flow/agent/multiagent/host/compose.go
user: login: luohq-bytedance
body: 啥意思？
created_at: 2025-04-15T07:39:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+/*
+ * 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 agent
+
+import (
+	"errors"
+
+	"github.com/cloudwego/eino/components/model"
+	"github.com/cloudwego/eino/schema"
+)
+
+// ChatModelWithTools returns a BaseChatModel with tools bound. It prioritizes toolCallingModel over model_.
+// If toolCallingModel is provided, it will be used with WithTools method.
+// If only model_ is provided, it will try to cast it to ToolCallingChatModel first, otherwise use BindTools.
+func ChatModelWithTools(model_ model.ChatModel, toolCallingModel model.ToolCallingChatModel,
path: flow/agent/utils.go
user: login: luohq-bytedance
body: 是给其他的package用的
created_at: 2025-04-15T07:40:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,12 +17,216 @@
 package react
 
 import (
+	"context"
+
+	"github.com/cloudwego/eino/callbacks"
+	"github.com/cloudwego/eino/components/model"
 	"github.com/cloudwego/eino/components/tool"
 	"github.com/cloudwego/eino/compose"
 	"github.com/cloudwego/eino/flow/agent"
+	"github.com/cloudwego/eino/internal"
+	"github.com/cloudwego/eino/schema"
+	ub "github.com/cloudwego/eino/utils/callbacks"
 )
 
 // WithToolOptions returns an agent option that specifies tool.Option for the tools in agent.
 func WithToolOptions(opts ...tool.Option) agent.AgentOption {
 	return agent.WithComposeOptions(compose.WithToolsNodeOption(compose.WithToolOption(opts...)))
 }
+
+type Iterator[T any] struct {
+	ch *internal.UnboundedChan[item[T]]
+}
+
+func (iter *Iterator[T]) Next() (T, bool, error) {
+	ch := iter.ch
+	if ch == nil {
+		var zero T
+		return zero, false, nil
+	}
+
+	i, ok := ch.Receive()
+	if !ok {
+		var zero T
+		return zero, false, nil
+	}
+
+	return i.v, true, i.err
+}
+
+type MessageFuture interface {
+	// GetMessages returns an iterator for retrieving messages generated during "agent.Generate" calls.
+	GetMessages() *Iterator[*schema.Message]
+
+	// GetMessageStreams returns an iterator for retrieving streaming messages generated during "agent.Stream" calls.
+	GetMessageStreams() *Iterator[*schema.StreamReader[*schema.Message]]
+}
+
+// WithMessageFuture returns an agent option and a MessageFuture interface instance.
+// The option configures the agent to collect messages generated during execution,
+// while the MessageFuture interface allows users to asynchronously retrieve these messages.
+func WithMessageFuture() (agent.AgentOption, MessageFuture) {
+	h := &cbHandler{started: make(chan struct{})}
+
+	cmHandler := &ub.ModelCallbackHandler{
+		OnEnd:                 h.onChatModelEnd,
+		OnEndWithStreamOutput: h.onChatModelEndWithStreamOutput,
+	}
+	toolHandler := &ub.ToolCallbackHandler{
+		OnEnd:                 h.onToolEnd,
+		OnEndWithStreamOutput: h.onToolEndWithStreamOutput,
+	}
+	graphHandler := callbacks.NewHandlerBuilder().
+		OnStartFn(h.onGraphStart).
+		OnStartWithStreamInputFn(h.onGraphStartWithStreamInput).
+		OnEndFn(h.onGraphEnd).
+		OnEndWithStreamOutputFn(h.onGraphEndWithStreamOutput).
+		OnErrorFn(h.onGraphError).Build()
+	cb := ub.NewHandlerHelper().ChatModel(cmHandler).Tool(toolHandler).Graph(graphHandler).Handler()
+
+	option := agent.WithComposeOptions(compose.WithCallbacks(cb))
+
+	return option, h
+}
+
+type item[T any] struct {
+	v   T
+	err error
+}
+
+type cbHandler struct {
+	msgs  *internal.UnboundedChan[item[*schema.Message]]
+	sMsgs *internal.UnboundedChan[item[*schema.StreamReader[*schema.Message]]]
+
+	started chan struct{}
+}
+
+func (h *cbHandler) GetMessages() *Iterator[*schema.Message] {
+	<-h.started
+
+	return &Iterator[*schema.Message]{ch: h.msgs}
+}
+
+func (h *cbHandler) GetMessageStreams() *Iterator[*schema.StreamReader[*schema.Message]] {
+	<-h.started
+
+	return &Iterator[*schema.StreamReader[*schema.Message]]{ch: h.sMsgs}
+}
+
+func (h *cbHandler) onChatModelEnd(ctx context.Context,
+	_ *callbacks.RunInfo, input *model.CallbackOutput) context.Context {
+
+	h.sendMessage(input.Message)
+
+	return ctx
+}
+
+func (h *cbHandler) onChatModelEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, input *schema.StreamReader[*model.CallbackOutput]) context.Context {
+
+	c := func(output *model.CallbackOutput) (*schema.Message, error) {
+		return output.Message, nil
+	}
+	s := schema.StreamReaderWithConvert(input, c)
+
+	h.sendMessageStream(s)
+
+	return ctx
+}
+
+func (h *cbHandler) onToolEnd(ctx context.Context,
+	_ *callbacks.RunInfo, input *tool.CallbackOutput) context.Context {
+
+	toolCallID := compose.GetToolCallID(ctx)
+	msg := schema.ToolMessage(input.Response, toolCallID)
+
+	h.sendMessage(msg)
+
+	return ctx
+}
+
+func (h *cbHandler) onToolEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, input *schema.StreamReader[*tool.CallbackOutput]) context.Context {
+
+	toolCallID := compose.GetToolCallID(ctx)
+	c := func(output *tool.CallbackOutput) (*schema.Message, error) {
+		return schema.ToolMessage(output.Response, toolCallID), nil
+	}
+	s := schema.StreamReaderWithConvert(input, c)
+
+	h.sendMessageStream(s)
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphError(ctx context.Context,
+	_ *callbacks.RunInfo, err error) context.Context {
+
+	if h.msgs != nil {
+		h.msgs.Send(item[*schema.Message]{err: err})
+	} else {
+		h.sMsgs.Send(item[*schema.StreamReader[*schema.Message]]{err: err})
+	}
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphEnd(ctx context.Context,
+	_ *callbacks.RunInfo, _ callbacks.CallbackOutput) context.Context {
+
+	h.msgs.Close()
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphEndWithStreamOutput(ctx context.Context,
+	_ *callbacks.RunInfo, _ *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
+
+	h.sMsgs.Close()
+
+	return ctx
+}
+
+func (h *cbHandler) onGraphStart(ctx context.Context,
+	_ *callbacks.RunInfo, _ callbacks.CallbackInput) context.Context {
+
+	h.msgs = internal.NewUnboundedChan[item[*schema.Message]]()
path: flow/agent/react/option.go
user: login: luohq-bytedance
body: type MessageFuture interface {
	// GetMessages returns an iterator for retrieving messages generated during "agent.Generate" calls.
	GetMessages() *Iterator[*schema.Message]

	// GetMessageStreams returns an iterator for retrieving streaming messages generated during "agent.Stream" calls.
	GetMessageStreams() *Iterator[*schema.StreamReader[*schema.Message]]
}
目前是这么设计的
created_at: 2025-04-15T07:42:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -88,20 +89,25 @@ func NewMultiAgent(ctx context.Context, config *MultiAgentConfig) (*MultiAgent,
 		agentMap[specialist.Name] = true
 	}
 
-	if err := addHostAgent(config.Host.ChatModel, hostPrompt, agentTools, g); err != nil {
+	chatModel, err := agent.ChatModelWithTools(config.Host.ChatModel, config.Host.ToolCallingModel, agentTools)
path: flow/agent/multiagent/host/compose.go
user: login: meguminnnnnnnnn
body: 这之后用户传进来 BindTools 会尝试断言 WithTools，如果成功，就用 WithTools，不改变 chatmodel 状态。  但是本来 BindTools 就是改 chatmodel 状态的，用户可能会依赖的这个
created_at: 2025-04-15T08:05:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -88,20 +89,25 @@ func NewMultiAgent(ctx context.Context, config *MultiAgentConfig) (*MultiAgent,
 		agentMap[specialist.Name] = true
 	}
 
-	if err := addHostAgent(config.Host.ChatModel, hostPrompt, agentTools, g); err != nil {
+	chatModel, err := agent.ChatModelWithTools(config.Host.ChatModel, config.Host.ToolCallingModel, agentTools)
path: flow/agent/multiagent/host/compose.go
user: login: luohq-bytedance
body: 上次不就是用户觉得不应该改变chatmodel 状态才出错的。所以用户不会刻意使用这个副作用？

created_at: 2025-04-15T08:08:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -88,20 +89,25 @@ func NewMultiAgent(ctx context.Context, config *MultiAgentConfig) (*MultiAgent,
 		agentMap[specialist.Name] = true
 	}
 
-	if err := addHostAgent(config.Host.ChatModel, hostPrompt, agentTools, g); err != nil {
+	chatModel, err := agent.ChatModelWithTools(config.Host.ChatModel, config.Host.ToolCallingModel, agentTools)
path: flow/agent/multiagent/host/compose.go
user: login: meguminnnnnnnnn
body: 这样改算 break change 吧， 万一有人有问题不好解释啊， 不改有人再来问直接跟他说明白就行了吧
created_at: 2025-04-15T08:13:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: CLAassistant
created_at: 2025-04-10T08:59:00Z
body: [![CLA assistant check](https://cla-assistant.io/pull/badge/signed)](https://cla-assistant.io/cloudwego/eino?pullRequest=157) <br/>All committers have signed the CLA.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-10T09:00:50Z
body: ## 📊 Coverage Report:

```
File coverage threshold (20%) satisfied:	PASS
Package coverage threshold (30%) satisfied:	PASS
Total coverage threshold (83%) satisfied:	PASS
Total test coverage: 85.0% (4333/5098)

```
author_association: NONE