/* * 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") }) }