Files
2026-07-13 12:49:10 +08:00

1202 lines
36 KiB
Go

/*
* 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 planexecute
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
mockAdk "github.com/cloudwego/eino/internal/mock/adk"
mockModel "github.com/cloudwego/eino/internal/mock/components/model"
"github.com/cloudwego/eino/schema"
)
// TestNewPlanner tests the NewPlanner function with ChatModelWithFormattedOutput
func TestNewPlannerWithFormattedOutput(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock chat model
mockChatModel := mockModel.NewMockBaseChatModel(ctrl)
// Create the PlannerConfig
conf := &PlannerConfig{
ChatModelWithFormattedOutput: mockChatModel,
}
// Create the planner
p, err := NewPlanner(ctx, conf)
assert.NoError(t, err)
assert.NotNil(t, p)
// Verify the planner's name and description
assert.Equal(t, "planner", p.Name(ctx))
assert.Equal(t, "a planner agent", p.Description(ctx))
}
// TestNewPlannerWithToolCalling tests the NewPlanner function with ToolCallingChatModel
func TestNewPlannerWithToolCalling(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock tool calling chat model
mockToolCallingModel := mockModel.NewMockToolCallingChatModel(ctrl)
mockToolCallingModel.EXPECT().WithTools(gomock.Any()).Return(mockToolCallingModel, nil).Times(1)
// Create the PlannerConfig
conf := &PlannerConfig{
ToolCallingChatModel: mockToolCallingModel,
// Use default instruction and tool info
}
// Create the planner
p, err := NewPlanner(ctx, conf)
assert.NoError(t, err)
assert.NotNil(t, p)
// Verify the planner's name and description
assert.Equal(t, "planner", p.Name(ctx))
assert.Equal(t, "a planner agent", p.Description(ctx))
}
// TestPlannerRunWithFormattedOutput tests the Run method of a planner created with ChatModelWithFormattedOutput
func TestPlannerRunWithFormattedOutput(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock chat model
mockChatModel := mockModel.NewMockBaseChatModel(ctrl)
// Create a plan response
planJSON := `{"steps":["Step 1", "Step 2", "Step 3"]}`
planMsg := schema.AssistantMessage(planJSON, nil)
sr, sw := schema.Pipe[*schema.Message](1)
sw.Send(planMsg, nil)
sw.Close()
// Mock the Generate method
mockChatModel.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
// Create the PlannerConfig
conf := &PlannerConfig{
ChatModelWithFormattedOutput: mockChatModel,
}
// Create the planner
p, err := NewPlanner(ctx, conf)
assert.NoError(t, err)
// Run the planner
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: p})
iterator := runner.Run(ctx, []adk.Message{schema.UserMessage("Plan this task")})
// Get the event from the iterator
event, ok := iterator.Next()
assert.True(t, ok)
assert.Nil(t, event.Err)
msg, _, err := adk.GetMessage(event)
assert.NoError(t, err)
assert.Equal(t, planMsg.Content, msg.Content)
_, ok = iterator.Next()
assert.False(t, ok)
plan := defaultNewPlan(ctx)
err = plan.UnmarshalJSON([]byte(msg.Content))
assert.NoError(t, err)
plan_ := plan.(*defaultPlan)
assert.Equal(t, 3, len(plan_.Steps))
assert.Equal(t, "Step 1", plan_.Steps[0])
assert.Equal(t, "Step 2", plan_.Steps[1])
assert.Equal(t, "Step 3", plan_.Steps[2])
}
// TestPlannerRunWithToolCalling tests the Run method of a planner created with ToolCallingChatModel
func TestPlannerRunWithToolCalling(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock tool calling chat model
mockToolCallingModel := mockModel.NewMockToolCallingChatModel(ctrl)
// Create a tool call response with a plan
planArgs := `{"steps":["Step 1", "Step 2", "Step 3"]}`
toolCall := schema.ToolCall{
ID: "tool_call_id",
Type: "function",
Function: schema.FunctionCall{
Name: "plan", // This should match PlanToolInfo.Name
Arguments: planArgs,
},
}
toolCallMsg := schema.AssistantMessage("", nil)
toolCallMsg.ToolCalls = []schema.ToolCall{toolCall}
sr, sw := schema.Pipe[*schema.Message](1)
sw.Send(toolCallMsg, nil)
sw.Close()
// Mock the WithTools method to return a model that will be used for Generate
mockToolCallingModel.EXPECT().WithTools(gomock.Any()).Return(mockToolCallingModel, nil).Times(1)
// Mock the Generate method to return the tool call message
mockToolCallingModel.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
// Create the PlannerConfig with ToolCallingChatModel
conf := &PlannerConfig{
ToolCallingChatModel: mockToolCallingModel,
// Use default instruction and tool info
}
// Create the planner
p, err := NewPlanner(ctx, conf)
assert.NoError(t, err)
// Run the planner
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: p})
iterator := runner.Run(ctx, []adk.Message{schema.UserMessage("no input")})
// Get the event from the iterator
event, ok := iterator.Next()
assert.True(t, ok)
assert.Nil(t, event.Err)
msg, _, err := adk.GetMessage(event)
assert.NoError(t, err)
assert.Equal(t, planArgs, msg.Content)
_, ok = iterator.Next()
assert.False(t, ok)
plan := defaultNewPlan(ctx)
err = plan.UnmarshalJSON([]byte(msg.Content))
assert.NoError(t, err)
plan_ := plan.(*defaultPlan)
assert.NoError(t, err)
assert.Equal(t, 3, len(plan_.Steps))
assert.Equal(t, "Step 1", plan_.Steps[0])
assert.Equal(t, "Step 2", plan_.Steps[1])
assert.Equal(t, "Step 3", plan_.Steps[2])
}
// TestNewExecutor tests the NewExecutor function
func TestNewExecutor(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock tool calling chat model
mockToolCallingModel := mockModel.NewMockToolCallingChatModel(ctrl)
// Create the ExecutorConfig
conf := &ExecutorConfig{
Model: mockToolCallingModel,
MaxIterations: 3,
}
// Create the executor
executor, err := NewExecutor(ctx, conf)
assert.NoError(t, err)
assert.NotNil(t, executor)
// Verify the executor's name and description
assert.Equal(t, "executor", executor.Name(ctx))
assert.Equal(t, "an executor agent", executor.Description(ctx))
}
// TestExecutorRun tests the Run method of the executor
func TestExecutorRun(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock tool calling chat model
mockToolCallingModel := mockModel.NewMockToolCallingChatModel(ctrl)
// Store a plan in the session
plan := &defaultPlan{Steps: []string{"Step 1", "Step 2", "Step 3"}}
adk.AddSessionValue(ctx, PlanSessionKey, plan)
// Set up expectations for the mock model
// The model should return the last user message as its response
mockToolCallingModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, messages []*schema.Message, opts ...model.Option) (*schema.Message, error) {
// Find the last user message
var lastUserMessage string
for _, msg := range messages {
if msg.Role == schema.User {
lastUserMessage = msg.Content
}
}
// Return the last user message as the model's response
return schema.AssistantMessage(lastUserMessage, nil), nil
}).Times(1)
// Create the ExecutorConfig
conf := &ExecutorConfig{
Model: mockToolCallingModel,
MaxIterations: 3,
}
// Create the executor
executor, err := NewExecutor(ctx, conf)
assert.NoError(t, err)
// Run the executor
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: executor})
iterator := runner.Run(ctx, []adk.Message{schema.UserMessage("no input")},
adk.WithSessionValues(map[string]any{
PlanSessionKey: plan,
UserInputSessionKey: []adk.Message{schema.UserMessage("no input")},
}),
)
// Get the event from the iterator
event, ok := iterator.Next()
assert.True(t, ok)
assert.Nil(t, event.Err)
assert.NotNil(t, event.Output)
assert.NotNil(t, event.Output.MessageOutput)
msg, _, err := adk.GetMessage(event)
assert.NoError(t, err)
t.Logf("executor model input msg:\n %s\n", msg.Content)
_, ok = iterator.Next()
assert.False(t, ok)
}
// TestNewReplanner tests the NewReplanner function
func TestNewReplanner(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock tool calling chat model
mockToolCallingModel := mockModel.NewMockToolCallingChatModel(ctrl)
// Mock the WithTools method
mockToolCallingModel.EXPECT().WithTools(gomock.Any()).Return(mockToolCallingModel, nil).Times(1)
// Create plan and respond tools
planTool := &schema.ToolInfo{
Name: "Plan",
Desc: "Plan tool",
}
respondTool := &schema.ToolInfo{
Name: "Respond",
Desc: "Respond tool",
}
// Create the ReplannerConfig
conf := &ReplannerConfig{
ChatModel: mockToolCallingModel,
PlanTool: planTool,
RespondTool: respondTool,
}
// Create the replanner
rp, err := NewReplanner(ctx, conf)
assert.NoError(t, err)
assert.NotNil(t, rp)
// Verify the replanner's name and description
assert.Equal(t, "replanner", rp.Name(ctx))
assert.Equal(t, "a replanner agent", rp.Description(ctx))
}
// TestReplannerRunWithPlan tests the Replanner's ability to use the plan_tool
func TestReplannerRunWithPlan(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock tool calling chat model
mockToolCallingModel := mockModel.NewMockToolCallingChatModel(ctrl)
// Create plan and respond tools
planTool := &schema.ToolInfo{
Name: "Plan",
Desc: "Plan tool",
}
respondTool := &schema.ToolInfo{
Name: "Respond",
Desc: "Respond tool",
}
// Create a tool call response for the Plan tool
planArgs := `{"steps":["Updated Step 1", "Updated Step 2"]}`
toolCall := schema.ToolCall{
ID: "tool_call_id",
Type: "function",
Function: schema.FunctionCall{
Name: planTool.Name,
Arguments: planArgs,
},
}
toolCallMsg := schema.AssistantMessage("", nil)
toolCallMsg.ToolCalls = []schema.ToolCall{toolCall}
sr, sw := schema.Pipe[*schema.Message](1)
sw.Send(toolCallMsg, nil)
sw.Close()
// Mock the Generate method
mockToolCallingModel.EXPECT().WithTools(gomock.Any()).Return(mockToolCallingModel, nil).Times(1)
mockToolCallingModel.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
// Create the ReplannerConfig
conf := &ReplannerConfig{
ChatModel: mockToolCallingModel,
PlanTool: planTool,
RespondTool: respondTool,
}
// Create the replanner
rp, err := NewReplanner(ctx, conf)
assert.NoError(t, err)
// Store necessary values in the session
plan := &defaultPlan{Steps: []string{"Step 1", "Step 2", "Step 3"}}
rp, err = agentOutputSessionKVs(ctx, rp)
assert.NoError(t, err)
// Run the replanner
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: rp})
iterator := runner.Run(ctx, []adk.Message{schema.UserMessage("no input")},
adk.WithSessionValues(map[string]any{
PlanSessionKey: plan,
ExecutedStepSessionKey: "Execution result",
UserInputSessionKey: []adk.Message{schema.UserMessage("User input")},
}),
)
// Get the event from the iterator
event, ok := iterator.Next()
assert.True(t, ok)
assert.Nil(t, event.Err)
event, ok = iterator.Next()
assert.True(t, ok)
kvs := event.Output.CustomizedOutput.(map[string]any)
assert.Greater(t, len(kvs), 0)
// Verify the updated plan was stored in the session
planValue, ok := kvs[PlanSessionKey]
assert.True(t, ok)
updatedPlan, ok := planValue.(*defaultPlan)
assert.True(t, ok)
assert.Equal(t, 2, len(updatedPlan.Steps))
assert.Equal(t, "Updated Step 1", updatedPlan.Steps[0])
assert.Equal(t, "Updated Step 2", updatedPlan.Steps[1])
// Verify the execute results were updated
executeResultsValue, ok := kvs[ExecutedStepsSessionKey]
assert.True(t, ok)
executeResults, ok := executeResultsValue.([]ExecutedStep)
assert.True(t, ok)
assert.Equal(t, 1, len(executeResults))
assert.Equal(t, "Step 1", executeResults[0].Step)
assert.Equal(t, "Execution result", executeResults[0].Result)
_, ok = iterator.Next()
assert.False(t, ok)
}
// TestReplannerRunWithRespond tests the Replanner's ability to use the respond_tool
func TestReplannerRunWithRespond(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a mock tool calling chat model
mockToolCallingModel := mockModel.NewMockToolCallingChatModel(ctrl)
// Create plan and respond tools
planTool := &schema.ToolInfo{
Name: "Plan",
Desc: "Plan tool",
}
respondTool := &schema.ToolInfo{
Name: "Respond",
Desc: "Respond tool",
}
// Create a tool call response for the Respond tool
responseArgs := `{"response":"This is the final response to the user"}`
toolCall := schema.ToolCall{
ID: "tool_call_id",
Type: "function",
Function: schema.FunctionCall{
Name: respondTool.Name,
Arguments: responseArgs,
},
}
toolCallMsg := schema.AssistantMessage("", nil)
toolCallMsg.ToolCalls = []schema.ToolCall{toolCall}
sr, sw := schema.Pipe[*schema.Message](1)
sw.Send(toolCallMsg, nil)
sw.Close()
// Mock the Generate method
mockToolCallingModel.EXPECT().WithTools(gomock.Any()).Return(mockToolCallingModel, nil).Times(1)
mockToolCallingModel.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).Return(sr, nil).Times(1)
// Create the ReplannerConfig
conf := &ReplannerConfig{
ChatModel: mockToolCallingModel,
PlanTool: planTool,
RespondTool: respondTool,
}
// Create the replanner
rp, err := NewReplanner(ctx, conf)
assert.NoError(t, err)
// Store necessary values in the session
plan := &defaultPlan{Steps: []string{"Step 1", "Step 2", "Step 3"}}
// Run the replanner
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: rp})
iterator := runner.Run(ctx, []adk.Message{schema.UserMessage("no input")},
adk.WithSessionValues(map[string]any{
PlanSessionKey: plan,
ExecutedStepSessionKey: "Execution result",
UserInputSessionKey: []adk.Message{schema.UserMessage("User input")},
}),
)
// Get the event from the iterator
event, ok := iterator.Next()
assert.True(t, ok)
assert.Nil(t, event.Err)
msg, _, err := adk.GetMessage(event)
assert.NoError(t, err)
assert.Equal(t, responseArgs, msg.Content)
// Verify that an exit action was generated
event, ok = iterator.Next()
assert.True(t, ok)
assert.NotNil(t, event.Action)
assert.NotNil(t, event.Action.BreakLoop)
assert.False(t, event.Action.BreakLoop.Done)
_, ok = iterator.Next()
assert.False(t, ok)
}
// TestNewPlanExecuteAgent tests the New function
func TestNewPlanExecuteAgent(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create mock agents
mockPlanner := mockAdk.NewMockAgent(ctrl)
mockExecutor := mockAdk.NewMockAgent(ctrl)
mockReplanner := mockAdk.NewMockAgent(ctrl)
// Set up expectations for the mock agents
mockPlanner.EXPECT().Name(gomock.Any()).Return("planner").AnyTimes()
mockPlanner.EXPECT().Description(gomock.Any()).Return("a planner agent").AnyTimes()
mockExecutor.EXPECT().Name(gomock.Any()).Return("executor").AnyTimes()
mockExecutor.EXPECT().Description(gomock.Any()).Return("an executor agent").AnyTimes()
mockReplanner.EXPECT().Name(gomock.Any()).Return("replanner").AnyTimes()
mockReplanner.EXPECT().Description(gomock.Any()).Return("a replanner agent").AnyTimes()
conf := &Config{
Planner: mockPlanner,
Executor: mockExecutor,
Replanner: mockReplanner,
}
// Create the plan execute agent
agent, err := New(ctx, conf)
assert.NoError(t, err)
assert.NotNil(t, agent)
}
func TestPlanExecuteAgentWithReplan(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create mock agents
mockPlanner := mockAdk.NewMockAgent(ctrl)
mockExecutor := mockAdk.NewMockAgent(ctrl)
mockReplanner := mockAdk.NewMockAgent(ctrl)
// Set up expectations for the mock agents
mockPlanner.EXPECT().Name(gomock.Any()).Return("planner").AnyTimes()
mockPlanner.EXPECT().Description(gomock.Any()).Return("a planner agent").AnyTimes()
mockExecutor.EXPECT().Name(gomock.Any()).Return("executor").AnyTimes()
mockExecutor.EXPECT().Description(gomock.Any()).Return("an executor agent").AnyTimes()
mockReplanner.EXPECT().Name(gomock.Any()).Return("replanner").AnyTimes()
mockReplanner.EXPECT().Description(gomock.Any()).Return("a replanner agent").AnyTimes()
// Create a plan
originalPlan := &defaultPlan{Steps: []string{"Step 1", "Step 2", "Step 3"}}
// Create an updated plan with fewer steps (after replanning)
updatedPlan := &defaultPlan{Steps: []string{"Updated Step 2", "Updated Step 3"}}
// Create execute result
originalExecuteResult := "Execution result for Step 1"
updatedExecuteResult := "Execution result for Updated Step 2"
// Create user input
userInput := []adk.Message{schema.UserMessage("User task input")}
finalResponse := &Response{Response: "Final response to user after executing all steps"}
// Mock the planner Run method to set the original plan
mockPlanner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iterator, generator := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
// Set the plan in the session
adk.AddSessionValue(ctx, PlanSessionKey, originalPlan)
adk.AddSessionValue(ctx, UserInputSessionKey, userInput)
// Send a message event
planJSON, _ := sonic.MarshalString(originalPlan)
msg := schema.AssistantMessage(planJSON, nil)
event := adk.EventFromMessage(msg, nil, schema.Assistant, "")
generator.Send(event)
generator.Close()
return iterator
},
).Times(1)
// Mock the executor Run method to set the execute result
mockExecutor.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iterator, generator := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
plan, _ := adk.GetSessionValue(ctx, PlanSessionKey)
currentPlan := plan.(*defaultPlan)
var msg adk.Message
// Check if this is the first replanning (original plan has 3 steps)
if len(currentPlan.Steps) == 3 {
msg = schema.AssistantMessage(originalExecuteResult, nil)
adk.AddSessionValue(ctx, ExecutedStepSessionKey, originalExecuteResult)
} else {
msg = schema.AssistantMessage(updatedExecuteResult, nil)
adk.AddSessionValue(ctx, ExecutedStepSessionKey, updatedExecuteResult)
}
event := adk.EventFromMessage(msg, nil, schema.Assistant, "")
generator.Send(event)
generator.Close()
return iterator
},
).Times(2)
// Mock the replanner Run method to first update the plan, then respond to user
mockReplanner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iterator, generator := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
// First call: Update the plan
// Get the current plan from the session
plan, _ := adk.GetSessionValue(ctx, PlanSessionKey)
currentPlan := plan.(*defaultPlan)
// Check if this is the first replanning (original plan has 3 steps)
if len(currentPlan.Steps) == 3 {
// Send a message event with the updated plan
planJSON, _ := sonic.MarshalString(updatedPlan)
msg := schema.AssistantMessage(planJSON, nil)
event := adk.EventFromMessage(msg, nil, schema.Assistant, "")
generator.Send(event)
// Set the updated plan & execute result in the session
adk.AddSessionValue(ctx, PlanSessionKey, updatedPlan)
adk.AddSessionValue(ctx, ExecutedStepsSessionKey, []ExecutedStep{{
Step: currentPlan.Steps[0],
Result: originalExecuteResult,
}})
} else {
// Second call: Respond to user
responseJSON, err := sonic.MarshalString(finalResponse)
assert.NoError(t, err)
msg := schema.AssistantMessage(responseJSON, nil)
event := adk.EventFromMessage(msg, nil, schema.Assistant, "")
generator.Send(event)
// Send exit action
action := adk.NewExitAction()
generator.Send(&adk.AgentEvent{Action: action})
}
generator.Close()
return iterator
},
).Times(2)
conf := &Config{
Planner: mockPlanner,
Executor: mockExecutor,
Replanner: mockReplanner,
}
// Create the plan execute agent
agent, err := New(ctx, conf)
assert.NoError(t, err)
assert.NotNil(t, agent)
// Run the agent
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent})
iterator := runner.Run(ctx, userInput)
// Collect all events
var events []*adk.AgentEvent
for {
event, ok := iterator.Next()
if !ok {
break
}
events = append(events, event)
}
// Verify the events
assert.Greater(t, len(events), 0)
for i, event := range events {
eventJSON, e := sonic.MarshalString(event)
assert.NoError(t, e)
t.Logf("event %d:\n%s", i, eventJSON)
}
}
type interruptibleTool struct {
name string
t *testing.T
}
func (m *interruptibleTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: m.name,
Desc: "A tool that requires human approval before execution",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"action": {
Type: schema.String,
Desc: "The action to perform",
Required: true,
},
}),
}, nil
}
func (m *interruptibleTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
wasInterrupted, _, _ := tool.GetInterruptState[any](ctx)
if !wasInterrupted {
return "", tool.Interrupt(ctx, fmt.Sprintf("Tool '%s' requires human approval", m.name))
}
isResumeTarget, hasData, data := tool.GetResumeContext[string](ctx)
if !isResumeTarget {
return "", tool.Interrupt(ctx, fmt.Sprintf("Tool '%s' requires human approval", m.name))
}
if hasData {
return fmt.Sprintf("Approved action executed with data: %s", data), nil
}
return "Approved action executed", nil
}
type checkpointStore struct {
data map[string][]byte
}
func newCheckpointStore() *checkpointStore {
return &checkpointStore{data: make(map[string][]byte)}
}
func (s *checkpointStore) Set(_ context.Context, key string, value []byte) error {
s.data[key] = value
return nil
}
func (s *checkpointStore) Get(_ context.Context, key string) ([]byte, bool, error) {
v, ok := s.data[key]
return v, ok, nil
}
func TestPlanExecuteAgentInterruptResume(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockToolCallingModel := mockModel.NewMockToolCallingChatModel(ctrl)
approvalTool := &interruptibleTool{name: "approve_action", t: t}
plan := &defaultPlan{Steps: []string{"Execute action requiring approval", "Complete task"}}
userInput := []adk.Message{schema.UserMessage("Please execute the action")}
mockPlanner := mockAdk.NewMockAgent(ctrl)
mockPlanner.EXPECT().Name(gomock.Any()).Return("planner").AnyTimes()
mockPlanner.EXPECT().Description(gomock.Any()).Return("a planner agent").AnyTimes()
mockPlanner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iterator, generator := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
adk.AddSessionValue(ctx, PlanSessionKey, plan)
adk.AddSessionValue(ctx, UserInputSessionKey, userInput)
planJSON, _ := sonic.MarshalString(plan)
msg := schema.AssistantMessage(planJSON, nil)
event := adk.EventFromMessage(msg, nil, schema.Assistant, "")
generator.Send(event)
generator.Close()
return iterator
},
).Times(1)
toolCallMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "call_1",
Type: "function",
Function: schema.FunctionCall{
Name: "approve_action",
Arguments: `{"action": "execute"}`,
},
},
})
completionMsg := schema.AssistantMessage("Action approved and executed successfully", nil)
mockToolCallingModel.EXPECT().WithTools(gomock.Any()).Return(mockToolCallingModel, nil).AnyTimes()
mockToolCallingModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(toolCallMsg, nil).Times(1)
mockToolCallingModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(completionMsg, nil).AnyTimes()
executor, err := NewExecutor(ctx, &ExecutorConfig{
Model: mockToolCallingModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{approvalTool},
},
},
MaxIterations: 5,
})
assert.NoError(t, err)
mockReplanner := mockAdk.NewMockAgent(ctrl)
mockReplanner.EXPECT().Name(gomock.Any()).Return("replanner").AnyTimes()
mockReplanner.EXPECT().Description(gomock.Any()).Return("a replanner agent").AnyTimes()
mockReplanner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iterator, generator := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
responseJSON := `{"response":"Task completed successfully"}`
msg := schema.AssistantMessage(responseJSON, nil)
event := adk.EventFromMessage(msg, nil, schema.Assistant, "")
generator.Send(event)
action := adk.NewBreakLoopAction("replanner")
generator.Send(&adk.AgentEvent{Action: action})
generator.Close()
return iterator
},
).AnyTimes()
agent, err := New(ctx, &Config{
Planner: mockPlanner,
Executor: executor,
Replanner: mockReplanner,
MaxIterations: 5,
})
assert.NoError(t, err)
store := newCheckpointStore()
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
CheckPointStore: store,
})
iter := runner.Run(ctx, userInput, adk.WithCheckPointID("test-interrupt-1"))
var events []*adk.AgentEvent
var interruptEvent *adk.AgentEvent
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Action != nil && event.Action.Interrupted != nil {
interruptEvent = event
}
events = append(events, event)
}
t.Logf("Total events received: %d", len(events))
for i, event := range events {
eventJSON, _ := sonic.MarshalString(event)
t.Logf("Event %d: %s", i, eventJSON)
}
if interruptEvent == nil {
t.Fatal("Expected an interrupt event from the tool, but none was received")
}
assert.NotNil(t, interruptEvent.Action.Interrupted, "Should have interrupt info")
assert.NotEmpty(t, interruptEvent.Action.Interrupted.InterruptContexts, "Should have interrupt contexts")
t.Logf("Interrupt event received with %d contexts", len(interruptEvent.Action.Interrupted.InterruptContexts))
for i, ctx := range interruptEvent.Action.Interrupted.InterruptContexts {
t.Logf("Interrupt context %d: ID=%s, Info=%v, Address=%v", i, ctx.ID, ctx.Info, ctx.Address)
}
var toolInterruptID string
for _, intCtx := range interruptEvent.Action.Interrupted.InterruptContexts {
if intCtx.IsRootCause {
toolInterruptID = intCtx.ID
break
}
}
assert.NotEmpty(t, toolInterruptID, "Should have a root cause interrupt ID")
t.Logf("Attempting to resume with interrupt ID: %s", toolInterruptID)
resumeIter, err := runner.ResumeWithParams(ctx, "test-interrupt-1", &adk.ResumeParams{
Targets: map[string]any{
toolInterruptID: "approved",
},
})
assert.NoError(t, err, "Resume should not error")
assert.NotNil(t, resumeIter, "Resume iterator should not be nil")
var resumeEvents []*adk.AgentEvent
for {
event, ok := resumeIter.Next()
if !ok {
break
}
resumeEvents = append(resumeEvents, event)
}
assert.NotEmpty(t, resumeEvents, "Should have resume events")
for _, event := range resumeEvents {
assert.NoError(t, event.Err, "Resume event should not have error")
}
var hasToolResponse, hasAssistantCompletion, hasBreakLoop bool
for _, event := range resumeEvents {
if event.Output != nil && event.Output.MessageOutput != nil {
msg := event.Output.MessageOutput.Message
if msg != nil {
if msg.Role == "tool" && strings.Contains(msg.Content, "Approved action executed") {
hasToolResponse = true
}
if msg.Role == "assistant" && strings.Contains(msg.Content, "approved") {
hasAssistantCompletion = true
}
}
}
if event.Action != nil && event.Action.BreakLoop != nil && event.Action.BreakLoop.Done {
hasBreakLoop = true
}
}
assert.True(t, hasToolResponse, "Should have tool response with approved action")
assert.True(t, hasAssistantCompletion, "Should have assistant completion message")
assert.True(t, hasBreakLoop, "Should have break loop action indicating completion")
}
// slowChatModel is a ChatModel that blocks for a configurable duration.
type slowChatModel struct {
delay time.Duration
response *schema.Message
startedChan chan struct{}
startedOnce sync.Once
}
func (m *slowChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
m.startedOnce.Do(func() {
close(m.startedChan)
})
select {
case <-time.After(m.delay):
return m.response, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *slowChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
msg, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
sr, sw := schema.Pipe[*schema.Message](1)
sw.Send(msg, nil)
sw.Close()
return sr, nil
}
func (m *slowChatModel) BindTools(tools []*schema.ToolInfo) error { return nil }
func (m *slowChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return m, nil
}
// TestWithCancel_PlanExecute_DuringExecution verifies that cancel works
// during the executor (ChatModelAgent) phase of the PlanExecute agent.
func TestWithCancel_PlanExecute_DuringExecution(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Planner: returns a plan quickly
mockPlanner := mockAdk.NewMockAgent(ctrl)
mockPlanner.EXPECT().Name(gomock.Any()).Return("planner").AnyTimes()
mockPlanner.EXPECT().Description(gomock.Any()).Return("a planner agent").AnyTimes()
plan := &defaultPlan{Steps: []string{"Step 1", "Step 2"}}
userInput := []adk.Message{schema.UserMessage("test task")}
mockPlanner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iterator, generator := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
adk.AddSessionValue(ctx, PlanSessionKey, plan)
adk.AddSessionValue(ctx, UserInputSessionKey, userInput)
planJSON, _ := sonic.MarshalString(plan)
msg := schema.AssistantMessage(planJSON, nil)
generator.Send(adk.EventFromMessage(msg, nil, schema.Assistant, ""))
generator.Close()
return iterator
},
).Times(1)
// Executor: uses a slow model that we can cancel
executorStarted := make(chan struct{})
slowModel := &slowChatModel{
delay: 5 * time.Second,
response: schema.AssistantMessage("step result", nil),
startedChan: executorStarted,
}
executor, err := NewExecutor(ctx, &ExecutorConfig{
Model: slowModel,
MaxIterations: 5,
})
assert.NoError(t, err)
// Replanner: should not be reached since we cancel during executor
mockReplanner := mockAdk.NewMockAgent(ctrl)
mockReplanner.EXPECT().Name(gomock.Any()).Return("replanner").AnyTimes()
mockReplanner.EXPECT().Description(gomock.Any()).Return("a replanner agent").AnyTimes()
agent, err := New(ctx, &Config{
Planner: mockPlanner,
Executor: executor,
Replanner: mockReplanner,
MaxIterations: 5,
})
assert.NoError(t, err)
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent})
cancelOpt, cancelFn := adk.WithCancel()
iter := runner.Run(ctx, userInput, cancelOpt)
// Wait for the executor's model to start
select {
case <-executorStarted:
case <-time.After(10 * time.Second):
t.Fatal("Executor model did not start")
}
time.Sleep(50 * time.Millisecond)
// Cancel should NOT return ErrExecutionEnded
handle, _ := cancelFn()
err = handle.Wait()
assert.NoError(t, err, "Cancel during executor should succeed")
hasCancelError := false
for {
event, ok := iter.Next()
if !ok {
break
}
var ce *adk.CancelError
if event.Err != nil && errors.As(event.Err, &ce) {
hasCancelError = true
}
}
assert.True(t, hasCancelError, "Should have CancelError event")
}
// TestWithCancel_PlanExecute_BetweenTransitions verifies that cancel works
// when fired between agent transitions (e.g., after planner, before executor starts).
func TestWithCancel_PlanExecute_BetweenTransitions(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
plannerDone := make(chan struct{})
// Planner: signals when done
mockPlanner := mockAdk.NewMockAgent(ctrl)
mockPlanner.EXPECT().Name(gomock.Any()).Return("planner").AnyTimes()
mockPlanner.EXPECT().Description(gomock.Any()).Return("a planner agent").AnyTimes()
plan := &defaultPlan{Steps: []string{"Step 1"}}
userInput := []adk.Message{schema.UserMessage("test task")}
mockPlanner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iterator, generator := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
go func() {
defer generator.Close()
adk.AddSessionValue(ctx, PlanSessionKey, plan)
adk.AddSessionValue(ctx, UserInputSessionKey, userInput)
planJSON, _ := sonic.MarshalString(plan)
msg := schema.AssistantMessage(planJSON, nil)
generator.Send(adk.EventFromMessage(msg, nil, schema.Assistant, ""))
close(plannerDone)
}()
return iterator
},
).Times(1)
// Executor: slow model to give time to observe cancel
executorModelStarted := make(chan struct{})
slowExecModel := &slowChatModel{
delay: 5 * time.Second,
response: schema.AssistantMessage("step result", nil),
startedChan: executorModelStarted,
}
executor, err := NewExecutor(ctx, &ExecutorConfig{
Model: slowExecModel,
MaxIterations: 5,
})
assert.NoError(t, err)
mockReplanner := mockAdk.NewMockAgent(ctrl)
mockReplanner.EXPECT().Name(gomock.Any()).Return("replanner").AnyTimes()
mockReplanner.EXPECT().Description(gomock.Any()).Return("a replanner agent").AnyTimes()
agent, err := New(ctx, &Config{
Planner: mockPlanner,
Executor: executor,
Replanner: mockReplanner,
MaxIterations: 5,
})
assert.NoError(t, err)
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent})
cancelOpt, cancelFn := adk.WithCancel()
iter := runner.Run(ctx, userInput, cancelOpt)
// Wait for planner to finish, then cancel before executor has a chance to produce output
select {
case <-plannerDone:
case <-time.After(10 * time.Second):
t.Fatal("Planner did not finish")
}
// Cancel after planner, during executor phase
// The executor is a ChatModelAgent which will handle the cancel
select {
case <-executorModelStarted:
case <-time.After(10 * time.Second):
t.Fatal("Executor model did not start")
}
start := time.Now()
handle, _ := cancelFn()
err = handle.Wait()
assert.NoError(t, err, "Cancel between transitions should succeed")
hasCancelError := false
for {
event, ok := iter.Next()
if !ok {
break
}
var ce *adk.CancelError
if event.Err != nil && errors.As(event.Err, &ce) {
hasCancelError = true
}
}
elapsed := time.Since(start)
assert.True(t, hasCancelError, "Should have CancelError event")
assert.True(t, elapsed < 3*time.Second, "Should complete quickly after cancel, elapsed: %v", elapsed)
}