From 9835e6abd62c472615527b9a77c330bae8cf2175 Mon Sep 17 00:00:00 2001 From: "shentong.martin" Date: Thu, 18 Dec 2025 20:27:08 +0800 Subject: [PATCH] feat(react): dynamic option during execution Change-Id: I7c67d6b6244c3f37912faf35d64fa4c73bc04358 --- adk/intro/http-sse-service/go.mod | 2 +- adk/intro/http-sse-service/go.sum | 1 + .../react/dynamic_option_example/README.md | 188 +++++++++++ .../dynamic_option_example/dynamic/model.go | 146 +++++++++ .../dynamic_option_example/dynamic/state.go | 65 ++++ .../react/dynamic_option_example/main.go | 308 ++++++++++++++++++ go.mod | 4 +- go.sum | 8 +- 8 files changed, 715 insertions(+), 7 deletions(-) create mode 100644 flow/agent/react/dynamic_option_example/README.md create mode 100644 flow/agent/react/dynamic_option_example/dynamic/model.go create mode 100644 flow/agent/react/dynamic_option_example/dynamic/state.go create mode 100644 flow/agent/react/dynamic_option_example/main.go diff --git a/adk/intro/http-sse-service/go.mod b/adk/intro/http-sse-service/go.mod index 4643749..9fa3f82 100644 --- a/adk/intro/http-sse-service/go.mod +++ b/adk/intro/http-sse-service/go.mod @@ -5,7 +5,7 @@ go 1.24.9 replace github.com/cloudwego/eino-examples => ../../.. require ( - github.com/cloudwego/eino v0.7.8 + github.com/cloudwego/eino v0.7.11 github.com/cloudwego/eino-examples v0.0.0-00010101000000-000000000000 github.com/cloudwego/hertz v0.10.3 github.com/hertz-contrib/sse v0.1.0 diff --git a/adk/intro/http-sse-service/go.sum b/adk/intro/http-sse-service/go.sum index cb26e44..c2ade4b 100644 --- a/adk/intro/http-sse-service/go.sum +++ b/adk/intro/http-sse-service/go.sum @@ -106,6 +106,7 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/eino v0.7.8 h1:3a2j1UKZZuQ3SzqDToOI5g6lrlJ7xZEtMlNQkTgIvaI= github.com/cloudwego/eino v0.7.8/go.mod h1:nA8Vacmuqv3pqKBQbTWENBLQ8MmGmPt/WqiyLeB8ohQ= +github.com/cloudwego/eino v0.7.11/go.mod h1:nA8Vacmuqv3pqKBQbTWENBLQ8MmGmPt/WqiyLeB8ohQ= github.com/cloudwego/eino-ext/components/model/ark v0.1.45 h1:LWvSHJVlvS1S/IxN9XUKNw/MI0I7YPePt3LMNxyCrZ0= github.com/cloudwego/eino-ext/components/model/ark v0.1.45/go.mod h1:e8P5dGVI/JMQ1FYNgmu5EFRWA8fivBc6NwNJ9g8FBK8= github.com/cloudwego/eino-ext/components/model/openai v0.1.5 h1:+yvGbTPw93li9GSmdm6Rix88Yy8AXg5NNBcRbWx3CQU= diff --git a/flow/agent/react/dynamic_option_example/README.md b/flow/agent/react/dynamic_option_example/README.md new file mode 100644 index 0000000..4f4324d --- /dev/null +++ b/flow/agent/react/dynamic_option_example/README.md @@ -0,0 +1,188 @@ +# Dynamic Option Modification for ReAct Agent + +This example demonstrates how to dynamically modify `model.Option` during ReAct agent execution. The key challenge is that options passed to `agent.Generate()` or `agent.Stream()` are fixed at call time, but we may want to change options based on the current iteration, previous tool calls, or other runtime conditions. + +## Problem + +When calling a ReAct agent, the option list is passed once and applied to all ChatModel calls during the loop: + +```go +agent.Invoke(ctx, messages, opts...) // opts are fixed for all iterations +``` + +However, you may want to: +- Enable/disable extended thinking based on iteration +- Change `tool_choice` to force a final answer after N iterations +- Modify tool bindings dynamically + +## Solution + +We solve this by: +1. **Wrapping the ChatModel** with a `dynamic.ChatModel` that intercepts `Generate()`/`Stream()` calls +2. **Using Graph State** via `compose.ProcessState` to persist iteration count across the ReAct loop +3. **Wrapping the ReAct Agent** in a parent Graph that provides the state +4. **Using MessageFuture** to observe all intermediate results (reasoning, tool calls, tool results) + +### Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Parent Graph (with dynamic.State) │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ ReAct Agent (as sub-graph node) │ │ +│ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │ dynamic.ChatModel │ │ │ +│ │ │ ├─ Reads iteration from state │ │ │ +│ │ │ ├─ Calls GetOptionFunc(ctx, input, state) │ │ │ +│ │ │ ├─ Increments iteration in state │ │ │ +│ │ │ └─ Calls inner ChatModel with merged opts │ │ │ +│ │ └─────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## Components + +### State (`dynamic/state.go`) + +Holds the iteration counter and optional data for decision making: + +```go +type State struct { + Iteration int + LastToolCalls []*schema.ToolCall + CustomData map[string]any +} + +type OptionFunc func(ctx context.Context, input []*schema.Message, state *State) []model.Option +``` + +### ChatModel (`dynamic/model.go`) + +Wraps any `model.BaseChatModel` and uses `compose.ProcessState` to access the graph state: + +```go +type ChatModel struct { + Model model.BaseChatModel + GetOptionFunc OptionFunc +} +``` + +## Usage + +```go +// Wrap the ChatModel with dynamic option support +dynamicModel := &dynamic.ChatModel{ + Model: arkChatModel, + GetOptionFunc: getDynamicOptions, +} + +// Create ReAct agent with the dynamic model +rAgent, _ := react.NewAgent(ctx, &react.AgentConfig{ + ToolCallingModel: dynamicModel, + ToolsConfig: toolsConfig, +}) + +// Create parent graph with local state +parentGraph := compose.NewGraph[[]*schema.Message, *schema.Message]( + compose.WithGenLocalState(func(ctx context.Context) *dynamic.State { + return dynamic.NewState() + }), +) + +// Export and add ReAct agent as sub-graph +agentGraph, agentOpts := rAgent.ExportGraph() +_ = parentGraph.AddGraphNode("react_agent", agentGraph, agentOpts...) +_ = parentGraph.AddEdge(compose.START, "react_agent") +_ = parentGraph.AddEdge("react_agent", compose.END) + +runnable, _ := parentGraph.Compile(ctx) + +// Use MessageFuture to observe intermediate results +msgFutureOpt, msgFuture := react.WithMessageFuture() + +go func() { + // Process intermediate messages in a goroutine + iter := msgFuture.GetMessageStreams() + for { + sr, ok, _ := iter.Next() + if !ok { + break + } + // Read and print messages... + } +}() + +// Use Invoke with DesignateNode to pass options to the sub-graph +runnable.Invoke(ctx, messages, agent.GetComposeOptions(msgFutureOpt)[0].DesignateNode("react_agent")) +``` + +## Example: Dynamic Option Function + +```go +func getDynamicOptions(ctx context.Context, input []*schema.Message, state *dynamic.State) []model.Option { + var opts []model.Option + + // Control thinking mode based on iteration + if state.Iteration >= 1 { + opts = append(opts, ark.WithThinking(&arkModel.Thinking{ + Type: arkModel.ThinkingTypeDisabled, + })) + } + + // Force final answer after first iteration + if state.Iteration >= 1 { + opts = append(opts, model.WithToolChoice(schema.ToolChoiceForbidden)) + opts = append(opts, model.WithTools([]*schema.ToolInfo{})) + } else { + opts = append(opts, model.WithToolChoice(schema.ToolChoiceAllowed)) + // Bind tools for first iteration + opts = append(opts, model.WithTools(toolInfos)) + } + + return opts +} +``` + +## Observing Intermediate Results with MessageFuture + +The `react.WithMessageFuture()` function returns an option and a `MessageFuture` interface that allows you to observe all intermediate messages during agent execution: + +- **Reasoning Content**: The model's thinking process (`msg.ReasoningContent`) +- **Tool Calls**: Function calls made by the model (`msg.ToolCalls`) +- **Tool Results**: Results returned from tool execution (`msg.Role == schema.Tool`) +- **Assistant Messages**: Text responses from the model + +**Note**: When using `Invoke` instead of `Stream`, you must use `DesignateNode` to pass the MessageFuture option to the correct sub-graph node: + +```go +runnable.Invoke(ctx, messages, agent.GetComposeOptions(msgFutureOpt)[0].DesignateNode("react_agent")) +``` + +## Quick Start + +Environment variables: +- `ARK_API_KEY` +- `ARK_MODEL_NAME` + +Build and run: + +```bash +cd flow/agent/react/dynamic_option_example +go build -o dynamic_option_example . +./dynamic_option_example +``` + +## Key Design Decisions + +1. **Graph State over Context**: Context values don't propagate back from `Generate()`, so we use `compose.ProcessState` to persist state across iterations. + +2. **Wrapper Pattern**: Following the decorator pattern used elsewhere in the codebase. + +3. **Simple Function Type**: Using `OptionFunc` instead of an interface keeps the API simple and easy to understand. + +4. **Parent Graph**: The ReAct agent is wrapped as a sub-graph node in a parent graph that provides the state. + +5. **MessageFuture for Observability**: Using `react.WithMessageFuture()` to capture and display all intermediate results including reasoning, tool calls, and tool results. + +6. **Invoke with DesignateNode**: When using `Invoke` instead of `Stream`, use `DesignateNode` to ensure options are passed to the correct sub-graph node. diff --git a/flow/agent/react/dynamic_option_example/dynamic/model.go b/flow/agent/react/dynamic_option_example/dynamic/model.go new file mode 100644 index 0000000..e2e008e --- /dev/null +++ b/flow/agent/react/dynamic_option_example/dynamic/model.go @@ -0,0 +1,146 @@ +/* + * 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 dynamic + +import ( + "context" + "fmt" + "time" + + "github.com/cloudwego/eino/components" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" +) + +// ChatModel wraps a BaseChatModel and enables dynamic option modification. +// Before each Generate() or Stream() call, it: +// 1. Reads the current iteration state from the parent graph via compose.ProcessState +// 2. Calls GetOptionFunc to get dynamic options based on the current state +// 3. Increments the iteration counter +// 4. Merges dynamic options with any static options and calls the inner model +type ChatModel struct { + // Model is the underlying ChatModel to wrap + Model model.BaseChatModel + + // GetOptionFunc is called before each Generate()/Stream() call to get dynamic options + GetOptionFunc OptionFunc +} + +// Generate implements model.BaseChatModel. +// It reads state, calls GetOptionFunc, increments iteration, and delegates to the inner model. +func (d *ChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) { + var dynamicOpts []model.Option + + // Access the parent graph's state via compose.ProcessState. + // This is the key mechanism that allows state to persist across ReAct loop iterations. + // We are accessing parent graph's state here. Require eino version v0.7.11+ + err := compose.ProcessState[*State](ctx, func(_ context.Context, state *State) error { + // Small delay to ensure log ordering (optional, for demo purposes) + time.Sleep(100 * time.Millisecond) + fmt.Printf("\n==================== Iteration %d ====================\n", state.Iteration) + + // Get dynamic options based on current state + dynamicOpts = d.GetOptionFunc(ctx, input, state) + + // Increment iteration for next call + state.Iteration++ + return nil + }) + if err != nil { + // If state access fails (e.g., not running in a graph), use no dynamic options + dynamicOpts = nil + } + + // Merge dynamic options with static options (dynamic options take precedence) + mergedOpts := append(dynamicOpts, opts...) + resp, err := d.Model.Generate(ctx, input, mergedOpts...) + + // Store tool calls in state for potential use in next iteration's decision + if err == nil && resp != nil && len(resp.ToolCalls) > 0 { + _ = compose.ProcessState[*State](ctx, func(_ context.Context, state *State) error { + toolCalls := make([]*schema.ToolCall, len(resp.ToolCalls)) + for i := range resp.ToolCalls { + toolCalls[i] = &resp.ToolCalls[i] + } + state.LastToolCalls = toolCalls + return nil + }) + } + + return resp, err +} + +// Stream implements model.BaseChatModel. +// Same logic as Generate but returns a stream reader. +func (d *ChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { + var dynamicOpts []model.Option + + // We are accessing parent graph's state here. Require eino version v0.7.11+ + err := compose.ProcessState[*State](ctx, func(_ context.Context, state *State) error { + time.Sleep(100 * time.Millisecond) + fmt.Printf("\n==================== Iteration %d ====================\n", state.Iteration) + dynamicOpts = d.GetOptionFunc(ctx, input, state) + state.Iteration++ + return nil + }) + if err != nil { + dynamicOpts = nil + } + + mergedOpts := append(dynamicOpts, opts...) + return d.Model.Stream(ctx, input, mergedOpts...) +} + +// WithTools implements model.ToolCallingChatModel. +// It creates a new ChatModel wrapping the result of the inner model's WithTools. +func (d *ChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) { + tcm, ok := d.Model.(model.ToolCallingChatModel) + if !ok { + return nil, nil + } + newModel, err := tcm.WithTools(tools) + if err != nil { + return nil, err + } + return &ChatModel{ + Model: newModel, + GetOptionFunc: d.GetOptionFunc, + }, nil +} + +// IsCallbacksEnabled implements components.Checker. +// Delegates to the inner model if it implements Checker. +func (d *ChatModel) IsCallbacksEnabled() bool { + checker, ok := d.Model.(components.Checker) + if ok { + return checker.IsCallbacksEnabled() + } + return false +} + +// GetType returns the type name for this component. +func (d *ChatModel) GetType() string { + return "DynamicChatModel" +} + +// Compile-time interface checks +var ( + _ model.BaseChatModel = (*ChatModel)(nil) + _ model.ToolCallingChatModel = (*ChatModel)(nil) + _ components.Checker = (*ChatModel)(nil) +) diff --git a/flow/agent/react/dynamic_option_example/dynamic/state.go b/flow/agent/react/dynamic_option_example/dynamic/state.go new file mode 100644 index 0000000..7f62ec7 --- /dev/null +++ b/flow/agent/react/dynamic_option_example/dynamic/state.go @@ -0,0 +1,65 @@ +/* + * 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 dynamic + +import ( + "context" + + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" +) + +// State holds the iteration state that persists across ReAct loop iterations. +// It is stored in the parent graph's local state and accessed via compose.ProcessState. +type State struct { + // Iteration is the current iteration number (0-indexed). + // It is incremented after each ChatModel.Generate() call. + Iteration int + + // LastToolCalls stores the tool calls from the previous iteration. + // This can be used to make decisions based on what tools were called. + LastToolCalls []*schema.ToolCall + + // CustomData allows storing arbitrary data for custom decision logic. + CustomData map[string]any +} + +func init() { + // Register the State type for serialization support in compose graphs. + schema.RegisterName[State]("DynamicOptionState") +} + +// NewState creates a new State with default values. +func NewState() *State { + return &State{ + Iteration: 0, + CustomData: make(map[string]any), + } +} + +// OptionFunc is the function signature for dynamic option generation. +// It is called before each ChatModel.Generate() call and returns options +// to be merged with any static options passed to the agent. +// +// Parameters: +// - ctx: The context from the current request +// - input: The input messages being sent to the ChatModel +// - state: The current iteration state (can be modified) +// +// Returns: +// - A slice of model.Option to be applied to this ChatModel call +type OptionFunc func(ctx context.Context, input []*schema.Message, state *State) []model.Option diff --git a/flow/agent/react/dynamic_option_example/main.go b/flow/agent/react/dynamic_option_example/main.go new file mode 100644 index 0000000..83034f8 --- /dev/null +++ b/flow/agent/react/dynamic_option_example/main.go @@ -0,0 +1,308 @@ +/* + * 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 main + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "sync" + + "github.com/cloudwego/eino-ext/components/model/ark" + "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/flow/agent/react" + "github.com/cloudwego/eino/schema" + arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" + + "github.com/cloudwego/eino-examples/components/model/httptransport" + "github.com/cloudwego/eino-examples/flow/agent/react/dynamic_option_example/dynamic" + "github.com/cloudwego/eino-examples/flow/agent/react/tools" + "github.com/cloudwego/eino-examples/internal/logs" +) + +func main() { + arkApiKey := os.Getenv("ARK_API_KEY") + arkModelName := os.Getenv("ARK_MODEL_NAME") + + ctx := context.Background() + + // Create HTTP client with curl-style logging for debugging HTTP requests + client := &http.Client{Transport: httptransport.NewCurlRT( + http.DefaultTransport, + httptransport.WithLogger(log.Default()), + httptransport.WithCtxLogger(httptransport.IDCtxLogger{L: log.Default()}), + httptransport.WithPrintAuth(false), + httptransport.WithMaskHeaders([]string{"X-API-KEY", "API-KEY"}), + httptransport.WithStreamLogging(true), + httptransport.WithMaxStreamLogBytes(8192), + )} + + // Create Ark ChatModel with custom HTTP client + config := &ark.ChatModelConfig{ + APIKey: arkApiKey, + Model: arkModelName, + HTTPClient: client, + } + arkChatModel, err := ark.NewChatModel(ctx, config) + if err != nil { + logs.Errorf("failed to create chat model: %v", err) + return + } + + restaurantTool := tools.GetRestaurantTool() + dishTool := tools.GetDishTool() + + persona := `# Character: +你是一个帮助用户推荐餐厅和菜品的助手,根据用户的需要,查询餐厅信息并推荐,查询餐厅的菜品并推荐。 +` + + // Wrap the ChatModel with dynamic.ChatModel to enable dynamic option modification. + // The GetOptionFunc will be called before each ChatModel.Generate() call, + // allowing us to modify options based on the current iteration state. + dynamicModel := &dynamic.ChatModel{ + Model: arkChatModel, + GetOptionFunc: getDynamicOptions, + } + + // Create ReAct agent with the dynamic model + rAgent, err := react.NewAgent(ctx, &react.AgentConfig{ + ToolCallingModel: dynamicModel, + ToolsConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{restaurantTool, dishTool}, + }, + }) + if err != nil { + logs.Errorf("failed to create agent: %v", err) + return + } + + // Create a parent graph that wraps the ReAct agent. + // This parent graph provides the local state (dynamic.State) that persists + // across ReAct loop iterations. The state is accessed via compose.ProcessState + // inside the dynamic.ChatModel wrapper. + parentGraph := compose.NewGraph[[]*schema.Message, *schema.Message]( + compose.WithGenLocalState(func(ctx context.Context) *dynamic.State { + return dynamic.NewState() + }), + ) + + // Export the ReAct agent as a sub-graph and add it to the parent graph + agentGraph, agentOpts := rAgent.ExportGraph() + err = parentGraph.AddGraphNode("react_agent", agentGraph, agentOpts...) + if err != nil { + logs.Errorf("failed to add graph node: %v", err) + return + } + _ = parentGraph.AddEdge(compose.START, "react_agent") + _ = parentGraph.AddEdge("react_agent", compose.END) + + runnable, err := parentGraph.Compile(ctx, compose.WithGraphName("DynamicOptionReactAgent")) + if err != nil { + logs.Errorf("failed to compile graph: %v", err) + return + } + + messages := []*schema.Message{ + { + Role: schema.System, + Content: persona, + }, + { + Role: schema.User, + Content: "我在北京,给我推荐一些菜,需要有口味辣一点的菜,至少推荐有 2 家餐厅", + }, + } + + // Create MessageFuture to observe intermediate results (reasoning, tool calls, tool results). + // This allows us to print the agent's thought process in real-time. + msgFutureOpt, msgFuture := react.WithMessageFuture() + + // Process MessageFuture in a separate goroutine to print intermediate results + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + processMessageFuture(msgFuture) + }() + + // Use Invoke instead of Stream. The MessageFuture still provides streaming + // access to intermediate messages even when using Invoke. + // Note: DesignateNode is used to pass the option to the specific sub-graph node. + _, err = runnable.Invoke(ctx, messages, agent.GetComposeOptions(msgFutureOpt)[0].DesignateNode("react_agent")) + if err != nil { + logs.Errorf("failed to invoke: %v", err) + return + } + + wg.Wait() + fmt.Printf("\n==================== Finished ====================\n") +} + +// processMessageFuture reads from the MessageFuture and prints intermediate results. +// Each iteration of the ReAct loop produces multiple message streams: +// - Assistant message with reasoning and tool calls +// - Tool result messages +// - Final assistant message with the answer +func processMessageFuture(msgFuture react.MessageFuture) { + iter := msgFuture.GetMessageStreams() + for { + sr, ok, err := iter.Next() + if err != nil { + logs.Errorf("failed to get next message stream: %v", err) + return + } + if !ok { + break + } + + // Accumulate streaming chunks into complete content + var reasoningBuilder strings.Builder + var contentBuilder strings.Builder + var toolCallsMap = make(map[int]*strings.Builder) + var toolCallNames = make(map[int]string) + var toolResult *struct { + name string + content string + } + + // Read all chunks from the stream + for { + msg, err := sr.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + logs.Errorf("failed to recv from message stream: %v", err) + return + } + + // Accumulate reasoning content (thinking process) + if msg.ReasoningContent != "" { + reasoningBuilder.WriteString(msg.ReasoningContent) + } + + // Accumulate tool calls (function name and arguments come in separate chunks) + if len(msg.ToolCalls) > 0 { + for _, tc := range msg.ToolCalls { + idx := 0 + if tc.Index != nil { + idx = *tc.Index + } + if _, exists := toolCallsMap[idx]; !exists { + toolCallsMap[idx] = &strings.Builder{} + } + if tc.Function.Name != "" { + toolCallNames[idx] = tc.Function.Name + } + toolCallsMap[idx].WriteString(tc.Function.Arguments) + } + } + + // Capture tool result + if msg.Role == schema.Tool && msg.Content != "" { + toolResult = &struct { + name string + content string + }{ + name: msg.ToolName, + content: msg.Content, + } + } + + // Accumulate assistant content (final answer) + if msg.Role == schema.Assistant && msg.Content != "" { + contentBuilder.WriteString(msg.Content) + } + } + + // Print accumulated content + if reasoningBuilder.Len() > 0 { + fmt.Printf("\n[Reasoning]\n%s\n", reasoningBuilder.String()) + } + + if len(toolCallsMap) > 0 { + for idx := 0; idx < len(toolCallsMap); idx++ { + if builder, exists := toolCallsMap[idx]; exists { + name := toolCallNames[idx] + fmt.Printf("\n[ToolCall] %s(%s)\n", name, builder.String()) + } + } + } + + if toolResult != nil { + fmt.Printf("\n[ToolResult] %s:\n%s\n", toolResult.name, truncateString(toolResult.content, 300)) + } + + if contentBuilder.Len() > 0 && len(toolCallsMap) == 0 { + fmt.Printf("\n[FinalAnswer]\n%s\n", contentBuilder.String()) + } + } +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// getDynamicOptions is called before each ChatModel.Generate() call. +// It demonstrates how to dynamically modify options based on the current iteration: +// - Iteration 0: Enable thinking mode, allow tool calls +// - Iteration 1+: Disable thinking mode, forbid tool calls to force final answer +func getDynamicOptions(ctx context.Context, input []*schema.Message, state *dynamic.State) []model.Option { + var opts []model.Option + + fmt.Printf("\n--- [DynamicOption] Preparing options for iteration %d ---\n", state.Iteration) + + // Control thinking mode based on iteration + if state.Iteration >= 1 { + fmt.Printf(" -> Disabling thinking mode\n") + opts = append(opts, ark.WithThinking(&arkModel.Thinking{ + Type: arkModel.ThinkingTypeDisabled, + })) + } else { + fmt.Printf(" -> Thinking mode enabled (first iteration)\n") + } + + // Control tool choice based on iteration + // After the first iteration, forbid tool calls to force the model to give a final answer + if state.Iteration >= 1 { + fmt.Printf(" -> Forcing final answer (tool_choice=forbidden)\n") + opts = append(opts, model.WithToolChoice(schema.ToolChoiceForbidden)) + opts = append(opts, model.WithTools([]*schema.ToolInfo{})) + } else { + fmt.Printf(" -> Tool choice: auto\n") + opts = append(opts, model.WithToolChoice(schema.ToolChoiceAllowed)) + // Re-bind tools for the first iteration + restaurantTool := tools.GetRestaurantTool() + dishTool := tools.GetDishTool() + info1, _ := restaurantTool.Info(ctx) + info2, _ := dishTool.Info(ctx) + opts = append(opts, model.WithTools([]*schema.ToolInfo{info1, info2})) + } + + return opts +} diff --git a/go.mod b/go.mod index 75809a7..836de03 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/alicebob/miniredis/v2 v2.35.0 github.com/bytedance/sonic v1.14.2 github.com/chromedp/chromedp v0.9.5 - github.com/cloudwego/eino v0.7.0 + github.com/cloudwego/eino v0.7.11 github.com/cloudwego/eino-ext/callbacks/cozeloop v0.1.6 github.com/cloudwego/eino-ext/components/document/parser/html v0.0.0-20251117090452-bd6375a0b3cf github.com/cloudwego/eino-ext/components/document/parser/pdf v0.0.0-20251117090452-bd6375a0b3cf @@ -23,7 +23,7 @@ require ( github.com/cloudwego/eino-ext/devops v0.1.8 github.com/coze-dev/cozeloop-go v0.1.11 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc - github.com/eino-contrib/jsonschema v1.0.2 + github.com/eino-contrib/jsonschema v1.0.3 github.com/google/uuid v1.6.0 github.com/json-iterator/go v1.1.12 github.com/kaptinlin/jsonrepair v0.2.4 diff --git a/go.sum b/go.sum index 3c5ace0..d78077a 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,8 @@ github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5P github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cloudwego/eino v0.7.0 h1:XDGdGMZCAVx+OC0IxiLlyNFELoLN+56THUhYYqEujuM= -github.com/cloudwego/eino v0.7.0/go.mod h1:JNapfU+QUrFFpboNDrNOFvmz0m9wjBFHHCr77RH6a50= +github.com/cloudwego/eino v0.7.11 h1:QQ3Ik4/nW1462CuvFsmH3gWAqNI/70BXRDmsYyvXyds= +github.com/cloudwego/eino v0.7.11/go.mod h1:nA8Vacmuqv3pqKBQbTWENBLQ8MmGmPt/WqiyLeB8ohQ= github.com/cloudwego/eino-ext/callbacks/cozeloop v0.1.6 h1:gS4nAOpQQC5WItt1k32yjZt9O2UWMpnbgF6vkMQAWhg= github.com/cloudwego/eino-ext/callbacks/cozeloop v0.1.6/go.mod h1:ZniRkgN+9FUFxtN60X7yzD6UOruqrKQusjrOiGcH4I8= github.com/cloudwego/eino-ext/components/document/parser/html v0.0.0-20251117090452-bd6375a0b3cf h1:Uwh3VT+xPrfDjM677dj1pSidCzBFoTrYlC274kEci5w= @@ -188,8 +188,8 @@ github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/eino-contrib/jsonschema v1.0.2 h1:HaxruBMUdnXa7Lg/lX8g0Hk71ZIfdTZXmBQz0e3esr8= -github.com/eino-contrib/jsonschema v1.0.2/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= +github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= github.com/eino-contrib/ollama v0.1.0 h1:z1NaMdKW6X1ftP8g5xGGR5zDRPUtuTKFq35vBQgxsN4= github.com/eino-contrib/ollama v0.1.0/go.mod h1:mYsQ7b3DeqY8bHPuD3MZJYTqkgyL6LoemxoP/B7ZNhA= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=