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

1160 lines
38 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 supervisor
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/callbacks"
"github.com/cloudwego/eino/components"
"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"
)
// TestNewSupervisor tests the New function
func TestNewSupervisor(t *testing.T) {
ctx := context.Background()
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create mock agents
supervisorAgent := mockAdk.NewMockAgent(ctrl)
subAgent1 := mockAdk.NewMockAgent(ctrl)
subAgent2 := mockAdk.NewMockAgent(ctrl)
supervisorAgent.EXPECT().Name(gomock.Any()).Return("SupervisorAgent").AnyTimes()
supervisorAgent.EXPECT().Description(gomock.Any()).Return("Supervisor agent description").AnyTimes()
subAgent1.EXPECT().Name(gomock.Any()).Return("SubAgent1").AnyTimes()
subAgent2.EXPECT().Name(gomock.Any()).Return("SubAgent2").AnyTimes()
aMsg, tMsg := adk.GenTransferMessages(ctx, "SubAgent1")
i, g := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
g.Send(adk.EventFromMessage(aMsg, nil, schema.Assistant, ""))
event := adk.EventFromMessage(tMsg, nil, schema.Tool, tMsg.ToolName)
event.Action = &adk.AgentAction{TransferToAgent: &adk.TransferToAgentAction{DestAgentName: "SubAgent1"}}
g.Send(event)
g.Close()
supervisorAgent.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i).Times(1)
i, g = adk.NewAsyncIteratorPair[*adk.AgentEvent]()
subAgent1Msg := schema.AssistantMessage("SubAgent1", nil)
g.Send(adk.EventFromMessage(subAgent1Msg, nil, schema.Assistant, ""))
g.Close()
subAgent1.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i).Times(1)
aMsg, tMsg = adk.GenTransferMessages(ctx, "SubAgent2 message")
i, g = adk.NewAsyncIteratorPair[*adk.AgentEvent]()
g.Send(adk.EventFromMessage(aMsg, nil, schema.Assistant, ""))
event = adk.EventFromMessage(tMsg, nil, schema.Tool, tMsg.ToolName)
event.Action = &adk.AgentAction{TransferToAgent: &adk.TransferToAgentAction{DestAgentName: "SubAgent2"}}
g.Send(event)
g.Close()
supervisorAgent.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i).Times(1)
i, g = adk.NewAsyncIteratorPair[*adk.AgentEvent]()
subAgent2Msg := schema.AssistantMessage("SubAgent2 message", nil)
g.Send(adk.EventFromMessage(subAgent2Msg, nil, schema.Assistant, ""))
g.Close()
subAgent2.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i).Times(1)
i, g = adk.NewAsyncIteratorPair[*adk.AgentEvent]()
finishMsg := schema.AssistantMessage("finish", nil)
g.Send(adk.EventFromMessage(finishMsg, nil, schema.Assistant, ""))
g.Close()
supervisorAgent.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i).Times(1)
conf := &Config{
Supervisor: supervisorAgent,
SubAgents: []adk.Agent{subAgent1, subAgent2},
}
multiAgent, err := New(ctx, conf)
assert.NoError(t, err)
assert.NotNil(t, multiAgent)
assert.Equal(t, "SupervisorAgent", multiAgent.Name(ctx))
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: multiAgent})
aIter := runner.Run(ctx, []adk.Message{schema.UserMessage("test")})
// transfer to agent1
event, ok := aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SupervisorAgent", event.AgentName)
assert.Equal(t, schema.Assistant, event.Output.MessageOutput.Role)
assert.NotEqual(t, 0, len(event.Output.MessageOutput.Message.ToolCalls))
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SupervisorAgent", event.AgentName)
assert.Equal(t, schema.Tool, event.Output.MessageOutput.Role)
assert.Equal(t, "SubAgent1", event.Action.TransferToAgent.DestAgentName)
// agent1's output
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SubAgent1", event.AgentName)
assert.Equal(t, schema.Assistant, event.Output.MessageOutput.Role)
assert.Equal(t, subAgent1Msg.Content, event.Output.MessageOutput.Message.Content)
// transfer back to supervisor
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SubAgent1", event.AgentName)
assert.Equal(t, schema.Assistant, event.Output.MessageOutput.Role)
assert.NotEqual(t, 0, len(event.Output.MessageOutput.Message.ToolCalls))
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SubAgent1", event.AgentName)
assert.Equal(t, schema.Tool, event.Output.MessageOutput.Role)
assert.Equal(t, "SupervisorAgent", event.Action.TransferToAgent.DestAgentName)
// transfer to agent2
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SupervisorAgent", event.AgentName)
assert.Equal(t, schema.Assistant, event.Output.MessageOutput.Role)
assert.NotEqual(t, 0, len(event.Output.MessageOutput.Message.ToolCalls))
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SupervisorAgent", event.AgentName)
assert.Equal(t, schema.Tool, event.Output.MessageOutput.Role)
assert.Equal(t, "SubAgent2", event.Action.TransferToAgent.DestAgentName)
// agent1's output
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SubAgent2", event.AgentName)
assert.Equal(t, schema.Assistant, event.Output.MessageOutput.Role)
assert.Equal(t, subAgent2Msg.Content, event.Output.MessageOutput.Message.Content)
// transfer back to supervisor
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SubAgent2", event.AgentName)
assert.Equal(t, schema.Assistant, event.Output.MessageOutput.Role)
assert.NotEqual(t, 0, len(event.Output.MessageOutput.Message.ToolCalls))
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SubAgent2", event.AgentName)
assert.Equal(t, schema.Tool, event.Output.MessageOutput.Role)
assert.Equal(t, "SupervisorAgent", event.Action.TransferToAgent.DestAgentName)
// finish
event, ok = aIter.Next()
assert.True(t, ok)
assert.Equal(t, "SupervisorAgent", event.AgentName)
assert.Equal(t, schema.Assistant, event.Output.MessageOutput.Role)
assert.Equal(t, finishMsg.Content, event.Output.MessageOutput.Message.Content)
}
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 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
}
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 TestNestedSupervisorInterruptResume(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockOuterSupervisorModel := mockModel.NewMockToolCallingChatModel(ctrl)
mockInnerSupervisorModel := mockModel.NewMockToolCallingChatModel(ctrl)
mockWorkerModel := mockModel.NewMockToolCallingChatModel(ctrl)
paymentTool := &approvableTool{name: "process_payment", t: t}
userInput := []adk.Message{schema.UserMessage("Process a payment of $1000")}
mockWorkerModel.EXPECT().WithTools(gomock.Any()).Return(mockWorkerModel, nil).AnyTimes()
workerToolCallMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "call_payment_1",
Type: "function",
Function: schema.FunctionCall{
Name: "process_payment",
Arguments: `{"action": "process $1000 payment"}`,
},
},
})
mockWorkerModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(workerToolCallMsg, nil).Times(1)
workerCompletionMsg := schema.AssistantMessage("Payment processed successfully", nil)
mockWorkerModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(workerCompletionMsg, nil).AnyTimes()
workerAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "payment_worker",
Description: "the agent responsible for processing payments",
Instruction: "You are a payment processing worker. Use the process_payment tool to handle payments.",
Model: mockWorkerModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{paymentTool},
},
},
})
assert.NoError(t, err)
mockInnerSupervisorModel.EXPECT().WithTools(gomock.Any()).Return(mockInnerSupervisorModel, nil).AnyTimes()
innerTransferMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "inner_transfer_1",
Type: "function",
Function: schema.FunctionCall{
Name: "transfer_to_agent",
Arguments: `{"agent_name":"payment_worker"}`,
},
},
})
mockInnerSupervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(innerTransferMsg, nil).Times(1)
innerFinalMsg := schema.AssistantMessage("Payment has been processed and approved.", nil)
mockInnerSupervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(innerFinalMsg, nil).AnyTimes()
innerSupervisorChatAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "payment_supervisor",
Description: "the supervisor agent responsible for payment operations",
Instruction: "You are a payment supervisor. Delegate payment tasks to payment_worker.",
Model: mockInnerSupervisorModel,
Exit: &adk.ExitTool{},
})
assert.NoError(t, err)
innerSupervisorAgent, err := New(ctx, &Config{
Supervisor: innerSupervisorChatAgent,
SubAgents: []adk.Agent{workerAgent},
})
assert.NoError(t, err)
innerSupervisorWrapped := &namedAgent{
ResumableAgent: innerSupervisorAgent,
name: "payment_department",
description: "the department responsible for all payment-related operations",
}
mockOuterSupervisorModel.EXPECT().WithTools(gomock.Any()).Return(mockOuterSupervisorModel, nil).AnyTimes()
outerTransferMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "outer_transfer_1",
Type: "function",
Function: schema.FunctionCall{
Name: "transfer_to_agent",
Arguments: `{"agent_name":"payment_department"}`,
},
},
})
mockOuterSupervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(outerTransferMsg, nil).Times(1)
outerFinalMsg := schema.AssistantMessage("The payment request has been fully processed by the payment department.", nil)
mockOuterSupervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(outerFinalMsg, nil).AnyTimes()
outerSupervisorChatAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "company_coordinator",
Description: "the top-level coordinator for company operations",
Instruction: "You are the company coordinator. Route payment requests to payment_department.",
Model: mockOuterSupervisorModel,
Exit: &adk.ExitTool{},
})
assert.NoError(t, err)
outerSupervisorAgent, err := New(ctx, &Config{
Supervisor: outerSupervisorChatAgent,
SubAgents: []adk.Agent{innerSupervisorWrapped},
})
assert.NoError(t, err)
outerSupervisorWrapped := &namedAgent{
ResumableAgent: outerSupervisorAgent,
name: "headquarters",
description: "the company headquarters that coordinates all departments",
}
store := newCheckpointStore()
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: outerSupervisorWrapped,
CheckPointStore: store,
})
t.Log("========================================")
t.Log("Starting Nested Supervisor Integration Test (with namedAgent wrappers)")
t.Log("Hierarchy: headquarters(wrapper) -> company_coordinator -> payment_department(wrapper) -> payment_supervisor -> payment_worker -> process_payment tool")
t.Log("========================================")
checkpointID := "test-nested-supervisor-1"
iter := runner.Run(ctx, userInput, adk.WithCheckPointID(checkpointID))
var interruptEvent *adk.AgentEvent
eventCount := 0
for {
event, ok := iter.Next()
if !ok {
break
}
eventCount++
if event.Action != nil && event.Action.Interrupted != nil {
interruptEvent = event
t.Log("INTERRUPT DETECTED - Deep interrupt from tool within nested supervisor")
break
}
}
if interruptEvent == nil {
t.Fatal("Expected an interrupt event from the process_payment 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")
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("Resuming with approval for interrupt ID: %s", toolInterruptID)
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, hasTransferBack 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.TransferToAgent != nil {
if event.Action.TransferToAgent.DestAgentName == "company_coordinator" {
hasTransferBack = true
}
}
}
assert.True(t, hasToolResponse, "Should have tool response indicating successful payment processing")
assert.True(t, hasTransferBack, "Should have transfer back to outer supervisor indicating completion")
}
func TestSupervisorExit(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
supervisorAgent := mockAdk.NewMockAgent(ctrl)
subAgent := mockAdk.NewMockAgent(ctrl)
supervisorAgent.EXPECT().Name(gomock.Any()).Return("Supervisor").AnyTimes()
supervisorAgent.EXPECT().Description(gomock.Any()).Return("Supervisor description").AnyTimes()
subAgent.EXPECT().Name(gomock.Any()).Return("SubAgent").AnyTimes()
// 1. Supervisor transfers to SubAgent
aMsg, tMsg := adk.GenTransferMessages(ctx, "SubAgent")
i1, g1 := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
g1.Send(adk.EventFromMessage(aMsg, nil, schema.Assistant, ""))
event1 := adk.EventFromMessage(tMsg, nil, schema.Tool, tMsg.ToolName)
event1.Action = &adk.AgentAction{TransferToAgent: &adk.TransferToAgentAction{DestAgentName: "SubAgent"}}
g1.Send(event1)
g1.Close()
supervisorAgent.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i1).Times(1)
// 2. SubAgent emits Exit action
i2, g2 := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
exitEvent := &adk.AgentEvent{
AgentName: "SubAgent",
Action: &adk.AgentAction{Exit: true},
Output: &adk.AgentOutput{
MessageOutput: &adk.MessageVariant{
Role: schema.Assistant,
Message: schema.AssistantMessage("Exiting...", nil),
},
},
}
g2.Send(exitEvent)
g2.Close()
subAgent.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i2).Times(1)
conf := &Config{
Supervisor: supervisorAgent,
SubAgents: []adk.Agent{subAgent},
}
multiAgent, err := New(ctx, conf)
assert.NoError(t, err)
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: multiAgent})
aIter := runner.Run(ctx, []adk.Message{schema.UserMessage("test")})
// Collect events
var events []*adk.AgentEvent
for {
event, ok := aIter.Next()
if !ok {
break
}
events = append(events, event)
}
foundExit := false
foundTransferBack := false
for _, e := range events {
if e.Action != nil {
if e.Action.Exit {
foundExit = true
}
if e.Action.TransferToAgent != nil && e.Action.TransferToAgent.DestAgentName == "Supervisor" {
foundTransferBack = true
}
}
}
assert.True(t, foundExit, "Should have found Exit action")
assert.False(t, foundTransferBack, "Should NOT have found Transfer back to Supervisor after Exit")
}
func TestNestedSupervisorExit(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
topSupervisor := mockAdk.NewMockAgent(ctrl)
midSupervisor := mockAdk.NewMockAgent(ctrl)
worker := mockAdk.NewMockAgent(ctrl)
topSupervisor.EXPECT().Name(gomock.Any()).Return("TopSupervisor").AnyTimes()
topSupervisor.EXPECT().Description(gomock.Any()).Return("Top supervisor description").AnyTimes()
midSupervisor.EXPECT().Name(gomock.Any()).Return("MidSupervisor").AnyTimes()
midSupervisor.EXPECT().Description(gomock.Any()).Return("Mid supervisor description").AnyTimes()
worker.EXPECT().Name(gomock.Any()).Return("Worker").AnyTimes()
// 1. TopSupervisor transfers to MidSupervisor
aMsg1, tMsg1 := adk.GenTransferMessages(ctx, "MidSupervisor")
i1, g1 := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
g1.Send(adk.EventFromMessage(aMsg1, nil, schema.Assistant, ""))
event1 := adk.EventFromMessage(tMsg1, nil, schema.Tool, tMsg1.ToolName)
event1.Action = &adk.AgentAction{TransferToAgent: &adk.TransferToAgentAction{DestAgentName: "MidSupervisor"}}
g1.Send(event1)
g1.Close()
topSupervisor.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i1).AnyTimes()
// 2. MidSupervisor transfers to Worker
aMsg2, tMsg2 := adk.GenTransferMessages(ctx, "Worker")
i2, g2 := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
g2.Send(adk.EventFromMessage(aMsg2, nil, schema.Assistant, ""))
event2 := adk.EventFromMessage(tMsg2, nil, schema.Tool, tMsg2.ToolName)
event2.Action = &adk.AgentAction{TransferToAgent: &adk.TransferToAgentAction{DestAgentName: "Worker"}}
g2.Send(event2)
g2.Close()
midSupervisor.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i2).AnyTimes()
// 3. Worker emits Exit action
i3, g3 := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
exitEvent := &adk.AgentEvent{
AgentName: "Worker",
Action: &adk.AgentAction{Exit: true},
Output: &adk.AgentOutput{
MessageOutput: &adk.MessageVariant{
Role: schema.Assistant,
Message: schema.AssistantMessage("Worker Exiting...", nil),
},
},
}
g3.Send(exitEvent)
g3.Close()
worker.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).Return(i3).Times(1)
// Build Nested System
// Mid System: MidSupervisor -> [Worker]
midSystem, err := New(ctx, &Config{
Supervisor: midSupervisor,
SubAgents: []adk.Agent{worker},
})
assert.NoError(t, err)
// We need to give the midSystem the name "MidSupervisor" so TopSupervisor can find it
// supervisor.New returns a ResumableAgent that delegates Name() to the supervisor agent.
// So midSystem.Name() should already be "MidSupervisor" because midSupervisor.Name() is "MidSupervisor".
// Top System: TopSupervisor -> [midSystem]
topSystem, err := New(ctx, &Config{
Supervisor: topSupervisor,
SubAgents: []adk.Agent{midSystem},
})
assert.NoError(t, err)
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: topSystem})
aIter := runner.Run(ctx, []adk.Message{schema.UserMessage("test nested exit")})
// Collect events
var events []*adk.AgentEvent
for {
event, ok := aIter.Next()
if !ok {
break
}
events = append(events, event)
}
foundExit := false
foundTransferBackToMidAfterExit := false
foundTransferBackToTopAfterExit := false
for _, e := range events {
if e.Action != nil {
if e.Action.Exit {
foundExit = true
}
if foundExit && e.Action.TransferToAgent != nil {
if e.Action.TransferToAgent.DestAgentName == "MidSupervisor" {
foundTransferBackToMidAfterExit = true
}
if e.Action.TransferToAgent.DestAgentName == "TopSupervisor" {
foundTransferBackToTopAfterExit = true
}
}
}
}
assert.True(t, foundExit, "Should have found Exit action")
assert.False(t, foundTransferBackToMidAfterExit, "Should NOT have found Transfer back to MidSupervisor after Exit")
assert.False(t, foundTransferBackToTopAfterExit, "Should NOT have found Transfer back to TopSupervisor after Exit")
}
func TestChatModelAgentInternalEventsExit(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
supervisorAgent := mockAdk.NewMockAgent(ctrl)
workerModel := mockModel.NewMockToolCallingChatModel(ctrl)
innerAgent := mockAdk.NewMockAgent(ctrl)
supervisorAgent.EXPECT().Name(gomock.Any()).Return("Supervisor").AnyTimes()
supervisorAgent.EXPECT().Description(gomock.Any()).Return("Supervisor description").AnyTimes()
innerAgent.EXPECT().Name(gomock.Any()).Return("InnerAgent").AnyTimes()
innerAgent.EXPECT().Description(gomock.Any()).Return("Inner Agent Description").AnyTimes()
// 1. Supervisor transfers to Worker (only once, then exits when worker transfers back)
supervisorRunCount := 0
supervisorAgent.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
supervisorRunCount++
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
go func() {
defer gen.Close()
if supervisorRunCount == 1 {
aMsg, tMsg := adk.GenTransferMessages(ctx, "Worker")
gen.Send(adk.EventFromMessage(aMsg, nil, schema.Assistant, ""))
event1 := adk.EventFromMessage(tMsg, nil, schema.Tool, tMsg.ToolName)
event1.Action = &adk.AgentAction{TransferToAgent: &adk.TransferToAgentAction{DestAgentName: "Worker"}}
gen.Send(event1)
} else {
exitEvent := &adk.AgentEvent{
AgentName: "Supervisor",
Action: &adk.AgentAction{Exit: true},
Output: &adk.AgentOutput{
MessageOutput: &adk.MessageVariant{
Role: schema.Assistant,
Message: schema.AssistantMessage("Supervisor done", nil),
},
},
}
gen.Send(exitEvent)
}
}()
return iter
}).AnyTimes()
// 2. Worker runs, calls AgentTool (InnerAgent)
// Mock WorkerModel behavior
workerModel.EXPECT().WithTools(gomock.Any()).Return(workerModel, nil).AnyTimes()
// 2.1 Worker generates tool call
toolCallMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "call_inner_1",
Type: "function",
Function: schema.FunctionCall{
Name: "InnerAgent",
Arguments: `{"request": "do exit"}`,
},
},
})
workerModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(toolCallMsg, nil).Times(1)
// 2.2 InnerAgent runs and emits Exit
innerAgent.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
go func() {
defer gen.Close()
innerExitEvent := &adk.AgentEvent{
AgentName: "InnerAgent",
Action: &adk.AgentAction{Exit: true},
RunPath: []adk.RunStep{},
Output: &adk.AgentOutput{
MessageOutput: &adk.MessageVariant{
Role: schema.Assistant,
Message: schema.AssistantMessage("Inner Exiting...", nil),
},
},
}
gen.Send(innerExitEvent)
}()
return iter
}).AnyTimes()
// 2.3 Worker receives tool result (empty string or whatever AgentTool returns on exit/interrupt)
// AgentTool implementation details: if Exit action is present, it returns whatever output is there.
// The Exit action itself is passed as internal event.
// 2.4 Worker generates final response
finalMsg := schema.AssistantMessage("Worker Finished", nil)
workerModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(finalMsg, nil).AnyTimes()
// Build Worker Agent
agentTool := adk.NewAgentTool(ctx, innerAgent)
workerAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "Worker",
Description: "Worker Agent",
Model: workerModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{agentTool},
},
EmitInternalEvents: true, // Key configuration
},
})
assert.NoError(t, err)
// Build System
sys, err := New(ctx, &Config{
Supervisor: supervisorAgent,
SubAgents: []adk.Agent{workerAgent},
})
assert.NoError(t, err)
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: sys})
aIter := runner.Run(ctx, []adk.Message{schema.UserMessage("start")})
// Collect events
var events []*adk.AgentEvent
for {
event, ok := aIter.Next()
if !ok {
break
}
events = append(events, event)
}
foundInnerExit := false
foundTransferBack := false
for _, e := range events {
// Check for InnerAgent exit event (propagated as internal event)
if e.AgentName == "InnerAgent" && e.Action != nil && e.Action.Exit {
foundInnerExit = true
}
// Check for transfer back to Supervisor
if e.AgentName == "Worker" && e.Action != nil && e.Action.TransferToAgent != nil &&
e.Action.TransferToAgent.DestAgentName == "Supervisor" {
foundTransferBack = true
}
}
assert.True(t, foundInnerExit, "Should have captured InnerAgent Exit event")
assert.True(t, foundTransferBack, "Should have found Transfer back to Supervisor (Worker should NOT be considered exited)")
}
func TestSupervisorContainerUnifiedTracing(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
supervisorModel := mockModel.NewMockToolCallingChatModel(ctrl)
subAgentModel := mockModel.NewMockToolCallingChatModel(ctrl)
supervisorModel.EXPECT().WithTools(gomock.Any()).Return(supervisorModel, nil).AnyTimes()
subAgentModel.EXPECT().WithTools(gomock.Any()).Return(subAgentModel, nil).AnyTimes()
transferMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "transfer_1",
Type: "function",
Function: schema.FunctionCall{
Name: "transfer_to_agent",
Arguments: `{"agent_name":"SubAgent"}`,
},
},
})
supervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(transferMsg, nil).Times(1)
subAgentResponse := schema.AssistantMessage("SubAgent response", nil)
subAgentModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(subAgentResponse, nil).Times(1)
finalResponse := schema.AssistantMessage("Final response", nil)
supervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(finalResponse, nil).Times(1)
supervisorAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "SupervisorAgent",
Description: "Supervisor agent",
Instruction: "You are a supervisor",
Model: supervisorModel,
})
assert.NoError(t, err)
subAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "SubAgent",
Description: "Sub agent",
Instruction: "You are a sub agent",
Model: subAgentModel,
})
assert.NoError(t, err)
multiAgent, err := New(ctx, &Config{
Supervisor: supervisorAgent,
SubAgents: []adk.Agent{subAgent},
})
assert.NoError(t, err)
assert.Equal(t, "SupervisorAgent", multiAgent.Name(ctx))
typer, ok := multiAgent.(components.Typer)
assert.True(t, ok, "Should implement components.Typer")
assert.Equal(t, "Supervisor", typer.GetType())
var mu sync.Mutex
var onStartCalls []string
var onEndCalls []string
handler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != adk.ComponentOfAgent {
return ctx
}
mu.Lock()
onStartCalls = append(onStartCalls, info.Name+":"+info.Type)
mu.Unlock()
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != adk.ComponentOfAgent {
return ctx
}
mu.Lock()
onEndCalls = append(onEndCalls, info.Name+":"+info.Type)
mu.Unlock()
if agentOutput := adk.ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: multiAgent})
iter := runner.Query(ctx, "hello", adk.WithCallbacks(handler))
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
assert.NotEmpty(t, onStartCalls, "Should have OnStart calls")
assert.Contains(t, onStartCalls, "SupervisorAgent:Supervisor", "Should have supervisor container OnStart with type 'Supervisor'")
}
type traceContextKey struct{}
func TestSupervisorContainerUnifiedTracingOnResume(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
supervisorModel := mockModel.NewMockToolCallingChatModel(ctrl)
workerModel := mockModel.NewMockToolCallingChatModel(ctrl)
supervisorModel.EXPECT().WithTools(gomock.Any()).Return(supervisorModel, nil).AnyTimes()
workerModel.EXPECT().WithTools(gomock.Any()).Return(workerModel, nil).AnyTimes()
paymentTool := &approvableTool{name: "process_payment", t: t}
workerToolCallMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "call_payment_1",
Type: "function",
Function: schema.FunctionCall{
Name: "process_payment",
Arguments: `{"action": "process $1000 payment"}`,
},
},
})
workerModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(workerToolCallMsg, nil).Times(1)
workerCompletionMsg := schema.AssistantMessage("Payment processed successfully", nil)
workerModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(workerCompletionMsg, nil).AnyTimes()
workerAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "Worker",
Description: "Worker agent",
Instruction: "You are a worker",
Model: workerModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{paymentTool},
},
},
})
assert.NoError(t, err)
transferMsg := schema.AssistantMessage("", []schema.ToolCall{
{
ID: "transfer_1",
Type: "function",
Function: schema.FunctionCall{
Name: "transfer_to_agent",
Arguments: `{"agent_name":"Worker"}`,
},
},
})
supervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(transferMsg, nil).Times(1)
finalResponse := schema.AssistantMessage("Final response", nil)
supervisorModel.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(finalResponse, nil).AnyTimes()
supervisorAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "SupervisorAgent",
Description: "Supervisor agent",
Instruction: "You are a supervisor",
Model: supervisorModel,
Exit: &adk.ExitTool{},
})
assert.NoError(t, err)
multiAgent, err := New(ctx, &Config{
Supervisor: supervisorAgent,
SubAgents: []adk.Agent{workerAgent},
})
assert.NoError(t, err)
store := newCheckpointStore()
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: multiAgent,
CheckPointStore: store,
})
var mu sync.Mutex
var runOnStartCalls []string
var resumeOnStartCalls []string
var resumeParentTraceIDs []string
runHandler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != adk.ComponentOfAgent {
return ctx
}
mu.Lock()
runOnStartCalls = append(runOnStartCalls, info.Name+":"+info.Type)
mu.Unlock()
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != adk.ComponentOfAgent {
return ctx
}
if agentOutput := adk.ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
checkpointID := "test-unified-tracing-resume"
iter := runner.Run(ctx, []adk.Message{schema.UserMessage("Process payment")}, adk.WithCallbacks(runHandler), adk.WithCheckPointID(checkpointID))
var interruptEvent *adk.AgentEvent
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Action != nil && event.Action.Interrupted != nil {
interruptEvent = event
break
}
}
assert.NotNil(t, interruptEvent, "Should have interrupt event")
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")
mu.Lock()
t.Logf("Run OnStart calls: %v", runOnStartCalls)
assert.Contains(t, runOnStartCalls, "SupervisorAgent:Supervisor", "Run should have supervisor container OnStart")
mu.Unlock()
resumeHandler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != adk.ComponentOfAgent {
return ctx
}
mu.Lock()
resumeOnStartCalls = append(resumeOnStartCalls, info.Name+":"+info.Type)
parentID, _ := ctx.Value(traceContextKey{}).(string)
resumeParentTraceIDs = append(resumeParentTraceIDs, info.Name+":parent="+parentID)
mu.Unlock()
if info.Type == "Supervisor" {
return context.WithValue(ctx, traceContextKey{}, "supervisor-trace-id")
}
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != adk.ComponentOfAgent {
return ctx
}
if agentOutput := adk.ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
resumeIter, err := runner.ResumeWithParams(ctx, checkpointID, &adk.ResumeParams{
Targets: map[string]any{
toolInterruptID: &approvalResult{Approved: true},
},
}, adk.WithCallbacks(resumeHandler))
assert.NoError(t, err)
for {
event, ok := resumeIter.Next()
if !ok {
break
}
assert.NoError(t, event.Err)
}
mu.Lock()
defer mu.Unlock()
t.Logf("Resume OnStart calls: %v", resumeOnStartCalls)
t.Logf("Resume parent trace IDs: %v", resumeParentTraceIDs)
assert.NotEmpty(t, resumeOnStartCalls, "Should have OnStart calls during resume")
assert.Contains(t, resumeOnStartCalls, "SupervisorAgent:Supervisor", "Resume should have supervisor container OnStart with type 'Supervisor'")
foundInnerSupervisorWithParent := false
for _, entry := range resumeParentTraceIDs {
if strings.Contains(entry, "SupervisorAgent") && !strings.Contains(entry, "parent=supervisor-trace-id") && entry != "SupervisorAgent:parent=" {
if strings.Contains(resumeOnStartCalls[0], "Supervisor") {
continue
}
}
if strings.Contains(entry, "parent=supervisor-trace-id") {
foundInnerSupervisorWithParent = true
}
}
assert.True(t, foundInnerSupervisorWithParent,
"Inner agents should have parent trace from Supervisor container during Resume. Got: %v", resumeParentTraceIDs)
}