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

456 lines
14 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 prebuilt
import (
"context"
"fmt"
"strings"
"testing"
"github.com/bytedance/sonic"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
"github.com/cloudwego/eino/adk/prebuilt/supervisor"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
mockModel "github.com/cloudwego/eino/internal/mock/components/model"
"github.com/cloudwego/eino/schema"
)
type approvalInfo struct {
ToolName string
ArgumentsInJSON string
ToolCallID string
}
func (ai *approvalInfo) String() string {
return fmt.Sprintf("tool '%s' interrupted with arguments '%s', waiting for approval",
ai.ToolName, ai.ArgumentsInJSON)
}
type approvalResult struct {
Approved bool
DisapproveReason *string
}
func init() {
schema.Register[*approvalInfo]()
schema.Register[*approvalResult]()
}
type approvableTool struct {
name string
t *testing.T
}
func (m *approvableTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: m.name,
Desc: "A tool that requires approval before execution",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"action": {Type: schema.String, Desc: "The action to perform"},
}),
}, nil
}
func (m *approvableTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
wasInterrupted, _, storedArguments := tool.GetInterruptState[string](ctx)
if !wasInterrupted {
return "", tool.StatefulInterrupt(ctx, &approvalInfo{
ToolName: m.name,
ArgumentsInJSON: argumentsInJSON,
ToolCallID: compose.GetToolCallID(ctx),
}, argumentsInJSON)
}
isResumeTarget, hasData, data := tool.GetResumeContext[*approvalResult](ctx)
if !isResumeTarget {
return "", tool.StatefulInterrupt(ctx, &approvalInfo{
ToolName: m.name,
ArgumentsInJSON: storedArguments,
ToolCallID: compose.GetToolCallID(ctx),
}, storedArguments)
}
if !hasData {
return "", fmt.Errorf("tool '%s' resumed with no data", m.name)
}
if data.Approved {
return fmt.Sprintf("Tool '%s' executed successfully with args: %s", m.name, storedArguments), nil
}
if data.DisapproveReason != nil {
return fmt.Sprintf("Tool '%s' disapproved, reason: %s", m.name, *data.DisapproveReason), nil
}
return fmt.Sprintf("Tool '%s' disapproved", m.name), nil
}
type integrationCheckpointStore struct {
data map[string][]byte
}
func newIntegrationCheckpointStore() *integrationCheckpointStore {
return &integrationCheckpointStore{data: make(map[string][]byte)}
}
func (s *integrationCheckpointStore) Set(_ context.Context, key string, value []byte) error {
s.data[key] = value
return nil
}
func (s *integrationCheckpointStore) Get(_ context.Context, key string) ([]byte, bool, error) {
v, ok := s.data[key]
return v, ok, nil
}
type defaultPlan struct {
Steps []string `json:"steps"`
}
func (p *defaultPlan) FirstStep() string {
if len(p.Steps) == 0 {
return ""
}
return p.Steps[0]
}
func (p *defaultPlan) MarshalJSON() ([]byte, error) {
type planTyp defaultPlan
return sonic.Marshal((*planTyp)(p))
}
func (p *defaultPlan) UnmarshalJSON(bytes []byte) error {
type planTyp defaultPlan
return sonic.Unmarshal(bytes, (*planTyp)(p))
}
type namedAgent struct {
adk.ResumableAgent
name string
description string
}
func (n *namedAgent) Name(_ context.Context) string {
return n.name
}
func (n *namedAgent) Description(_ context.Context) string {
return n.description
}
func formatRunPath(runPath []adk.RunStep) string {
if len(runPath) == 0 {
return "[]"
}
var parts []string
for _, step := range runPath {
parts = append(parts, step.String())
}
return "[" + strings.Join(parts, " -> ") + "]"
}
func formatAgentEventIntegration(event *adk.AgentEvent) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("{AgentName: %q, RunPath: %s", event.AgentName, formatRunPath(event.RunPath)))
if event.Output != nil {
if event.Output.MessageOutput != nil && event.Output.MessageOutput.Message != nil {
msg := event.Output.MessageOutput.Message
sb.WriteString(fmt.Sprintf(", Output.Message: {Role: %q, Content: %q}", msg.Role, msg.Content))
}
}
if event.Action != nil {
if event.Action.Interrupted != nil {
sb.WriteString(fmt.Sprintf(", Action.Interrupted: {%d contexts}", len(event.Action.Interrupted.InterruptContexts)))
}
if event.Action.BreakLoop != nil {
sb.WriteString(fmt.Sprintf(", Action.BreakLoop: {From: %q, Done: %v}", event.Action.BreakLoop.From, event.Action.BreakLoop.Done))
}
if event.Action.TransferToAgent != nil {
sb.WriteString(fmt.Sprintf(", Action.TransferToAgent: {Dest: %q}", event.Action.TransferToAgent.DestAgentName))
}
}
if event.Err != nil {
sb.WriteString(fmt.Sprintf(", Err: %v", event.Err))
}
sb.WriteString("}")
return sb.String()
}
func TestSupervisorWithPlanExecuteInterruptResume(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockSupervisorModel := mockModel.NewMockToolCallingChatModel(ctrl)
mockPlannerModel := mockModel.NewMockToolCallingChatModel(ctrl)
mockExecutorModel := mockModel.NewMockToolCallingChatModel(ctrl)
mockReplannerModel := mockModel.NewMockToolCallingChatModel(ctrl)
budgetTool := &approvableTool{name: "allocate_budget", t: t}
plan := &defaultPlan{Steps: []string{"Allocate budget for the project", "Complete task"}}
userInput := []adk.Message{schema.UserMessage("Set up a new project with budget allocation")}
plannerModelWithTools := mockModel.NewMockToolCallingChatModel(ctrl)
mockPlannerModel.EXPECT().WithTools(gomock.Any()).Return(plannerModelWithTools, nil).AnyTimes()
planJSON, _ := sonic.MarshalString(plan)
plannerResponse := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "plan_call_1",
Type: "function",
Function: schema.FunctionCall{
Name: "plan",
Arguments: planJSON,
},
},
})
plannerModelWithTools.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input []*schema.Message, opts ...interface{}) (*schema.StreamReader[*schema.Message], error) {
sr, sw := schema.Pipe[*schema.Message](1)
go func() {
defer sw.Close()
sw.Send(plannerResponse, nil)
}()
return sr, nil
},
).Times(1)
mockExecutorModel.EXPECT().WithTools(gomock.Any()).Return(mockExecutorModel, nil).AnyTimes()
toolCallMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "call_budget_1",
Type: "function",
Function: schema.FunctionCall{
Name: "allocate_budget",
Arguments: `{"action": "allocate $50000 for project"}`,
},
},
})
mockExecutorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(toolCallMsg, nil).Times(1)
completionMsg := schema.AssistantMessage("Budget allocated successfully", nil)
mockExecutorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(completionMsg, nil).AnyTimes()
replannerModelWithTools := mockModel.NewMockToolCallingChatModel(ctrl)
mockReplannerModel.EXPECT().WithTools(gomock.Any()).Return(replannerModelWithTools, nil).AnyTimes()
respondResponse := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "respond_call_1",
Type: "function",
Function: schema.FunctionCall{
Name: "respond",
Arguments: `{"response":"Project setup completed with budget allocation"}`,
},
},
})
replannerModelWithTools.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input []*schema.Message, opts ...interface{}) (*schema.StreamReader[*schema.Message], error) {
sr, sw := schema.Pipe[*schema.Message](1)
go func() {
defer sw.Close()
sw.Send(respondResponse, nil)
}()
return sr, nil
},
).AnyTimes()
plannerAgent, err := planexecute.NewPlanner(ctx, &planexecute.PlannerConfig{
ToolCallingChatModel: mockPlannerModel,
})
assert.NoError(t, err)
executorAgent, err := planexecute.NewExecutor(ctx, &planexecute.ExecutorConfig{
Model: mockExecutorModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{budgetTool},
},
},
})
assert.NoError(t, err)
replannerAgent, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{
ChatModel: mockReplannerModel,
})
assert.NoError(t, err)
planExecuteAgent, err := planexecute.New(ctx, &planexecute.Config{
Planner: plannerAgent,
Executor: executorAgent,
Replanner: replannerAgent,
MaxIterations: 10,
})
assert.NoError(t, err)
projectAgent := &namedAgent{
ResumableAgent: planExecuteAgent,
name: "project_execution_agent",
description: "the agent responsible for complex project execution tasks",
}
var pa adk.Agent = projectAgent
_, ok := pa.(adk.ResumableAgent)
assert.True(t, ok)
mockSupervisorModel.EXPECT().WithTools(gomock.Any()).Return(mockSupervisorModel, nil).AnyTimes()
transferToProjectMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "transfer_call_1",
Type: "function",
Function: schema.FunctionCall{
Name: "transfer_to_agent",
Arguments: `{"agent_name":"project_execution_agent"}`,
},
},
})
mockSupervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(transferToProjectMsg, nil).Times(1)
finalSupervisorMsg := schema.AssistantMessage("Project setup completed successfully with budget allocation approved.", nil)
mockSupervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(finalSupervisorMsg, nil).AnyTimes()
supervisorChatAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "project_manager",
Description: "the supervisor agent responsible for coordinating project management tasks",
Instruction: "You are a project manager supervisor. Delegate complex project tasks to project_execution_agent.",
Model: mockSupervisorModel,
Exit: &adk.ExitTool{},
})
assert.NoError(t, err)
supervisorAgent, err := supervisor.New(ctx, &supervisor.Config{
Supervisor: supervisorChatAgent,
SubAgents: []adk.Agent{projectAgent},
})
assert.NoError(t, err)
store := newIntegrationCheckpointStore()
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: supervisorAgent,
CheckPointStore: store,
})
t.Log("========================================")
t.Log("Starting Supervisor + PlanExecute Integration Test")
t.Log("========================================")
checkpointID := "test-supervisor-plan_execute-1"
iter := runner.Run(ctx, userInput, adk.WithCheckPointID(checkpointID))
var interruptEvent *adk.AgentEvent
eventCount := 0
for {
event, ok := iter.Next()
if !ok {
break
}
eventCount++
t.Logf("Event %d: %s", eventCount, formatAgentEventIntegration(event))
if event.Err != nil {
t.Logf("Event has error: %v", event.Err)
}
if event.Action != nil && event.Action.Interrupted != nil {
interruptEvent = event
t.Log("========================================")
t.Log("INTERRUPT DETECTED - Deep interrupt from tool within executor")
t.Log("========================================")
break
}
}
if interruptEvent == nil {
t.Fatal("Expected an interrupt event from the approvable 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, IsRootCause=%v", i, ctx.ID, ctx.Info, ctx.IsRootCause)
}
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.Log("========================================")
t.Logf("Resuming with approval for interrupt ID: %s", toolInterruptID)
t.Log("========================================")
resumeIter, err := runner.ResumeWithParams(ctx, checkpointID, &adk.ResumeParams{
Targets: map[string]any{
toolInterruptID: &approvalResult{Approved: true},
},
})
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 after approval")
for _, event := range resumeEvents {
assert.NoError(t, event.Err, "Resume event should not have error")
}
var hasToolResponse, hasBreakLoop bool
for _, event := range resumeEvents {
if event.Output != nil && event.Output.MessageOutput != nil {
msg := event.Output.MessageOutput.Message
if msg != nil && msg.Role == "tool" && strings.Contains(msg.Content, "executed successfully") {
hasToolResponse = true
}
}
if event.Action != nil && event.Action.BreakLoop != nil && event.Action.BreakLoop.Done {
hasBreakLoop = true
}
}
assert.True(t, hasToolResponse, "Should have tool response indicating successful execution")
assert.True(t, hasBreakLoop, "Should have break loop action indicating task completion")
}