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

2572 lines
79 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 adk
import (
"context"
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/cloudwego/eino/components/model"
"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 testInstructionHandler struct {
*BaseChatModelAgentMiddleware
text string
}
func (h *testInstructionHandler) BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
if runCtx.Instruction == "" {
runCtx.Instruction = h.text
} else if h.text != "" {
runCtx.Instruction = runCtx.Instruction + "\n" + h.text
}
return ctx, runCtx, nil
}
type testInstructionFuncHandler struct {
*BaseChatModelAgentMiddleware
fn func(ctx context.Context, instruction string) (context.Context, string, error)
}
func (h *testInstructionFuncHandler) BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
newCtx, newInstruction, err := h.fn(ctx, runCtx.Instruction)
if err != nil {
return ctx, runCtx, err
}
runCtx.Instruction = newInstruction
return newCtx, runCtx, nil
}
type testToolsHandler struct {
*BaseChatModelAgentMiddleware
tools []tool.BaseTool
}
func (h *testToolsHandler) BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
runCtx.Tools = append(runCtx.Tools, h.tools...)
return ctx, runCtx, nil
}
type testToolsFuncHandler struct {
*BaseChatModelAgentMiddleware
fn func(ctx context.Context, tools []tool.BaseTool, returnDirectly map[string]bool) (context.Context, []tool.BaseTool, map[string]bool, error)
}
func (h *testToolsFuncHandler) BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
newCtx, newTools, newReturnDirectly, err := h.fn(ctx, runCtx.Tools, runCtx.ReturnDirectly)
if err != nil {
return ctx, runCtx, err
}
runCtx.Tools = newTools
runCtx.ReturnDirectly = newReturnDirectly
return newCtx, runCtx, nil
}
type testBeforeAgentHandler struct {
*BaseChatModelAgentMiddleware
fn func(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error)
}
func (h *testBeforeAgentHandler) BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
return h.fn(ctx, runCtx)
}
type testBeforeModelRewriteStateHandler struct {
*BaseChatModelAgentMiddleware
fn func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error)
}
func (h *testBeforeModelRewriteStateHandler) BeforeModelRewriteState(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
return h.fn(ctx, state, mc)
}
type testAfterModelRewriteStateHandler struct {
*BaseChatModelAgentMiddleware
fn func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error)
}
func (h *testAfterModelRewriteStateHandler) AfterModelRewriteState(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
return h.fn(ctx, state, mc)
}
type testToolWrapperHandler struct {
*BaseChatModelAgentMiddleware
wrapInvokableFn func(context.Context, InvokableToolCallEndpoint, *ToolContext) InvokableToolCallEndpoint
wrapStreamableFn func(context.Context, StreamableToolCallEndpoint, *ToolContext) StreamableToolCallEndpoint
}
func (h *testToolWrapperHandler) WrapInvokableToolCall(ctx context.Context, endpoint InvokableToolCallEndpoint, tCtx *ToolContext) (InvokableToolCallEndpoint, error) {
if h.wrapInvokableFn != nil {
return h.wrapInvokableFn(ctx, endpoint, tCtx), nil
}
return endpoint, nil
}
func (h *testToolWrapperHandler) WrapStreamableToolCall(ctx context.Context, endpoint StreamableToolCallEndpoint, tCtx *ToolContext) (StreamableToolCallEndpoint, error) {
if h.wrapStreamableFn != nil {
return h.wrapStreamableFn(ctx, endpoint, tCtx), nil
}
return endpoint, nil
}
type testModelWrapperHandler struct {
*BaseChatModelAgentMiddleware
fn func(context.Context, model.BaseChatModel, *ModelContext) model.BaseChatModel
}
func (h *testModelWrapperHandler) WrapModel(ctx context.Context, m model.BaseChatModel, mc *ModelContext) (model.BaseChatModel, error) {
return h.fn(ctx, m, mc), nil
}
func newTestInvokableToolCallWrapper(beforeFn, afterFn func()) func(context.Context, InvokableToolCallEndpoint, *ToolContext) InvokableToolCallEndpoint {
return func(_ context.Context, endpoint InvokableToolCallEndpoint, _ *ToolContext) InvokableToolCallEndpoint {
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
if beforeFn != nil {
beforeFn()
}
result, err := endpoint(ctx, argumentsInJSON, opts...)
if afterFn != nil {
afterFn()
}
return result, err
}
}
}
func newResultModifyingInvokableToolCallWrapper(modifyFn func(string) string) func(context.Context, InvokableToolCallEndpoint, *ToolContext) InvokableToolCallEndpoint {
return func(_ context.Context, endpoint InvokableToolCallEndpoint, _ *ToolContext) InvokableToolCallEndpoint {
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
result, err := endpoint(ctx, argumentsInJSON, opts...)
if err == nil && modifyFn != nil {
result = modifyFn(result)
}
return result, err
}
}
}
func newTestStreamableToolCallWrapper(beforeFn, afterFn func()) func(context.Context, StreamableToolCallEndpoint, *ToolContext) StreamableToolCallEndpoint {
return func(_ context.Context, endpoint StreamableToolCallEndpoint, _ *ToolContext) StreamableToolCallEndpoint {
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (*schema.StreamReader[string], error) {
if beforeFn != nil {
beforeFn()
}
result, err := endpoint(ctx, argumentsInJSON, opts...)
if afterFn != nil {
afterFn()
}
return result, err
}
}
}
func TestHandlerExecutionOrder(t *testing.T) {
t.Run("MultipleInstructionHandlersPipeline", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var capturedInstruction string
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...interface{}) (*schema.Message, error) {
if len(msgs) > 0 && msgs[0].Role == schema.System {
capturedInstruction = msgs[0].Content
}
return schema.AssistantMessage("response", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Instruction: "Base instruction.",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testInstructionHandler{text: "Handler 1 addition."},
&testInstructionHandler{text: "Handler 2 addition."},
&testInstructionFuncHandler{fn: func(ctx context.Context, instruction string) (context.Context, string, error) {
return ctx, instruction + "\nHandler 3 dynamic.", nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Contains(t, capturedInstruction, "Base instruction.")
assert.Contains(t, capturedInstruction, "Handler 1 addition.")
assert.Contains(t, capturedInstruction, "Handler 2 addition.")
assert.Contains(t, capturedInstruction, "Handler 3 dynamic.")
})
t.Run("MiddlewaresBeforeHandlers", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var capturedInstruction string
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...interface{}) (*schema.Message, error) {
if len(msgs) > 0 && msgs[0].Role == schema.System {
capturedInstruction = msgs[0].Content
}
return schema.AssistantMessage("response", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Instruction: "Base.",
Model: cm,
Middlewares: []AgentMiddleware{
{AdditionalInstruction: "Middleware instruction."},
},
Handlers: []ChatModelAgentMiddleware{
&testInstructionHandler{text: "Handler instruction."},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
middlewareIdx := len(capturedInstruction) - len("Middleware instruction.") - len("\nHandler instruction.")
handlerIdx := len(capturedInstruction) - len("Handler instruction.")
assert.True(t, middlewareIdx < handlerIdx, "Middleware should be applied before Handler")
})
}
func TestToolsHandlerCombinations(t *testing.T) {
t.Run("MultipleToolsHandlersAppend", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &fakeToolForTest{tarCount: 1}
tool2 := &fakeToolForTest{tarCount: 2}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
var capturedToolCount int
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
options := model.GetCommonOptions(&model.Options{}, opts...)
capturedToolCount = len(options.Tools)
return schema.AssistantMessage("response", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1},
},
},
Handlers: []ChatModelAgentMiddleware{
&testToolsHandler{tools: []tool.BaseTool{tool2}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, 2, capturedToolCount)
})
t.Run("ToolsFuncCanRemoveTools", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &namedTool{name: "tool1"}
tool2 := &namedTool{name: "tool2"}
tool3 := &namedTool{name: "tool3"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
var capturedToolNames []string
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
options := model.GetCommonOptions(&model.Options{}, opts...)
for _, t := range options.Tools {
capturedToolNames = append(capturedToolNames, t.Name)
}
return schema.AssistantMessage("response", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1, tool2, tool3},
},
},
Handlers: []ChatModelAgentMiddleware{
&testToolsFuncHandler{fn: func(ctx context.Context, tools []tool.BaseTool, returnDirectly map[string]bool) (context.Context, []tool.BaseTool, map[string]bool, error) {
filtered := make([]tool.BaseTool, 0)
for _, t := range tools {
info, _ := t.Info(ctx)
if info.Name != "tool2" {
filtered = append(filtered, t)
}
}
return ctx, filtered, returnDirectly, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Contains(t, capturedToolNames, "tool1")
assert.NotContains(t, capturedToolNames, "tool2")
assert.Contains(t, capturedToolNames, "tool3")
})
t.Run("ReturnDirectlyModification", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &namedTool{name: "tool1"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: "tool1", Arguments: "{}"}},
}), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1},
},
},
Handlers: []ChatModelAgentMiddleware{
&testToolsFuncHandler{fn: func(ctx context.Context, tools []tool.BaseTool, returnDirectly map[string]bool) (context.Context, []tool.BaseTool, map[string]bool, error) {
for _, t := range tools {
info, _ := t.Info(ctx)
if info.Name == "tool1" {
returnDirectly[info.Name] = true
}
}
return ctx, tools, returnDirectly, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
eventCount := 0
for {
event, ok := iter.Next()
if !ok {
break
}
eventCount++
if event.Output != nil && event.Output.MessageOutput != nil &&
event.Output.MessageOutput.Message != nil &&
event.Output.MessageOutput.Message.Role == schema.Tool {
assert.Equal(t, "tool1 result", event.Output.MessageOutput.Message.Content)
}
}
assert.Equal(t, 2, eventCount)
})
t.Run("DynamicToolCanBeCalledByModel", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
dynamicToolCalled := false
dynamicTool := &callableTool{
name: "dynamic_tool",
invokeFn: func() {
dynamicToolCalled = true
},
}
info, _ := dynamicTool.Info(ctx)
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("Using dynamic tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: info.Name, Arguments: "{}"}},
}), nil).Times(1)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("done", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testToolsHandler{tools: []tool.BaseTool{dynamicTool}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.True(t, dynamicToolCalled, "Dynamic tool should have been called")
})
}
func TestMessageRewriteHandlers(t *testing.T) {
t.Run("BeforeModelRewriteStatePipeline", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var capturedMsgCount int
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...interface{}) (*schema.Message, error) {
capturedMsgCount = len(msgs)
return schema.AssistantMessage("response", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Instruction: "instruction",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
state.Messages = append(state.Messages, schema.UserMessage("injected1"))
return ctx, state, nil
}},
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
state.Messages = append(state.Messages, schema.UserMessage("injected2"))
return ctx, state, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("original")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, 4, capturedMsgCount)
})
t.Run("AfterModelRewriteState", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
afterCalled := false
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testAfterModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
afterCalled = true
assert.True(t, len(state.Messages) > 0)
lastMsg := state.Messages[len(state.Messages)-1]
assert.Equal(t, schema.Assistant, lastMsg.Role)
return ctx, state, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.True(t, afterCalled)
})
}
func TestToolCallWrapperHandlers(t *testing.T) {
t.Run("MultipleToolWrappersPipeline", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
testTool := &namedTool{name: "test_tool"}
info, _ := testTool.Info(ctx)
var callOrder []string
var mu sync.Mutex
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: info.Name, Arguments: "{}"}},
}), nil).Times(1)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("done", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{testTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testToolWrapperHandler{wrapInvokableFn: newTestInvokableToolCallWrapper(
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper1-before")
mu.Unlock()
},
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper1-after")
mu.Unlock()
},
)},
&testToolWrapperHandler{wrapInvokableFn: newTestInvokableToolCallWrapper(
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper2-before")
mu.Unlock()
},
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper2-after")
mu.Unlock()
},
)},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, []string{"wrapper1-before", "wrapper2-before", "wrapper2-after", "wrapper1-after"}, callOrder)
})
t.Run("StreamingToolWrappersPipeline", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
testTool := &streamingNamedTool{name: "streaming_tool"}
info, _ := testTool.Info(ctx)
var callOrder []string
var mu sync.Mutex
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.StreamReaderFromArray([]*schema.Message{
schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: info.Name, Arguments: "{}"}},
}),
}), nil).Times(1)
cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.StreamReaderFromArray([]*schema.Message{
schema.AssistantMessage("done", nil),
}), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{testTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testToolWrapperHandler{wrapStreamableFn: newTestStreamableToolCallWrapper(
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper1-stream-before")
mu.Unlock()
},
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper1-stream-after")
mu.Unlock()
},
)},
&testToolWrapperHandler{wrapStreamableFn: newTestStreamableToolCallWrapper(
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper2-stream-before")
mu.Unlock()
},
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper2-stream-after")
mu.Unlock()
},
)},
},
})
assert.NoError(t, err)
r := NewRunner(ctx, RunnerConfig{Agent: agent, EnableStreaming: true, CheckPointStore: newBridgeStore()})
iter := r.Run(ctx, []Message{schema.UserMessage("test")})
var hasStreamingToolResult bool
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Output != nil && event.Output.MessageOutput != nil &&
event.Output.MessageOutput.IsStreaming &&
event.Output.MessageOutput.Role == schema.Tool {
hasStreamingToolResult = true
for {
_, err := event.Output.MessageOutput.MessageStream.Recv()
if err != nil {
break
}
}
}
}
assert.True(t, hasStreamingToolResult, "Should have streaming tool result")
assert.Equal(t, []string{"wrapper1-stream-before", "wrapper2-stream-before", "wrapper2-stream-after", "wrapper1-stream-after"}, callOrder,
"Streaming wrappers should be called in correct order")
})
t.Run("ToolWrapperCanModifyResult", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
testTool := &namedTool{name: "test_tool"}
info, _ := testTool.Info(ctx)
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: info.Name, Arguments: "{}"}},
}), nil).Times(1)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("done", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{testTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testToolWrapperHandler{wrapInvokableFn: newResultModifyingInvokableToolCallWrapper(func(result string) string {
return "modified: " + result
})},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Output != nil && event.Output.MessageOutput != nil &&
event.Output.MessageOutput.Message != nil &&
event.Output.MessageOutput.Message.Role == schema.Tool {
assert.Equal(t, "modified: test_tool result", event.Output.MessageOutput.Message.Content)
}
}
})
}
func TestToolContextFunctions(t *testing.T) {
t.Run("ModelContextToolsField", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
testTool := &namedTool{name: "base_tool"}
info, _ := testTool.Info(ctx)
var wrapperSeenTools []*schema.ToolInfo
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("done", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{testTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testModelWrapperHandler{
BaseChatModelAgentMiddleware: &BaseChatModelAgentMiddleware{},
fn: func(_ context.Context, m model.BaseChatModel, mc *ModelContext) model.BaseChatModel {
return &toolChainingTestModel{
inner: m,
mc: mc,
wrapFn: func(ctx context.Context, opts []model.Option) []model.Option {
wrapperSeenTools = mc.Tools
return opts
},
}
},
},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Len(t, wrapperSeenTools, 1, "Wrapper should see base tool")
assert.Equal(t, info.Name, wrapperSeenTools[0].Name, "Wrapper should see base_tool")
})
}
type toolChainingTestModel struct {
inner model.BaseChatModel
mc *ModelContext
wrapFn func(ctx context.Context, opts []model.Option) []model.Option
}
func (m *toolChainingTestModel) Generate(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
if m.wrapFn != nil {
opts = m.wrapFn(ctx, opts)
}
return m.inner.Generate(ctx, msgs, opts...)
}
func (m *toolChainingTestModel) Stream(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
if m.wrapFn != nil {
opts = m.wrapFn(ctx, opts)
}
return m.inner.Stream(ctx, msgs, opts...)
}
func (m *toolChainingTestModel) BindTools(tools []*schema.ToolInfo) error {
return nil
}
func TestContextPropagation(t *testing.T) {
t.Run("ContextPassedThroughBeforeModelHandlers", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
type ctxKey string
const key1 ctxKey = "key1"
const key2 ctxKey = "key2"
var handler2ReceivedValue1 interface{}
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
return context.WithValue(ctx, key1, "value1"), state, nil
}},
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
handler2ReceivedValue1 = ctx.Value(key1)
return context.WithValue(ctx, key2, "value2"), state, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, "value1", handler2ReceivedValue1, "Handler 2 should receive context value set by Handler 1")
})
t.Run("BeforeAgentContextPropagation", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
type ctxKey string
const key1 ctxKey = "key1"
var handler2ReceivedValue interface{}
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeAgentHandler{fn: func(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
return context.WithValue(ctx, key1, "value1"), runCtx, nil
}},
&testBeforeAgentHandler{fn: func(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
handler2ReceivedValue = ctx.Value(key1)
return ctx, runCtx, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, "value1", handler2ReceivedValue, "Handler 2 should receive context value set by Handler 1 during BeforeAgent")
})
}
func TestCustomHandler(t *testing.T) {
t.Run("CustomHandlerWithState", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
customHandler := &countingHandler{}
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{customHandler},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, 1, customHandler.beforeAgentCount)
assert.Equal(t, 1, customHandler.afterAgentCount)
assert.Equal(t, 1, customHandler.beforeModelCount)
assert.Equal(t, 1, customHandler.afterModelCount)
})
}
func TestHandlerErrorHandling(t *testing.T) {
t.Run("BeforeAgentErrorStopsRun", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeAgentHandler{fn: func(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
return ctx, runCtx, assert.AnError
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []*schema.Message{schema.UserMessage("test")},
})
var gotErr error
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Err != nil {
gotErr = event.Err
}
}
assert.Error(t, gotErr)
assert.Contains(t, gotErr.Error(), "BeforeAgent failed")
})
}
type namedTool struct {
name string
}
func (t *namedTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{Name: t.name, Desc: t.name + " description"}, nil
}
func (t *namedTool) InvokableRun(_ context.Context, _ string, _ ...tool.Option) (string, error) {
return t.name + " result", nil
}
type streamingNamedTool struct {
name string
}
func (t *streamingNamedTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{Name: t.name, Desc: t.name + " description"}, nil
}
func (t *streamingNamedTool) InvokableRun(_ context.Context, _ string, _ ...tool.Option) (string, error) {
return t.name + " result", nil
}
func (t *streamingNamedTool) StreamableRun(_ context.Context, _ string, _ ...tool.Option) (*schema.StreamReader[string], error) {
return schema.StreamReaderFromArray([]string{t.name + " stream result"}), nil
}
type callableTool struct {
name string
invokeFn func()
}
func (t *callableTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{Name: t.name, Desc: t.name + " description"}, nil
}
func (t *callableTool) InvokableRun(_ context.Context, _ string, _ ...tool.Option) (string, error) {
if t.invokeFn != nil {
t.invokeFn()
}
return t.name + " result", nil
}
type countingHandler struct {
*BaseChatModelAgentMiddleware
beforeAgentCount int
afterAgentCount int
beforeModelCount int
afterModelCount int
mu sync.Mutex
}
func (h *countingHandler) BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) {
h.mu.Lock()
h.beforeAgentCount++
h.mu.Unlock()
return ctx, runCtx, nil
}
func (h *countingHandler) AfterAgent(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
h.mu.Lock()
h.afterAgentCount++
h.mu.Unlock()
return ctx, nil
}
func (h *countingHandler) BeforeModelRewriteState(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
h.mu.Lock()
h.beforeModelCount++
h.mu.Unlock()
return ctx, state, nil
}
func (h *countingHandler) AfterModelRewriteState(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
h.mu.Lock()
h.afterModelCount++
h.mu.Unlock()
return ctx, state, nil
}
func newTestModelWrapperFn(beforeFn, afterFn func()) func(context.Context, model.BaseChatModel, *ModelContext) model.BaseChatModel {
return func(_ context.Context, m model.BaseChatModel, _ *ModelContext) model.BaseChatModel {
return &testWrappedModel{
inner: m,
beforeFn: beforeFn,
afterFn: afterFn,
}
}
}
type testWrappedModel struct {
inner model.BaseChatModel
beforeFn func()
afterFn func()
}
func (m *testWrappedModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
if m.beforeFn != nil {
m.beforeFn()
}
result, err := m.inner.Generate(ctx, input, opts...)
if m.afterFn != nil {
m.afterFn()
}
return result, err
}
func (m *testWrappedModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
if m.beforeFn != nil {
m.beforeFn()
}
result, err := m.inner.Stream(ctx, input, opts...)
if m.afterFn != nil {
m.afterFn()
}
return result, err
}
func TestModelWrapperHandlers(t *testing.T) {
t.Run("MultipleModelWrappersPipeline", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var callOrder []string
var mu sync.Mutex
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testModelWrapperHandler{fn: newTestModelWrapperFn(
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper1-before")
mu.Unlock()
},
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper1-after")
mu.Unlock()
},
)},
&testModelWrapperHandler{fn: newTestModelWrapperFn(
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper2-before")
mu.Unlock()
},
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper2-after")
mu.Unlock()
},
)},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, []string{"wrapper1-before", "wrapper2-before", "wrapper2-after", "wrapper1-after"}, callOrder)
})
t.Run("ModelWrapperBeforeAfterCallOrder", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var callOrder []string
var mu sync.Mutex
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
mu.Lock()
callOrder = append(callOrder, "model-generate")
mu.Unlock()
return schema.AssistantMessage("original response", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testModelWrapperHandler{fn: newTestModelWrapperFn(
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper-before")
mu.Unlock()
},
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper-after")
mu.Unlock()
},
)},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, []string{"wrapper-before", "model-generate", "wrapper-after"}, callOrder)
})
t.Run("ModelWrapperWithTools", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
testTool := &namedTool{name: "test_tool"}
info, _ := testTool.Info(ctx)
var callOrder []string
var mu sync.Mutex
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
mu.Lock()
callOrder = append(callOrder, "model-call")
mu.Unlock()
return schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: info.Name, Arguments: "{}"}},
}), nil
}).Times(1)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
mu.Lock()
callOrder = append(callOrder, "model-call")
mu.Unlock()
return schema.AssistantMessage("done", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{testTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testModelWrapperHandler{fn: newTestModelWrapperFn(
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper-before")
mu.Unlock()
},
func() {
mu.Lock()
callOrder = append(callOrder, "wrapper-after")
mu.Unlock()
},
)},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, []string{
"wrapper-before", "model-call", "wrapper-after",
"wrapper-before", "model-call", "wrapper-after",
}, callOrder)
})
}
func newInputModifyingWrapperFn(inputPrefix string) func(context.Context, model.BaseChatModel, *ModelContext) model.BaseChatModel {
return func(_ context.Context, m model.BaseChatModel, _ *ModelContext) model.BaseChatModel {
return &inputOutputModifyingModel{
inner: m,
inputPrefix: inputPrefix,
}
}
}
type inputOutputModifyingModel struct {
inner model.BaseChatModel
inputPrefix string
}
func (m *inputOutputModifyingModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
modifiedMessages := make([]*schema.Message, len(input))
for i, msg := range input {
if msg.Role == schema.User {
modifiedMessages[i] = schema.UserMessage(m.inputPrefix + msg.Content)
} else {
modifiedMessages[i] = msg
}
}
return m.inner.Generate(ctx, modifiedMessages, opts...)
}
func (m *inputOutputModifyingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
modifiedMessages := make([]*schema.Message, len(input))
for i, msg := range input {
if msg.Role == schema.User {
modifiedMessages[i] = schema.UserMessage(m.inputPrefix + msg.Content)
} else {
modifiedMessages[i] = msg
}
}
return m.inner.Stream(ctx, modifiedMessages, opts...)
}
func TestModelWrapper_InputModification(t *testing.T) {
t.Run("ModelWrapperModifiesInput_Generate", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var modelReceivedInput []*schema.Message
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
modelReceivedInput = input
return schema.AssistantMessage("original response", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testModelWrapperHandler{fn: newInputModifyingWrapperFn("[WRAPPER]")},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test input")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.NotNil(t, modelReceivedInput)
assert.True(t, len(modelReceivedInput) > 0)
found := false
for _, msg := range modelReceivedInput {
if msg.Content == "[WRAPPER]test input" {
found = true
break
}
}
assert.True(t, found, "Model should receive wrapper-modified input")
})
t.Run("ModelWrapperModifiesInput_Stream", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var modelReceivedInput []*schema.Message
cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
modelReceivedInput = input
return schema.StreamReaderFromArray([]*schema.Message{
schema.AssistantMessage("chunk1", nil),
schema.AssistantMessage("chunk2", nil),
}), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testModelWrapperHandler{fn: newInputModifyingWrapperFn("[WRAPPER]")},
},
})
assert.NoError(t, err)
r := NewRunner(ctx, RunnerConfig{Agent: agent, EnableStreaming: true, CheckPointStore: newBridgeStore()})
iter := r.Run(ctx, []Message{schema.UserMessage("test input")})
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Output != nil && event.Output.MessageOutput != nil &&
event.Output.MessageOutput.IsStreaming &&
event.Output.MessageOutput.Role == schema.Assistant {
for {
_, err := event.Output.MessageOutput.MessageStream.Recv()
if err != nil {
break
}
}
}
}
assert.NotNil(t, modelReceivedInput)
assert.True(t, len(modelReceivedInput) > 0)
found := false
for _, msg := range modelReceivedInput {
if msg.Content == "[WRAPPER]test input" {
found = true
break
}
}
assert.True(t, found, "Model should receive wrapper-modified input")
})
}
func TestRunLocalValueFunctions(t *testing.T) {
t.Run("SetAndGetRunLocalValue", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var capturedValue any
var capturedFound bool
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
err := SetRunLocalValue(ctx, "test_key", "test_value")
assert.NoError(t, err)
return ctx, state, nil
}},
&testAfterModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
val, found, err := GetRunLocalValue(ctx, "test_key")
assert.NoError(t, err)
capturedValue = val
capturedFound = found
return ctx, state, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.True(t, capturedFound, "Value should be found")
assert.Equal(t, "test_value", capturedValue, "Value should match what was set")
})
t.Run("DeleteRunLocalValue", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var valueAfterDelete any
var foundAfterDelete bool
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
err := SetRunLocalValue(ctx, "delete_key", "delete_value")
assert.NoError(t, err)
err = DeleteRunLocalValue(ctx, "delete_key")
assert.NoError(t, err)
return ctx, state, nil
}},
&testAfterModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
val, found, err := GetRunLocalValue(ctx, "delete_key")
assert.NoError(t, err)
valueAfterDelete = val
foundAfterDelete = found
return ctx, state, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.False(t, foundAfterDelete, "Value should not be found after deletion")
assert.Nil(t, valueAfterDelete, "Value should be nil after deletion")
})
t.Run("GetNonExistentKey", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var capturedValue any
var capturedFound bool
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
val, found, err := GetRunLocalValue(ctx, "non_existent_key")
assert.NoError(t, err)
capturedValue = val
capturedFound = found
return ctx, state, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.False(t, capturedFound, "Non-existent key should not be found")
assert.Nil(t, capturedValue, "Non-existent key should return nil value")
})
t.Run("RunLocalValueGobEncodabilityCheck", func(t *testing.T) {
type unregisteredType struct {
Data string
}
// unregistered custom struct should fail
err := checkGobEncodability("key", unregisteredType{Data: "hello"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not gob-serializable")
assert.Contains(t, err.Error(), "schema.RegisterName")
assert.Contains(t, err.Error(), "context.WithValue")
assert.Contains(t, err.Error(), "unregisteredType")
// primitives should succeed
assert.NoError(t, checkGobEncodability("key", "hello"))
assert.NoError(t, checkGobEncodability("key", 42))
assert.NoError(t, checkGobEncodability("key", true))
assert.NoError(t, checkGobEncodability("key", 3.14))
})
t.Run("RunLocalValueOutsideContext", func(t *testing.T) {
ctx := context.Background()
err := SetRunLocalValue(ctx, "key", "value")
assert.Error(t, err, "SetRunLocalValue should fail outside agent context")
assert.Contains(t, err.Error(), "SetRunLocalValue failed")
_, _, err = GetRunLocalValue(ctx, "key")
assert.Error(t, err, "GetRunLocalValue should fail outside agent context")
assert.Contains(t, err.Error(), "GetRunLocalValue failed")
err = DeleteRunLocalValue(ctx, "key")
assert.Error(t, err, "DeleteRunLocalValue should fail outside agent context")
assert.Contains(t, err.Error(), "DeleteRunLocalValue failed")
})
t.Run("RunLocalValuePersistsAcrossModelCalls", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
testTool := &namedTool{name: "test_tool"}
info, _ := testTool.Info(ctx)
var firstCallValue any
var secondCallValue any
callCount := 0
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: info.Name, Arguments: "{}"}},
}), nil).Times(1)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("done", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{testTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
callCount++
if callCount == 1 {
err := SetRunLocalValue(ctx, "persist_key", "persist_value")
assert.NoError(t, err)
val, _, _ := GetRunLocalValue(ctx, "persist_key")
firstCallValue = val
} else {
val, _, _ := GetRunLocalValue(ctx, "persist_key")
secondCallValue = val
}
return ctx, state, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, "persist_value", firstCallValue, "First call should set value")
assert.Equal(t, "persist_value", secondCallValue, "Value should persist to second model call")
})
}
func TestHandlerErrorPropagation(t *testing.T) {
t.Run("BeforeModelRewriteStateErrorStopsRun", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
return ctx, state, assert.AnError
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
var gotErr error
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Err != nil {
gotErr = event.Err
}
}
assert.Error(t, gotErr)
})
t.Run("AfterModelRewriteStateErrorStopsRun", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testAfterModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
return ctx, state, assert.AnError
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
var gotErr error
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Err != nil {
gotErr = event.Err
}
}
assert.Error(t, gotErr)
})
t.Run("MultipleHandlersFirstErrorStops", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
secondHandlerCalled := false
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
return ctx, state, assert.AnError
}},
&testBeforeModelRewriteStateHandler{fn: func(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) {
secondHandlerCalled = true
return ctx, state, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.False(t, secondHandlerCalled, "Second handler should not be called after first handler error")
})
}
func TestToolContextInWrappers(t *testing.T) {
t.Run("ToolContextHasCorrectNameAndCallID", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
testTool := &namedTool{name: "context_test_tool"}
info, _ := testTool.Info(ctx)
var capturedToolName string
var capturedCallID string
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "test_call_id_123", Function: schema.FunctionCall{Name: info.Name, Arguments: "{}"}},
}), nil).Times(1)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("done", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{testTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testToolWrapperHandler{
BaseChatModelAgentMiddleware: &BaseChatModelAgentMiddleware{},
wrapInvokableFn: func(_ context.Context, endpoint InvokableToolCallEndpoint, tCtx *ToolContext) InvokableToolCallEndpoint {
capturedToolName = tCtx.Name
capturedCallID = tCtx.CallID
return endpoint
},
},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, "context_test_tool", capturedToolName, "ToolContext should have correct tool name")
assert.Equal(t, "test_call_id_123", capturedCallID, "ToolContext should have correct call ID")
})
}
func TestAfterToolCallsHook(t *testing.T) {
t.Run("CalledAfterToolCalls", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &namedTool{name: "tool_alpha"}
tool2 := &namedTool{name: "tool_beta"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
// First call: model returns two tool calls
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("calling tools", []schema.ToolCall{
{ID: "call_1", Function: schema.FunctionCall{Name: "tool_alpha", Arguments: "{}"}},
{ID: "call_2", Function: schema.FunctionCall{Name: "tool_beta", Arguments: "{}"}},
}), nil).Times(1)
// Second call: model returns final response
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("done", nil), nil).Times(1)
var mu sync.Mutex
callCount := 0
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1, tool2},
},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}},
WithAfterToolCallsHook(func(ctx context.Context) error {
mu.Lock()
callCount++
mu.Unlock()
return nil
}))
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
// Should be called exactly once (one iteration with tool calls)
assert.Equal(t, 1, callCount)
})
t.Run("NotCalledWithoutToolCalls", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
// Model returns a direct response with no tool calls
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("direct response", nil), nil).Times(1)
callCount := 0
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}},
WithAfterToolCallsHook(func(ctx context.Context) error {
callCount++
return nil
}))
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, 0, callCount, "AfterToolCallsHook should not be called when no tool calls happen")
})
t.Run("ToolResultsInStateBeforeHookFires", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &namedTool{name: "mytool"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
// First call: model returns a tool call
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("calling", []schema.ToolCall{
{ID: "c1", Function: schema.FunctionCall{Name: "mytool", Arguments: "{}"}},
}), nil).Times(1)
// Second call: final response
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("final", nil), nil).Times(1)
var hookToolResultCount int
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1},
},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("original")}},
WithAfterToolCallsHook(func(ctx context.Context) error {
// Verify tool results are already in state when the hook fires
_ = compose.ProcessState(ctx, func(_ context.Context, st *State) error {
for _, msg := range st.Messages {
if msg.Role == schema.Tool {
hookToolResultCount++
}
}
return nil
})
return nil
}))
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, 1, hookToolResultCount, "Tool results should be in state when hook fires")
})
t.Run("HookErrorPropagation", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &namedTool{name: "mytool"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("calling", []schema.ToolCall{
{ID: "c1", Function: schema.FunctionCall{Name: "mytool", Arguments: "{}"}},
}), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1},
},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}},
WithAfterToolCallsHook(func(ctx context.Context) error {
return fmt.Errorf("hook failure")
}))
var sawError bool
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
assert.Contains(t, ev.Err.Error(), "hook failure")
sawError = true
}
}
assert.True(t, sawError, "hook error should propagate as an agent error event")
})
t.Run("HookCalledPerIteration", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &namedTool{name: "mytool"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
// Iteration 1: tool call
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("calling1", []schema.ToolCall{
{ID: "c1", Function: schema.FunctionCall{Name: "mytool", Arguments: "{}"}},
}), nil).Times(1)
// Iteration 2: tool call again
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("calling2", []schema.ToolCall{
{ID: "c2", Function: schema.FunctionCall{Name: "mytool", Arguments: "{}"}},
}), nil).Times(1)
// Iteration 3: final answer
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("done", nil), nil).Times(1)
var mu sync.Mutex
hookCount := 0
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1},
},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}},
WithAfterToolCallsHook(func(ctx context.Context) error {
mu.Lock()
hookCount++
mu.Unlock()
return nil
}))
for {
_, ok := iter.Next()
if !ok {
break
}
}
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 2, hookCount, "hook should fire once per tool-call iteration")
})
}
func TestToolResultNotDuplicated(t *testing.T) {
t.Run("SecondModelCallHasNoToolResultDuplication", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &namedTool{name: "mytool"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("calling", []schema.ToolCall{
{ID: "c1", Function: schema.FunctionCall{Name: "mytool", Arguments: "{}"}},
}), nil).Times(1)
var capturedMsgs []*schema.Message
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...interface{}) (*schema.Message, error) {
capturedMsgs = append([]*schema.Message{}, msgs...)
return schema.AssistantMessage("final", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Instruction: "You are helpful.",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1},
},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("hello")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.NotNil(t, capturedMsgs)
assert.Equal(t, 4, len(capturedMsgs),
"expected [system, user, assistant, tool_result], got %d messages", len(capturedMsgs))
assert.Equal(t, schema.System, capturedMsgs[0].Role)
assert.Equal(t, schema.User, capturedMsgs[1].Role)
assert.Equal(t, schema.Assistant, capturedMsgs[2].Role)
assert.Equal(t, schema.Tool, capturedMsgs[3].Role)
toolResultCount := 0
for _, msg := range capturedMsgs {
if msg.Role == schema.Tool {
toolResultCount++
}
}
assert.Equal(t, 1, toolResultCount,
"tool result should appear exactly once, got %d", toolResultCount)
})
t.Run("HookInjectedMessagePresentWithoutDuplication", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
tool1 := &namedTool{name: "mytool"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("calling", []schema.ToolCall{
{ID: "c1", Function: schema.FunctionCall{Name: "mytool", Arguments: "{}"}},
}), nil).Times(1)
var capturedMsgs []*schema.Message
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...interface{}) (*schema.Message, error) {
capturedMsgs = append([]*schema.Message{}, msgs...)
return schema.AssistantMessage("final", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Instruction: "You are helpful.",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1},
},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("hello")}},
WithAfterToolCallsHook(func(ctx context.Context) error {
return compose.ProcessState(ctx, func(_ context.Context, st *State) error {
st.Messages = append(st.Messages, schema.UserMessage("injected"))
return nil
})
}))
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.NotNil(t, capturedMsgs)
assert.Equal(t, 5, len(capturedMsgs),
"expected [system, user, assistant, tool_result, injected], got %d messages", len(capturedMsgs))
assert.Equal(t, schema.System, capturedMsgs[0].Role)
assert.Equal(t, schema.User, capturedMsgs[1].Role)
assert.Equal(t, schema.Assistant, capturedMsgs[2].Role)
assert.Equal(t, schema.Tool, capturedMsgs[3].Role)
assert.Equal(t, "injected", capturedMsgs[4].Content)
toolResultCount := 0
for _, msg := range capturedMsgs {
if msg.Role == schema.Tool {
toolResultCount++
}
}
assert.Equal(t, 1, toolResultCount,
"tool result should appear exactly once, got %d", toolResultCount)
})
}
type testAfterAgentHandler struct {
*BaseChatModelAgentMiddleware
fn func(ctx context.Context, state *ChatModelAgentState) (context.Context, error)
}
func (h *testAfterAgentHandler) AfterAgent(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
return h.fn(ctx, state)
}
type testAgenticAfterAgentHandler struct {
*TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
fn func(ctx context.Context, state *TypedChatModelAgentState[*schema.AgenticMessage]) (context.Context, error)
}
func (h *testAgenticAfterAgentHandler) AfterAgent(ctx context.Context, state *TypedChatModelAgentState[*schema.AgenticMessage]) (context.Context, error) {
return h.fn(ctx, state)
}
func TestAfterAgent(t *testing.T) {
t.Run("FinalAnswer", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
var called bool
var capturedState *ChatModelAgentState
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
called = true
capturedState = state
return ctx, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.True(t, called, "AfterAgent should be called on final answer")
assert.NotNil(t, capturedState)
assert.GreaterOrEqual(t, len(capturedState.Messages), 2, "state should contain at least user + assistant messages")
})
t.Run("ReturnDirectly", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
myTool := &namedTool{name: "myTool"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: "myTool", Arguments: "{}"}},
}), nil).Times(1)
var called bool
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{myTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testToolsFuncHandler{fn: func(ctx context.Context, tools []tool.BaseTool, returnDirectly map[string]bool) (context.Context, []tool.BaseTool, map[string]bool, error) {
returnDirectly["myTool"] = true
return ctx, tools, returnDirectly, nil
}},
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
called = true
return ctx, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.True(t, called, "AfterAgent should be called on return-directly tool result")
})
t.Run("NotCalledOnModelError", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, fmt.Errorf("model error")).Times(1)
var called bool
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
called = true
return ctx, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.False(t, called, "AfterAgent should NOT be called when model errors")
})
t.Run("NotCalledOnMaxIterations", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
myTool := &namedTool{name: "myTool"}
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("Using tool", []schema.ToolCall{
{ID: "call1", Function: schema.FunctionCall{Name: "myTool", Arguments: "{}"}},
}), nil).AnyTimes()
var called bool
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
MaxIterations: 1,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{myTool},
},
},
Handlers: []ChatModelAgentMiddleware{
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
called = true
return ctx, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.False(t, called, "AfterAgent should NOT be called on max iterations exceeded")
})
t.Run("ErrorStopsRun", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
return ctx, fmt.Errorf("after agent hook error")
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
var gotErr error
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Err != nil {
gotErr = event.Err
}
}
assert.Error(t, gotErr)
assert.Contains(t, gotErr.Error(), "AfterAgent")
})
t.Run("ContextPropagation", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
type ctxKey string
const key1 ctxKey = "afterAgentKey"
var handler2ReceivedValue interface{}
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
return context.WithValue(ctx, key1, "afterValue"), nil
}},
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
handler2ReceivedValue = ctx.Value(key1)
return ctx, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.Equal(t, "afterValue", handler2ReceivedValue,
"Handler 2 should receive context value set by Handler 1 during AfterAgent")
})
t.Run("NoToolsPath", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
var called bool
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
called = true
return ctx, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.True(t, called, "AfterAgent should be called on no-tools path")
})
t.Run("FailFast", func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).Times(1)
var handler2Called bool
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAgent",
Description: "Test agent",
Model: cm,
Handlers: []ChatModelAgentMiddleware{
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
return ctx, fmt.Errorf("first handler error")
}},
&testAfterAgentHandler{fn: func(ctx context.Context, state *ChatModelAgentState) (context.Context, error) {
handler2Called = true
return ctx, nil
}},
},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("test")}})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.False(t, handler2Called, "Handler 2 should NOT be called when Handler 1 errors (fail-fast)")
})
t.Run("AgenticFinalAnswer", func(t *testing.T) {
ctx := context.Background()
agenticResponse := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "agentic response"}),
},
}
m := &mockAgenticModel{
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
return agenticResponse, nil
},
}
var called bool
var capturedState *TypedChatModelAgentState[*schema.AgenticMessage]
handler := &testAgenticAfterAgentHandler{fn: func(ctx context.Context, state *TypedChatModelAgentState[*schema.AgenticMessage]) (context.Context, error) {
called = true
capturedState = state
return ctx, nil
}}
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "AgenticTestAgent",
Description: "test",
Model: m,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{&namedTool{name: "dummyTool"}},
},
},
Handlers: []TypedChatModelAgentMiddleware[*schema.AgenticMessage]{handler},
})
assert.NoError(t, err)
iter := agent.Run(ctx, &TypedAgentInput[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("test")},
})
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.True(t, called, "AfterAgent should be called on agentic final answer")
assert.NotNil(t, capturedState)
assert.GreaterOrEqual(t, len(capturedState.Messages), 2, "state should contain at least user + assistant messages")
})
}