feat(react): dynamic option during execution
Change-Id: I7c67d6b6244c3f37912faf35d64fa4c73bc04358drew/english
parent
82cc9a471d
commit
9835e6abd6
@ -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.
|
||||||
@ -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)
|
||||||
|
)
|
||||||
@ -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
|
||||||
Loading…
Reference in New Issue