Files
cloudwego--eino/adk/callback_integration_test.go
2026-07-13 12:49:10 +08:00

1063 lines
30 KiB
Go

/*
* Copyright 2026 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 adk
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/cloudwego/eino/callbacks"
mockModel "github.com/cloudwego/eino/internal/mock/components/model"
"github.com/cloudwego/eino/schema"
)
type callbackRecorder struct {
mu sync.Mutex
onStartCalled bool
onEndCalled bool
runInfo *callbacks.RunInfo
inputReceived *AgentCallbackInput
eventsReceived []*AgentEvent
eventsDone chan struct{}
closeOnce sync.Once
}
func (r *callbackRecorder) getOnStartCalled() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.onStartCalled
}
func (r *callbackRecorder) getOnEndCalled() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.onEndCalled
}
func (r *callbackRecorder) getEventsReceived() []*AgentEvent {
r.mu.Lock()
defer r.mu.Unlock()
result := make([]*AgentEvent, len(r.eventsReceived))
copy(result, r.eventsReceived)
return result
}
func newRecordingHandler(recorder *callbackRecorder) callbacks.Handler {
recorder.eventsDone = make(chan struct{})
return callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
recorder.mu.Lock()
defer recorder.mu.Unlock()
recorder.onStartCalled = true
recorder.runInfo = info
if agentInput := ConvAgentCallbackInput(input); agentInput != nil {
recorder.inputReceived = agentInput
}
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
recorder.mu.Lock()
recorder.onEndCalled = true
recorder.runInfo = info
recorder.mu.Unlock()
if agentOutput := ConvAgentCallbackOutput(output); agentOutput != nil {
if agentOutput.Events != nil {
go func() {
defer recorder.closeOnce.Do(func() { close(recorder.eventsDone) })
for {
event, ok := agentOutput.Events.Next()
if !ok {
break
}
recorder.mu.Lock()
recorder.eventsReceived = append(recorder.eventsReceived, event)
recorder.mu.Unlock()
}
}()
return ctx
}
}
recorder.closeOnce.Do(func() { close(recorder.eventsDone) })
return ctx
}).
Build()
}
func TestCallbackOnStartInvocation(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("test response", nil), nil).
Times(1)
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent for callback",
Instruction: "You are a test agent",
Model: cm,
})
assert.NoError(t, err)
recorder := &callbackRecorder{}
handler := newRecordingHandler(recorder)
runner := NewRunner(ctx, RunnerConfig{Agent: agent})
iter := runner.Query(ctx, "hello", WithCallbacks(handler))
for {
_, ok := iter.Next()
if !ok {
break
}
}
<-recorder.eventsDone
assert.True(t, recorder.onStartCalled, "OnStart should be called")
assert.NotNil(t, recorder.inputReceived, "Input should be received")
assert.NotNil(t, recorder.inputReceived.Input, "AgentInput should be set")
assert.Len(t, recorder.inputReceived.Input.Messages, 1)
}
func TestCallbackOnEndInvocation(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("test response", nil), nil).
Times(1)
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent for callback",
Instruction: "You are a test agent",
Model: cm,
})
assert.NoError(t, err)
recorder := &callbackRecorder{}
handler := newRecordingHandler(recorder)
runner := NewRunner(ctx, RunnerConfig{Agent: agent})
iter := runner.Query(ctx, "hello", WithCallbacks(handler))
for {
_, ok := iter.Next()
if !ok {
break
}
}
<-recorder.eventsDone
assert.True(t, recorder.onEndCalled, "OnEnd should be called")
assert.NotEmpty(t, recorder.eventsReceived, "Events should be received")
}
func TestCallbackRunInfoForChatModelAgent(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("test response", nil), nil).
Times(1)
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestChatAgent",
Description: "Test chat agent",
Instruction: "You are a test agent",
Model: cm,
})
assert.NoError(t, err)
recorder := &callbackRecorder{}
handler := newRecordingHandler(recorder)
runner := NewRunner(ctx, RunnerConfig{Agent: agent})
iter := runner.Query(ctx, "hello", WithCallbacks(handler))
for {
_, ok := iter.Next()
if !ok {
break
}
}
<-recorder.eventsDone
assert.NotNil(t, recorder.runInfo)
assert.Equal(t, "TestChatAgent", recorder.runInfo.Name)
assert.Equal(t, "ChatModel", recorder.runInfo.Type)
assert.Equal(t, ComponentOfAgent, recorder.runInfo.Component)
}
func TestMultipleCallbackHandlers(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("test response", nil), nil).
Times(1)
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Instruction: "You are a test agent",
Model: cm,
})
assert.NoError(t, err)
recorder1 := &callbackRecorder{}
recorder2 := &callbackRecorder{}
handler1 := newRecordingHandler(recorder1)
handler2 := newRecordingHandler(recorder2)
runner := NewRunner(ctx, RunnerConfig{Agent: agent})
iter := runner.Query(ctx, "hello", WithCallbacks(handler1, handler2))
for {
_, ok := iter.Next()
if !ok {
break
}
}
<-recorder1.eventsDone
<-recorder2.eventsDone
assert.True(t, recorder1.onStartCalled, "Handler1 OnStart should be called")
assert.True(t, recorder2.onStartCalled, "Handler2 OnStart should be called")
assert.True(t, recorder1.onEndCalled, "Handler1 OnEnd should be called")
assert.True(t, recorder2.onEndCalled, "Handler2 OnEnd should be called")
assert.NotEmpty(t, recorder1.eventsReceived, "Handler1 should receive events")
assert.NotEmpty(t, recorder2.eventsReceived, "Handler2 should receive events")
}
func TestCallbackWithWorkflowAgent(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm1 := mockModel.NewMockToolCallingChatModel(ctrl)
cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response 1", nil), nil).
Times(1)
cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes()
cm2 := mockModel.NewMockToolCallingChatModel(ctrl)
cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response 2", nil), nil).
Times(1)
cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes()
agent1, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent1",
Description: "First agent",
Instruction: "You are agent 1",
Model: cm1,
})
assert.NoError(t, err)
agent2, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent2",
Description: "Second agent",
Instruction: "You are agent 2",
Model: cm2,
})
assert.NoError(t, err)
seqAgent, err := NewSequentialAgent(ctx, &SequentialAgentConfig{
Name: "SequentialAgent",
Description: "Sequential workflow",
SubAgents: []Agent{agent1, agent2},
})
assert.NoError(t, err)
var callbackInfos []*callbacks.RunInfo
handler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component == ComponentOfAgent {
callbackInfos = append(callbackInfos, info)
}
return ctx
}).
Build()
runner := NewRunner(ctx, RunnerConfig{Agent: seqAgent})
iter := runner.Query(ctx, "hello", WithCallbacks(handler))
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.NotEmpty(t, callbackInfos, "OnStart should be called for agents")
foundAgent1 := false
foundAgent2 := false
for _, info := range callbackInfos {
if info.Name == "Agent1" && info.Type == "ChatModel" {
foundAgent1 = true
}
if info.Name == "Agent2" && info.Type == "ChatModel" {
foundAgent2 = true
}
}
assert.True(t, foundAgent1, "Agent1 callback should be invoked")
assert.True(t, foundAgent2, "Agent2 callback should be invoked")
}
func TestCallbackEventsMatchAgentOutput(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectedContent := "This is the test response content"
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage(expectedContent, nil), nil).
Times(1)
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Instruction: "You are a test agent",
Model: cm,
})
assert.NoError(t, err)
recorder := &callbackRecorder{}
handler := newRecordingHandler(recorder)
var agentEvents []*AgentEvent
runner := NewRunner(ctx, RunnerConfig{Agent: agent})
iter := runner.Query(ctx, "hello", WithCallbacks(handler))
for {
event, ok := iter.Next()
if !ok {
break
}
agentEvents = append(agentEvents, event)
}
<-recorder.eventsDone
assert.NotEmpty(t, agentEvents, "Agent should emit events")
assert.NotEmpty(t, recorder.eventsReceived, "Callback should receive events")
foundExpectedContent := false
for _, event := range recorder.eventsReceived {
if event.Output != nil && event.Output.MessageOutput != nil {
msg := event.Output.MessageOutput.Message
if msg != nil && msg.Content == expectedContent {
foundExpectedContent = true
break
}
}
}
assert.True(t, foundExpectedContent, "Callback events should contain the expected content")
}
func TestCallbackOnEndForWorkflowAgent(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm1 := mockModel.NewMockToolCallingChatModel(ctrl)
cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response 1", nil), nil).
Times(1)
cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes()
cm2 := mockModel.NewMockToolCallingChatModel(ctrl)
cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response 2", nil), nil).
Times(1)
cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes()
agent1, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent1",
Description: "First agent",
Instruction: "You are agent 1",
Model: cm1,
})
assert.NoError(t, err)
agent2, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent2",
Description: "Second agent",
Instruction: "You are agent 2",
Model: cm2,
})
assert.NoError(t, err)
seqAgent, err := NewSequentialAgent(ctx, &SequentialAgentConfig{
Name: "SequentialAgent",
Description: "Sequential workflow",
SubAgents: []Agent{agent1, agent2},
})
assert.NoError(t, err)
recorder := &callbackRecorder{}
handler := newRecordingHandler(recorder)
runner := NewRunner(ctx, RunnerConfig{Agent: seqAgent})
iter := runner.Query(ctx, "hello", WithCallbacks(handler))
for {
_, ok := iter.Next()
if !ok {
break
}
}
<-recorder.eventsDone
assert.True(t, recorder.getOnStartCalled(), "OnStart should be called for workflow agent")
assert.True(t, recorder.getOnEndCalled(), "OnEnd should be called for workflow agent")
assert.NotEmpty(t, recorder.getEventsReceived(), "Events should be received for workflow agent")
}
type ctxKeyForTest string
const testOnStartMarkerKey ctxKeyForTest = "onStartMarker"
func TestSubAgentContextIsolation(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm1 := mockModel.NewMockToolCallingChatModel(ctrl)
cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("transferring to Agent2",
[]schema.ToolCall{
{
ID: "transfer_1",
Function: schema.FunctionCall{
Name: TransferToAgentToolName,
Arguments: `{"agent_name": "Agent2"}`,
},
},
}), nil).
Times(1)
cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes()
cm2 := mockModel.NewMockToolCallingChatModel(ctrl)
cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("final response from Agent2", nil), nil).
Times(1)
cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes()
agent1, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent1",
Description: "First agent that transfers to Agent2",
Instruction: "You are agent 1",
Model: cm1,
})
assert.NoError(t, err)
agent2, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent2",
Description: "Second agent",
Instruction: "You are agent 2",
Model: cm2,
})
assert.NoError(t, err)
agentWithSubAgents, err := SetSubAgents(ctx, agent1, []Agent{agent2})
assert.NoError(t, err)
runner := NewRunner(ctx, RunnerConfig{Agent: agentWithSubAgents})
var mu sync.Mutex
onStartContextMarkers := make(map[string][]string)
handler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
mu.Lock()
marker, _ := ctx.Value(testOnStartMarkerKey).(string)
onStartContextMarkers[info.Name] = append(onStartContextMarkers[info.Name], marker)
mu.Unlock()
return context.WithValue(ctx, testOnStartMarkerKey, info.Name+"_marker")
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
if agentOutput := ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
iter := runner.Query(ctx, "hello", WithCallbacks(handler))
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
assert.NotEmpty(t, onStartContextMarkers["Agent1"], "Agent1's OnStart should be called")
assert.NotEmpty(t, onStartContextMarkers["Agent2"], "Agent2's OnStart should be called")
if len(onStartContextMarkers["Agent1"]) > 0 {
assert.Equal(t, "", onStartContextMarkers["Agent1"][0],
"Agent1's OnStart should receive context without marker (initial context)")
}
if len(onStartContextMarkers["Agent2"]) > 0 {
assert.Equal(t, "", onStartContextMarkers["Agent2"][0],
"Agent2's first OnStart should NOT inherit Agent1's marker - context should be isolated")
}
}
func TestCallbackDesignatedToSpecificAgent(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm1 := mockModel.NewMockToolCallingChatModel(ctrl)
cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("transferring to Agent2",
[]schema.ToolCall{
{
ID: "transfer_1",
Function: schema.FunctionCall{
Name: TransferToAgentToolName,
Arguments: `{"agent_name": "Agent2"}`,
},
},
}), nil).
Times(1)
cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes()
cm2 := mockModel.NewMockToolCallingChatModel(ctrl)
cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("final response from Agent2", nil), nil).
Times(1)
cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes()
agent1, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent1",
Description: "First agent that transfers to Agent2",
Instruction: "You are agent 1",
Model: cm1,
})
assert.NoError(t, err)
agent2, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent2",
Description: "Second agent",
Instruction: "You are agent 2",
Model: cm2,
})
assert.NoError(t, err)
agentWithSubAgents, err := SetSubAgents(ctx, agent1, []Agent{agent2})
assert.NoError(t, err)
runner := NewRunner(ctx, RunnerConfig{Agent: agentWithSubAgents})
var mu sync.Mutex
onStartCalls := make(map[string]int)
agent2OnlyHandler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
mu.Lock()
onStartCalls[info.Name]++
mu.Unlock()
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
if agentOutput := ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
iter := runner.Query(ctx, "hello", WithCallbacks(agent2OnlyHandler).DesignateAgent("Agent2"))
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 0, onStartCalls["Agent1"], "Agent1's OnStart should NOT be called when handler is designated to Agent2")
assert.Equal(t, 1, onStartCalls["Agent2"], "Agent2's OnStart should be called exactly once")
}
func TestCallbackDesignatedToMultipleAgents(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm1 := mockModel.NewMockToolCallingChatModel(ctrl)
cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("transferring to Agent2",
[]schema.ToolCall{
{
ID: "transfer_1",
Function: schema.FunctionCall{
Name: TransferToAgentToolName,
Arguments: `{"agent_name": "Agent2"}`,
},
},
}), nil).
Times(1)
cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes()
cm2 := mockModel.NewMockToolCallingChatModel(ctrl)
cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("final response from Agent2", nil), nil).
Times(1)
cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes()
agent1, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent1",
Description: "First agent",
Instruction: "You are agent 1",
Model: cm1,
})
assert.NoError(t, err)
agent2, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent2",
Description: "Second agent",
Instruction: "You are agent 2",
Model: cm2,
})
assert.NoError(t, err)
agentWithSubAgents, err := SetSubAgents(ctx, agent1, []Agent{agent2})
assert.NoError(t, err)
runner := NewRunner(ctx, RunnerConfig{Agent: agentWithSubAgents})
var mu sync.Mutex
onStartCalls := make(map[string]int)
agent1And2Handler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
mu.Lock()
onStartCalls[info.Name]++
mu.Unlock()
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
if agentOutput := ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
iter := runner.Query(ctx, "hello", WithCallbacks(agent1And2Handler).DesignateAgent("Agent1", "Agent2"))
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 1, onStartCalls["Agent1"], "Agent1's OnStart should be called exactly once")
assert.Equal(t, 1, onStartCalls["Agent2"], "Agent2's OnStart should be called exactly once")
}
func TestCallbackDesignatedExcludesNonMatchingAgents(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm1 := mockModel.NewMockToolCallingChatModel(ctrl)
cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("transferring to Agent2",
[]schema.ToolCall{
{
ID: "transfer_1",
Function: schema.FunctionCall{
Name: TransferToAgentToolName,
Arguments: `{"agent_name": "Agent2"}`,
},
},
}), nil).
Times(1)
cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes()
cm2 := mockModel.NewMockToolCallingChatModel(ctrl)
cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("final response from Agent2", nil), nil).
Times(1)
cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes()
agent1, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent1",
Description: "First agent",
Instruction: "You are agent 1",
Model: cm1,
})
assert.NoError(t, err)
agent2, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent2",
Description: "Second agent",
Instruction: "You are agent 2",
Model: cm2,
})
assert.NoError(t, err)
agentWithSubAgents, err := SetSubAgents(ctx, agent1, []Agent{agent2})
assert.NoError(t, err)
runner := NewRunner(ctx, RunnerConfig{Agent: agentWithSubAgents})
var mu sync.Mutex
onStartCalls := make(map[string]int)
agent1OnlyHandler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
mu.Lock()
onStartCalls[info.Name]++
mu.Unlock()
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
if agentOutput := ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
iter := runner.Query(ctx, "hello", WithCallbacks(agent1OnlyHandler).DesignateAgent("Agent1"))
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 1, onStartCalls["Agent1"], "Agent1's OnStart should be called exactly once")
assert.Equal(t, 0, onStartCalls["Agent2"], "Agent2's OnStart should NOT be called when handler is designated only to Agent1")
}
func TestMixedDesignatedAndGlobalCallbacks(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm1 := mockModel.NewMockToolCallingChatModel(ctrl)
cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("transferring to Agent2",
[]schema.ToolCall{
{
ID: "transfer_1",
Function: schema.FunctionCall{
Name: TransferToAgentToolName,
Arguments: `{"agent_name": "Agent2"}`,
},
},
}), nil).
Times(1)
cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes()
cm2 := mockModel.NewMockToolCallingChatModel(ctrl)
cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("final response from Agent2", nil), nil).
Times(1)
cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes()
agent1, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent1",
Description: "First agent that transfers to Agent2",
Instruction: "You are agent 1",
Model: cm1,
})
assert.NoError(t, err)
agent2, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent2",
Description: "Second agent",
Instruction: "You are agent 2",
Model: cm2,
})
assert.NoError(t, err)
agentWithSubAgents, err := SetSubAgents(ctx, agent1, []Agent{agent2})
assert.NoError(t, err)
runner := NewRunner(ctx, RunnerConfig{Agent: agentWithSubAgents})
var mu sync.Mutex
globalHandlerCalls := make(map[string]int)
agent2OnlyHandlerCalls := make(map[string]int)
globalHandler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
mu.Lock()
globalHandlerCalls[info.Name]++
mu.Unlock()
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
if agentOutput := ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
agent2OnlyHandler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
mu.Lock()
agent2OnlyHandlerCalls[info.Name]++
mu.Unlock()
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
if agentOutput := ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
iter := runner.Query(ctx, "hello",
WithCallbacks(globalHandler),
WithCallbacks(agent2OnlyHandler).DesignateAgent("Agent2"),
)
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 1, globalHandlerCalls["Agent1"], "Global handler should fire for Agent1")
assert.Equal(t, 1, globalHandlerCalls["Agent2"], "Global handler should fire for Agent2")
assert.Equal(t, 0, agent2OnlyHandlerCalls["Agent1"], "Agent2-only handler should NOT fire for Agent1")
assert.Equal(t, 1, agent2OnlyHandlerCalls["Agent2"], "Agent2-only handler should fire for Agent2")
}
func TestOnStartCalledOncePerAgentWithDesignation(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm1 := mockModel.NewMockToolCallingChatModel(ctrl)
cm1.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("transferring to Agent2",
[]schema.ToolCall{
{
ID: "transfer_1",
Function: schema.FunctionCall{
Name: TransferToAgentToolName,
Arguments: `{"agent_name": "Agent2"}`,
},
},
}), nil).
Times(1)
cm1.EXPECT().WithTools(gomock.Any()).Return(cm1, nil).AnyTimes()
cm2 := mockModel.NewMockToolCallingChatModel(ctrl)
cm2.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("final response from Agent2", nil), nil).
Times(1)
cm2.EXPECT().WithTools(gomock.Any()).Return(cm2, nil).AnyTimes()
agent1, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent1",
Description: "First agent that transfers to Agent2",
Instruction: "You are agent 1",
Model: cm1,
})
assert.NoError(t, err)
agent2, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "Agent2",
Description: "Second agent",
Instruction: "You are agent 2",
Model: cm2,
})
assert.NoError(t, err)
agentWithSubAgents, err := SetSubAgents(ctx, agent1, []Agent{agent2})
assert.NoError(t, err)
runner := NewRunner(ctx, RunnerConfig{Agent: agentWithSubAgents})
var mu sync.Mutex
onStartCalls := make(map[string]int)
handler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
mu.Lock()
onStartCalls[info.Name]++
mu.Unlock()
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if info.Component != ComponentOfAgent {
return ctx
}
if agentOutput := ConvAgentCallbackOutput(output); agentOutput != nil && agentOutput.Events != nil {
go func() {
for {
_, ok := agentOutput.Events.Next()
if !ok {
break
}
}
}()
}
return ctx
}).
Build()
iter := runner.Query(ctx, "hello", WithCallbacks(handler))
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 1, onStartCalls["Agent1"], "Agent1's OnStart should be called exactly once")
assert.Equal(t, 1, onStartCalls["Agent2"], "Agent2's OnStart should be called exactly once")
}