1746 lines
55 KiB
Go
1746 lines
55 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"
|
|
"errors"
|
|
"io"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/cloudwego/eino/components/model"
|
|
"github.com/cloudwego/eino/components/tool"
|
|
"github.com/cloudwego/eino/compose"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
type mockAgenticModel struct {
|
|
generateFn func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error)
|
|
streamFn func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error)
|
|
}
|
|
|
|
func (m *mockAgenticModel) Generate(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
return m.generateFn(ctx, input, opts...)
|
|
}
|
|
|
|
func (m *mockAgenticModel) Stream(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
|
if m.streamFn != nil {
|
|
return m.streamFn(ctx, input, opts...)
|
|
}
|
|
result, err := m.generateFn(ctx, input, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r, w := schema.Pipe[*schema.AgenticMessage](1)
|
|
go func() { defer w.Close(); w.Send(result, nil) }()
|
|
return r, nil
|
|
}
|
|
|
|
type testAgenticMiddleware struct {
|
|
*TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
|
beforeFn func(context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], *TypedModelContext[*schema.AgenticMessage]) (context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], error)
|
|
afterFn func(context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], *TypedModelContext[*schema.AgenticMessage]) (context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], error)
|
|
}
|
|
|
|
func (m *testAgenticMiddleware) BeforeModelRewriteState(ctx context.Context, state *TypedChatModelAgentState[*schema.AgenticMessage], mc *TypedModelContext[*schema.AgenticMessage]) (context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
|
if m.beforeFn != nil {
|
|
return m.beforeFn(ctx, state, mc)
|
|
}
|
|
return ctx, state, nil
|
|
}
|
|
|
|
func (m *testAgenticMiddleware) AfterModelRewriteState(ctx context.Context, state *TypedChatModelAgentState[*schema.AgenticMessage], mc *TypedModelContext[*schema.AgenticMessage]) (context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
|
if m.afterFn != nil {
|
|
return m.afterFn(ctx, state, mc)
|
|
}
|
|
return ctx, state, nil
|
|
}
|
|
|
|
func TestAgenticChatModelAgentRun_NoTools(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
agenticResponse := &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "Hello from agentic model"}),
|
|
},
|
|
}
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticResponse, nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticTestAgent",
|
|
Description: "Agentic test agent",
|
|
Instruction: "You are helpful.",
|
|
Model: m,
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, agent)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hi"),
|
|
},
|
|
}
|
|
iter := agent.Run(ctx, input)
|
|
require.NotNil(t, iter)
|
|
|
|
event, ok := iter.Next()
|
|
assert.True(t, ok)
|
|
require.NotNil(t, event)
|
|
assert.Nil(t, event.Err)
|
|
require.NotNil(t, event.Output)
|
|
require.NotNil(t, event.Output.MessageOutput)
|
|
|
|
msg := event.Output.MessageOutput.Message
|
|
require.NotNil(t, msg)
|
|
assert.Equal(t, schema.AgenticRoleTypeAssistant, msg.Role)
|
|
assert.Len(t, msg.ContentBlocks, 1)
|
|
assert.Equal(t, "Hello from agentic model", msg.ContentBlocks[0].AssistantGenText.Text)
|
|
|
|
_, ok = iter.Next()
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestAgenticChatModelAgentRun_WithTools(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
agenticResponse := &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "Used tool and got result"}),
|
|
},
|
|
}
|
|
|
|
var receivedToolInfos []*schema.ToolInfo
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
o := model.GetCommonOptions(&model.Options{}, opts...)
|
|
receivedToolInfos = o.Tools
|
|
return agenticResponse, nil
|
|
},
|
|
}
|
|
|
|
dummyTool := newSlowTool("dummy_tool", 0, "ok")
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticToolAgent",
|
|
Description: "Agentic agent with tools",
|
|
Instruction: "You are helpful.",
|
|
Model: m,
|
|
ToolsConfig: ToolsConfig{
|
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
|
Tools: []tool.BaseTool{dummyTool},
|
|
},
|
|
},
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, agent)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Call a tool"),
|
|
},
|
|
}
|
|
iter := agent.Run(ctx, input)
|
|
|
|
event, ok := iter.Next()
|
|
assert.True(t, ok)
|
|
assert.Nil(t, event.Err)
|
|
assert.NotNil(t, event.Output)
|
|
|
|
_, ok = iter.Next()
|
|
assert.False(t, ok)
|
|
|
|
require.Len(t, receivedToolInfos, 1)
|
|
assert.Equal(t, "dummy_tool", receivedToolInfos[0].Name)
|
|
}
|
|
|
|
func TestAgenticChatModelAgentRun_Streaming(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
chunk1 := &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "Hello "}),
|
|
},
|
|
}
|
|
chunk2 := &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "world"}),
|
|
},
|
|
}
|
|
|
|
m := &mockAgenticModel{
|
|
streamFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
|
r, w := schema.Pipe[*schema.AgenticMessage](2)
|
|
go func() {
|
|
defer w.Close()
|
|
w.Send(chunk1, nil)
|
|
w.Send(chunk2, nil)
|
|
}()
|
|
return r, nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticStreamAgent",
|
|
Description: "Agentic streaming agent",
|
|
Instruction: "You are helpful.",
|
|
Model: m,
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hi"),
|
|
},
|
|
EnableStreaming: true,
|
|
}
|
|
iter := agent.Run(ctx, input)
|
|
|
|
event, ok := iter.Next()
|
|
assert.True(t, ok)
|
|
assert.Nil(t, event.Err)
|
|
require.NotNil(t, event.Output)
|
|
require.NotNil(t, event.Output.MessageOutput)
|
|
require.NotNil(t, event.Output.MessageOutput.MessageStream)
|
|
event.Output.MessageOutput.MessageStream.Close()
|
|
|
|
_, ok = iter.Next()
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestDefaultAgenticGenModelInput(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("WithInstruction", func(t *testing.T) {
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hello"),
|
|
},
|
|
}
|
|
msgs, err := newDefaultGenModelInput[*schema.AgenticMessage]()(ctx, "Be helpful", input)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, msgs, 2)
|
|
assert.Equal(t, schema.AgenticRoleTypeSystem, msgs[0].Role)
|
|
assert.Equal(t, schema.AgenticRoleTypeUser, msgs[1].Role)
|
|
})
|
|
|
|
t.Run("WithoutInstruction", func(t *testing.T) {
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hello"),
|
|
},
|
|
}
|
|
msgs, err := newDefaultGenModelInput[*schema.AgenticMessage]()(ctx, "", input)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, msgs, 1)
|
|
assert.Equal(t, schema.AgenticRoleTypeUser, msgs[0].Role)
|
|
})
|
|
}
|
|
|
|
func TestAgenticRunnerQuery(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
agenticResponse := &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "query response"}),
|
|
},
|
|
}
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticResponse, nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "QueryAgent",
|
|
Description: "Query test agent",
|
|
Instruction: "Be helpful.",
|
|
Model: m,
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
|
|
Agent: agent,
|
|
})
|
|
|
|
iter := runner.Query(ctx, "What's up?")
|
|
|
|
event, ok := iter.Next()
|
|
assert.True(t, ok)
|
|
assert.Nil(t, event.Err)
|
|
|
|
_, ok = iter.Next()
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func agenticAssistantMessage(text string) *schema.AgenticMessage {
|
|
return &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: text}),
|
|
},
|
|
}
|
|
}
|
|
|
|
type mockAgenticRunnerAgent struct {
|
|
name string
|
|
description string
|
|
responses []*TypedAgentEvent[*schema.AgenticMessage]
|
|
callCount int
|
|
lastInput *TypedAgentInput[*schema.AgenticMessage]
|
|
enableStreaming bool
|
|
}
|
|
|
|
func (a *mockAgenticRunnerAgent) Name(_ context.Context) string { return a.name }
|
|
func (a *mockAgenticRunnerAgent) Description(_ context.Context) string { return a.description }
|
|
func (a *mockAgenticRunnerAgent) Run(_ context.Context, input *TypedAgentInput[*schema.AgenticMessage], _ ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
|
|
a.callCount++
|
|
a.lastInput = input
|
|
a.enableStreaming = input.EnableStreaming
|
|
|
|
iterator, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
|
|
go func() {
|
|
defer generator.Close()
|
|
for _, event := range a.responses {
|
|
generator.Send(event)
|
|
if event.Action != nil && event.Action.Exit {
|
|
break
|
|
}
|
|
}
|
|
}()
|
|
return iterator
|
|
}
|
|
|
|
type mockAgenticAgent struct {
|
|
name string
|
|
description string
|
|
responses []*TypedAgentEvent[*schema.AgenticMessage]
|
|
}
|
|
|
|
func (a *mockAgenticAgent) Name(_ context.Context) string { return a.name }
|
|
func (a *mockAgenticAgent) Description(_ context.Context) string { return a.description }
|
|
func (a *mockAgenticAgent) Run(_ context.Context, _ *TypedAgentInput[*schema.AgenticMessage], _ ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
|
|
iterator, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
|
|
go func() {
|
|
defer generator.Close()
|
|
for _, event := range a.responses {
|
|
generator.Send(event)
|
|
if event.Action != nil && event.Action.Exit {
|
|
break
|
|
}
|
|
}
|
|
}()
|
|
return iterator
|
|
}
|
|
|
|
type myAgenticAgent struct {
|
|
name string
|
|
runFn func(ctx context.Context, input *TypedAgentInput[*schema.AgenticMessage], options ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]]
|
|
resumeFn func(ctx context.Context, info *ResumeInfo, opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]]
|
|
}
|
|
|
|
func (m *myAgenticAgent) Name(_ context.Context) string {
|
|
if len(m.name) > 0 {
|
|
return m.name
|
|
}
|
|
return "myAgenticAgent"
|
|
}
|
|
func (m *myAgenticAgent) Description(_ context.Context) string { return "my agentic agent description" }
|
|
func (m *myAgenticAgent) Run(ctx context.Context, input *TypedAgentInput[*schema.AgenticMessage], options ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
|
|
return m.runFn(ctx, input, options...)
|
|
}
|
|
func (m *myAgenticAgent) Resume(ctx context.Context, info *ResumeInfo, opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
|
|
return m.resumeFn(ctx, info, opts...)
|
|
}
|
|
|
|
func TestAgenticChatModelAgentRun_WithMiddleware(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
agenticResponse := &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "Hello from agentic agent"}),
|
|
},
|
|
}
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticResponse, nil
|
|
},
|
|
}
|
|
|
|
afterModelExecuted := false
|
|
|
|
mw := &testAgenticMiddleware{
|
|
beforeFn: func(ctx context.Context, state *TypedChatModelAgentState[*schema.AgenticMessage], mc *TypedModelContext[*schema.AgenticMessage]) (context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
|
state.Messages = append(state.Messages, schema.UserAgenticMessage("extra"))
|
|
return ctx, state, nil
|
|
},
|
|
afterFn: func(ctx context.Context, state *TypedChatModelAgentState[*schema.AgenticMessage], mc *TypedModelContext[*schema.AgenticMessage]) (context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
|
assert.Len(t, state.Messages, 4)
|
|
afterModelExecuted = true
|
|
return ctx, state, nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticMiddlewareAgent",
|
|
Description: "Agentic agent with middleware",
|
|
Instruction: "You are helpful.",
|
|
Model: m,
|
|
Handlers: []TypedChatModelAgentMiddleware[*schema.AgenticMessage]{mw},
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hi"),
|
|
},
|
|
}
|
|
iter := agent.Run(ctx, input)
|
|
event, ok := iter.Next()
|
|
assert.True(t, ok)
|
|
assert.Nil(t, event.Err)
|
|
require.NotNil(t, event.Output)
|
|
require.NotNil(t, event.Output.MessageOutput)
|
|
require.NotNil(t, event.Output.MessageOutput.Message)
|
|
assert.Equal(t, schema.AgenticRoleTypeAssistant, event.Output.MessageOutput.Message.Role)
|
|
_, ok = iter.Next()
|
|
assert.False(t, ok)
|
|
assert.True(t, afterModelExecuted)
|
|
}
|
|
|
|
func TestAgenticAfterModel_NoTools_ModifyDoesNotAffectEvent(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticAssistantMessage("original content"), nil
|
|
},
|
|
}
|
|
|
|
var capturedMessages []*schema.AgenticMessage
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticAfterModelAgent",
|
|
Description: "Test AfterModelRewriteState",
|
|
Instruction: "You are helpful.",
|
|
Model: m,
|
|
Handlers: []TypedChatModelAgentMiddleware[*schema.AgenticMessage]{
|
|
&testAgenticMiddleware{
|
|
afterFn: func(ctx context.Context, state *TypedChatModelAgentState[*schema.AgenticMessage], mc *TypedModelContext[*schema.AgenticMessage]) (context.Context, *TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
|
capturedMessages = make([]*schema.AgenticMessage, len(state.Messages))
|
|
copy(capturedMessages, state.Messages)
|
|
state.Messages = append(state.Messages, agenticAssistantMessage("appended content"))
|
|
return ctx, state, nil
|
|
},
|
|
},
|
|
},
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hello"),
|
|
},
|
|
}
|
|
iterator := agent.Run(ctx, input)
|
|
|
|
event, ok := iterator.Next()
|
|
assert.True(t, ok)
|
|
assert.Nil(t, event.Err)
|
|
require.NotNil(t, event.Output)
|
|
require.NotNil(t, event.Output.MessageOutput)
|
|
|
|
msg := event.Output.MessageOutput.Message
|
|
require.NotNil(t, msg)
|
|
assert.Equal(t, "original content", msg.ContentBlocks[0].AssistantGenText.Text)
|
|
|
|
_, ok = iterator.Next()
|
|
assert.False(t, ok)
|
|
|
|
assert.Len(t, capturedMessages, 3)
|
|
}
|
|
|
|
func TestAgenticGetComposeOptions_WithChatModelOptions(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
var capturedTemperature float32
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
options := model.GetCommonOptions(&model.Options{}, opts...)
|
|
if options.Temperature != nil {
|
|
capturedTemperature = *options.Temperature
|
|
}
|
|
return agenticAssistantMessage("response"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticOptionsAgent",
|
|
Description: "Test agent",
|
|
Model: m,
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
temp := float32(0.7)
|
|
iter := agent.Run(ctx, &TypedAgentInput[*schema.AgenticMessage]{Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("test")}},
|
|
WithChatModelOptions([]model.Option{model.WithTemperature(temp)}))
|
|
for {
|
|
_, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, temp, capturedTemperature)
|
|
}
|
|
|
|
func TestAgenticChatModelAgent_PrepareExecContextError(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
expectedErr := errors.New("tool info error")
|
|
errTool := &errorTool{infoErr: expectedErr}
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticAssistantMessage("response"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticErrToolAgent",
|
|
Description: "Test agent",
|
|
Model: m,
|
|
ToolsConfig: ToolsConfig{
|
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
|
Tools: []tool.BaseTool{errTool},
|
|
},
|
|
},
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
iter := agent.Run(ctx, &TypedAgentInput[*schema.AgenticMessage]{Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("test")}})
|
|
|
|
event, ok := iter.Next()
|
|
assert.True(t, ok)
|
|
assert.NotNil(t, event.Err)
|
|
assert.Contains(t, event.Err.Error(), "tool info error")
|
|
|
|
_, ok = iter.Next()
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestAgenticChatModelAgentOutputKey(t *testing.T) {
|
|
t.Run("OutputKeyStoresInSession", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticAssistantMessage("Hello from agentic assistant."), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticOutputKeyAgent",
|
|
Description: "Test agent for output key",
|
|
Instruction: "You are helpful.",
|
|
Model: m,
|
|
OutputKey: "agent_output",
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hello"),
|
|
},
|
|
}
|
|
ctx, runCtx := initTypedRunCtx(ctx, "AgenticOutputKeyAgent", input)
|
|
require.NotNil(t, runCtx)
|
|
require.NotNil(t, runCtx.Session)
|
|
|
|
iterator := agent.Run(ctx, input)
|
|
|
|
event, ok := iterator.Next()
|
|
assert.True(t, ok)
|
|
assert.Nil(t, event.Err)
|
|
|
|
msg := event.Output.MessageOutput.Message
|
|
assert.Equal(t, "Hello from agentic assistant.", msg.ContentBlocks[0].AssistantGenText.Text)
|
|
|
|
_, ok = iterator.Next()
|
|
assert.False(t, ok)
|
|
|
|
sessionValues := GetSessionValues(ctx)
|
|
assert.Contains(t, sessionValues, "agent_output")
|
|
assert.Equal(t, "Hello from agentic assistant.", sessionValues["agent_output"])
|
|
})
|
|
|
|
t.Run("OutputKeyWithStreamingStoresInSession", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
chunk1 := agenticAssistantMessage("Hello")
|
|
chunk2 := agenticAssistantMessage(", world.")
|
|
|
|
m := &mockAgenticModel{
|
|
streamFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
|
r, w := schema.Pipe[*schema.AgenticMessage](2)
|
|
go func() {
|
|
defer w.Close()
|
|
w.Send(chunk1, nil)
|
|
w.Send(chunk2, nil)
|
|
}()
|
|
return r, nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "AgenticStreamOutputKeyAgent",
|
|
Description: "Test agent for streaming output key",
|
|
Instruction: "You are helpful.",
|
|
Model: m,
|
|
OutputKey: "agent_output",
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hello"),
|
|
},
|
|
EnableStreaming: true,
|
|
}
|
|
ctx, runCtx := initTypedRunCtx(ctx, "AgenticStreamOutputKeyAgent", input)
|
|
require.NotNil(t, runCtx)
|
|
require.NotNil(t, runCtx.Session)
|
|
|
|
iterator := agent.Run(ctx, input)
|
|
|
|
event, ok := iterator.Next()
|
|
assert.True(t, ok)
|
|
assert.Nil(t, event.Err)
|
|
assert.True(t, event.Output.MessageOutput.IsStreaming)
|
|
|
|
_, ok = iterator.Next()
|
|
assert.False(t, ok)
|
|
})
|
|
|
|
t.Run("SetOutputToSessionAgenticMessage", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("test")},
|
|
}
|
|
ctx, runCtx := initTypedRunCtx(ctx, "TestAgent", input)
|
|
require.NotNil(t, runCtx)
|
|
require.NotNil(t, runCtx.Session)
|
|
|
|
msg := agenticAssistantMessage("Test response")
|
|
err := setOutputToSession(ctx, msg, nil, "test_output")
|
|
assert.NoError(t, err)
|
|
|
|
sessionValues := GetSessionValues(ctx)
|
|
assert.Contains(t, sessionValues, "test_output")
|
|
assert.Equal(t, "Test response", sessionValues["test_output"])
|
|
})
|
|
}
|
|
|
|
func TestAgenticRunner_Run_WithStreaming(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
mockAgent_ := &mockAgenticRunnerAgent{
|
|
name: "AgenticStreamRunnerAgent",
|
|
description: "Test agent for agentic runner streaming",
|
|
responses: []*TypedAgentEvent[*schema.AgenticMessage]{
|
|
{
|
|
AgentName: "AgenticStreamRunnerAgent",
|
|
Output: &TypedAgentOutput[*schema.AgenticMessage]{
|
|
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
|
|
IsStreaming: true,
|
|
MessageStream: schema.StreamReaderFromArray([]*schema.AgenticMessage{
|
|
agenticAssistantMessage("Streaming response"),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{EnableStreaming: true, Agent: mockAgent_})
|
|
|
|
msgs := []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hello, agent!"),
|
|
}
|
|
|
|
iterator := runner.Run(ctx, msgs)
|
|
|
|
assert.Equal(t, 1, mockAgent_.callCount)
|
|
assert.Equal(t, msgs, mockAgent_.lastInput.Messages)
|
|
assert.True(t, mockAgent_.enableStreaming)
|
|
|
|
event, ok := iterator.Next()
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "AgenticStreamRunnerAgent", event.AgentName)
|
|
require.NotNil(t, event.Output)
|
|
require.NotNil(t, event.Output.MessageOutput)
|
|
assert.True(t, event.Output.MessageOutput.IsStreaming)
|
|
|
|
_, ok = iterator.Next()
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestAgenticRunner_Query_WithStreaming(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
mockAgent_ := &mockAgenticRunnerAgent{
|
|
name: "AgenticStreamQueryAgent",
|
|
description: "Test agent for agentic runner query streaming",
|
|
responses: []*TypedAgentEvent[*schema.AgenticMessage]{
|
|
{
|
|
AgentName: "AgenticStreamQueryAgent",
|
|
Output: &TypedAgentOutput[*schema.AgenticMessage]{
|
|
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
|
|
IsStreaming: true,
|
|
MessageStream: schema.StreamReaderFromArray([]*schema.AgenticMessage{
|
|
agenticAssistantMessage("Streaming query response"),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{EnableStreaming: true, Agent: mockAgent_})
|
|
|
|
iterator := runner.Query(ctx, "Test query")
|
|
|
|
assert.Equal(t, 1, mockAgent_.callCount)
|
|
assert.Len(t, mockAgent_.lastInput.Messages, 1)
|
|
assert.True(t, mockAgent_.enableStreaming)
|
|
|
|
event, ok := iterator.Next()
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "AgenticStreamQueryAgent", event.AgentName)
|
|
require.NotNil(t, event.Output)
|
|
require.NotNil(t, event.Output.MessageOutput)
|
|
assert.True(t, event.Output.MessageOutput.IsStreaming)
|
|
|
|
_, ok = iterator.Next()
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestAgenticSimpleInterrupt(t *testing.T) {
|
|
data := "hello world"
|
|
agent := &myAgenticAgent{
|
|
runFn: func(ctx context.Context, input *TypedAgentInput[*schema.AgenticMessage], options ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
|
|
iter, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
|
|
generator.Send(&TypedAgentEvent[*schema.AgenticMessage]{
|
|
Output: &TypedAgentOutput[*schema.AgenticMessage]{
|
|
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
|
|
IsStreaming: true,
|
|
MessageStream: schema.StreamReaderFromArray([]*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("hello "),
|
|
schema.UserAgenticMessage("world"),
|
|
}),
|
|
},
|
|
},
|
|
})
|
|
intEvent := TypedInterrupt[*schema.AgenticMessage](ctx, data)
|
|
intEvent.Action.Interrupted.Data = data
|
|
generator.Send(intEvent)
|
|
generator.Close()
|
|
return iter
|
|
},
|
|
resumeFn: func(ctx context.Context, info *ResumeInfo, opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
|
|
assert.True(t, info.WasInterrupted)
|
|
assert.Nil(t, info.InterruptState)
|
|
assert.True(t, info.EnableStreaming)
|
|
assert.Equal(t, data, info.Data)
|
|
|
|
assert.True(t, info.IsResumeTarget)
|
|
iter, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
|
|
generator.Close()
|
|
return iter
|
|
},
|
|
}
|
|
store := newMyStore()
|
|
ctx := context.Background()
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
|
|
Agent: agent,
|
|
EnableStreaming: true,
|
|
CheckPointStore: store,
|
|
})
|
|
iter := runner.Query(ctx, "hello world", WithCheckPointID("1"))
|
|
|
|
var interruptEvent *TypedAgentEvent[*schema.AgenticMessage]
|
|
for {
|
|
event, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if event.Action != nil && event.Action.Interrupted != nil {
|
|
interruptEvent = event
|
|
}
|
|
}
|
|
|
|
require.NotNil(t, interruptEvent)
|
|
assert.Equal(t, data, interruptEvent.Action.Interrupted.Data)
|
|
assert.NotEmpty(t, interruptEvent.Action.Interrupted.InterruptContexts[0].ID)
|
|
assert.True(t, interruptEvent.Action.Interrupted.InterruptContexts[0].IsRootCause)
|
|
assert.Equal(t, data, interruptEvent.Action.Interrupted.InterruptContexts[0].Info)
|
|
assert.Equal(t, Address{{Type: AddressSegmentAgent, ID: "myAgenticAgent"}},
|
|
interruptEvent.Action.Interrupted.InterruptContexts[0].Address)
|
|
}
|
|
|
|
func TestCascadingFrom_NewChatModelAgentFrom(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
agenticResponse := &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "from response"}),
|
|
},
|
|
}
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticResponse, nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "FromAgent",
|
|
Description: "Test cascading constructor",
|
|
Instruction: "Be helpful.",
|
|
Model: m,
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "FromAgent", agent.Name(ctx))
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{Agent: agent})
|
|
|
|
iter := runner.Run(ctx, []*schema.AgenticMessage{
|
|
schema.UserAgenticMessage("Hello"),
|
|
})
|
|
|
|
event, ok := iter.Next()
|
|
assert.True(t, ok)
|
|
assert.Nil(t, event.Err)
|
|
assert.NotNil(t, event.Output)
|
|
|
|
_, ok = iter.Next()
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestCascadingTyped_TypedStatefulInterrupt(t *testing.T) {
|
|
ctx := context.Background()
|
|
ctx = AppendAddressSegment(ctx, AddressSegmentAgent, "test-agent")
|
|
|
|
type myState struct {
|
|
Count int
|
|
}
|
|
|
|
event := TypedStatefulInterrupt[*schema.AgenticMessage](ctx, "please confirm", &myState{Count: 42})
|
|
require.NotNil(t, event)
|
|
require.NotNil(t, event.Action)
|
|
require.NotNil(t, event.Action.Interrupted)
|
|
}
|
|
|
|
func TestCascadingTyped_EventFromAgenticMessage(t *testing.T) {
|
|
msg := &schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "hello"}),
|
|
},
|
|
}
|
|
|
|
event := EventFromAgenticMessage(msg, nil, schema.AgenticRoleTypeAssistant)
|
|
require.NotNil(t, event)
|
|
require.NotNil(t, event.Output)
|
|
require.NotNil(t, event.Output.MessageOutput)
|
|
assert.Equal(t, msg, event.Output.MessageOutput.Message)
|
|
assert.False(t, event.Output.MessageOutput.IsStreaming)
|
|
assert.Equal(t, schema.RoleType(""), event.Output.MessageOutput.Role)
|
|
assert.Equal(t, schema.AgenticRoleTypeAssistant, event.Output.MessageOutput.AgenticRole)
|
|
assert.Empty(t, event.Output.MessageOutput.ToolName)
|
|
}
|
|
|
|
// assertAgenticEventRoleFields asserts that all AgenticMessage events in the
|
|
// list have zero-valued Role and ToolName fields (which are *schema.Message-only),
|
|
// and that AgenticRole is populated with a non-zero value.
|
|
func assertAgenticEventRoleFields(t *testing.T, events []*TypedAgentEvent[*schema.AgenticMessage]) {
|
|
t.Helper()
|
|
for i, event := range events {
|
|
if event.Output == nil || event.Output.MessageOutput == nil {
|
|
continue
|
|
}
|
|
mo := event.Output.MessageOutput
|
|
assert.Equal(t, schema.RoleType(""), mo.Role, "event[%d]: AgenticMessage must have zero Role", i)
|
|
assert.Empty(t, mo.ToolName, "event[%d]: AgenticMessage must have empty ToolName", i)
|
|
assert.NotEmpty(t, mo.AgenticRole, "event[%d]: AgenticMessage must have non-zero AgenticRole", i)
|
|
}
|
|
}
|
|
|
|
func TestCoverage_FlowAgent_ResumeNotResumable(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
agent := &mockAgenticAgent{
|
|
name: "non-resumable",
|
|
description: "cannot resume",
|
|
responses: []*TypedAgentEvent[*schema.AgenticMessage]{
|
|
{Output: &TypedAgentOutput[*schema.AgenticMessage]{
|
|
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
|
|
Message: agenticMsg("done"),
|
|
},
|
|
}},
|
|
},
|
|
}
|
|
|
|
fa := toTypedFlowAgent[*schema.AgenticMessage](agent)
|
|
|
|
info := &ResumeInfo{WasInterrupted: true}
|
|
iter := fa.Resume(ctx, info)
|
|
|
|
var capturedErr error
|
|
for {
|
|
event, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if event.Err != nil {
|
|
capturedErr = event.Err
|
|
}
|
|
}
|
|
require.Error(t, capturedErr, "should get error for non-resumable agent")
|
|
}
|
|
|
|
func TestCoverage_GenAgenticErrorIter(t *testing.T) {
|
|
testErr := errors.New("test agentic error")
|
|
iter := genAgenticErrorIter(testErr)
|
|
|
|
event, ok := iter.Next()
|
|
require.True(t, ok)
|
|
assert.Equal(t, testErr, event.Err)
|
|
|
|
_, ok = iter.Next()
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestCoverage_ChatModelAgent_OnSetSubAgents_FrozenError(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticMsg("done"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "freeze-test",
|
|
Description: "frozen test agent",
|
|
Model: m,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("Hi")},
|
|
}
|
|
iter := agent.Run(ctx, input)
|
|
for {
|
|
_, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
}
|
|
|
|
err = agent.OnSetSubAgents(ctx, []TypedAgent[*schema.AgenticMessage]{
|
|
&mockAgenticAgent{name: "late-child"},
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "frozen")
|
|
}
|
|
|
|
func TestCoverage_ChatModelAgent_OnSetAsSubAgent_FrozenError(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticMsg("done"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "freeze-child",
|
|
Description: "frozen child agent",
|
|
Model: m,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("Hi")},
|
|
}
|
|
iter := agent.Run(ctx, input)
|
|
for {
|
|
_, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
}
|
|
|
|
err = agent.OnSetAsSubAgent(ctx, &mockAgenticAgent{name: "parent"})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "frozen")
|
|
}
|
|
|
|
func TestCoverage_ChatModelAgent_OnSetAsSubAgent_DuplicateError(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticMsg("done"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "dup-child",
|
|
Description: "duplicate child agent",
|
|
Model: m,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = agent.OnSetAsSubAgent(ctx, &mockAgenticAgent{name: "parent1"})
|
|
assert.NoError(t, err)
|
|
|
|
err = agent.OnSetAsSubAgent(ctx, &mockAgenticAgent{name: "parent2"})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "already been set as a sub-agent")
|
|
}
|
|
|
|
func TestCoverage_ChatModelAgent_OnDisallowTransferToParent_FrozenError(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticMsg("done"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "disallow-test",
|
|
Description: "disallow transfer test",
|
|
Model: m,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
input := &TypedAgentInput[*schema.AgenticMessage]{
|
|
Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("Hi")},
|
|
}
|
|
iter := agent.Run(ctx, input)
|
|
for {
|
|
_, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
}
|
|
|
|
err = agent.OnDisallowTransferToParent(ctx)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "frozen")
|
|
}
|
|
|
|
func TestCoverage_TypedGetMessage_AgenticNonStreaming(t *testing.T) {
|
|
msg := agenticMsg("hello")
|
|
event := &TypedAgentEvent[*schema.AgenticMessage]{
|
|
Output: &TypedAgentOutput[*schema.AgenticMessage]{
|
|
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
|
|
Message: msg,
|
|
},
|
|
},
|
|
}
|
|
|
|
result, retEvent, err := TypedGetMessage(event)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, msg, result)
|
|
assert.Equal(t, event, retEvent)
|
|
}
|
|
|
|
func TestCoverage_TypedGetMessage_AgenticStreaming(t *testing.T) {
|
|
r, w := schema.Pipe[*schema.AgenticMessage](2)
|
|
go func() {
|
|
defer w.Close()
|
|
w.Send(&schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "Hello "}),
|
|
},
|
|
}, nil)
|
|
w.Send(&schema.AgenticMessage{
|
|
Role: schema.AgenticRoleTypeAssistant,
|
|
ContentBlocks: []*schema.ContentBlock{
|
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "world"}),
|
|
},
|
|
}, nil)
|
|
}()
|
|
|
|
event := &TypedAgentEvent[*schema.AgenticMessage]{
|
|
Output: &TypedAgentOutput[*schema.AgenticMessage]{
|
|
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
|
|
IsStreaming: true,
|
|
MessageStream: r,
|
|
},
|
|
},
|
|
}
|
|
|
|
result, retEvent, err := TypedGetMessage(event)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
require.NotNil(t, retEvent)
|
|
assert.NotNil(t, retEvent.Output.MessageOutput.MessageStream)
|
|
}
|
|
|
|
func TestCoverage_TypedGetMessage_NilOutput(t *testing.T) {
|
|
event := &TypedAgentEvent[*schema.AgenticMessage]{}
|
|
|
|
result, retEvent, err := TypedGetMessage(event)
|
|
assert.NoError(t, err)
|
|
assert.Nil(t, result)
|
|
assert.Equal(t, event, retEvent)
|
|
}
|
|
|
|
func TestCoverage_GetMessage_NonStreaming(t *testing.T) {
|
|
msg := schema.AssistantMessage("hello", nil)
|
|
event := &AgentEvent{
|
|
Output: &AgentOutput{
|
|
MessageOutput: &MessageVariant{
|
|
Message: msg,
|
|
},
|
|
},
|
|
}
|
|
|
|
result, retEvent, err := GetMessage(event)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, msg, result)
|
|
assert.Equal(t, event, retEvent)
|
|
}
|
|
|
|
func TestCoverage_GetMessage_Streaming(t *testing.T) {
|
|
r, w := schema.Pipe[*schema.Message](2)
|
|
go func() {
|
|
defer w.Close()
|
|
w.Send(schema.AssistantMessage("Hello ", nil), nil)
|
|
w.Send(schema.AssistantMessage("world", nil), nil)
|
|
}()
|
|
|
|
event := &AgentEvent{
|
|
Output: &AgentOutput{
|
|
MessageOutput: &MessageVariant{
|
|
IsStreaming: true,
|
|
MessageStream: r,
|
|
},
|
|
},
|
|
}
|
|
|
|
result, retEvent, err := GetMessage(event)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.NotNil(t, retEvent)
|
|
}
|
|
|
|
func TestCoverage_NewTypedAgentTool_Agentic(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return agenticMsg("tool response"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "tool-agent",
|
|
Description: "agent wrapped as tool",
|
|
Model: m,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
agentTool := NewTypedAgentTool[*schema.AgenticMessage](ctx, agent)
|
|
|
|
info, err := agentTool.Info(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "tool-agent", info.Name)
|
|
|
|
result, err := agentTool.(tool.InvokableTool).InvokableRun(ctx, `{"request":"test"}`)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result, "tool response")
|
|
}
|
|
func TestCoverage_CopyAgenticEvent(t *testing.T) {
|
|
original := &TypedAgentEvent[*schema.AgenticMessage]{
|
|
AgentName: "agent1",
|
|
RunPath: []RunStep{{agentName: "root"}, {agentName: "agent1"}},
|
|
Output: &TypedAgentOutput[*schema.AgenticMessage]{
|
|
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
|
|
Message: agenticMsg("hello"),
|
|
AgenticRole: schema.AgenticRoleTypeAssistant,
|
|
},
|
|
},
|
|
Action: &AgentAction{
|
|
TransferToAgent: &TransferToAgentAction{DestAgentName: "agent2"},
|
|
},
|
|
}
|
|
|
|
copied := copyTypedAgentEvent(original)
|
|
assert.Equal(t, original.AgentName, copied.AgentName)
|
|
assert.Equal(t, len(original.RunPath), len(copied.RunPath))
|
|
assert.Equal(t, original.Action, copied.Action)
|
|
assert.Equal(t, schema.AgenticRoleTypeAssistant, copied.Output.MessageOutput.AgenticRole)
|
|
|
|
copied.RunPath[0].agentName = "mutated"
|
|
assert.NotEqual(t, original.RunPath[0].agentName, copied.RunPath[0].agentName)
|
|
}
|
|
|
|
func TestCoverage_ChatModelAgent_ModelGenerateError(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
testErr := errors.New("model generate failed")
|
|
m := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return nil, testErr
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "error-model-agent",
|
|
Description: "tests model generate error",
|
|
Model: m,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
|
|
Agent: agent,
|
|
})
|
|
|
|
iter := runner.Query(ctx, "trigger error")
|
|
|
|
var capturedErr error
|
|
for {
|
|
event, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if event.Err != nil {
|
|
capturedErr = event.Err
|
|
}
|
|
}
|
|
require.Error(t, capturedErr, "should propagate model error")
|
|
}
|
|
|
|
func TestCoverage_NewTypedUserMessages(t *testing.T) {
|
|
t.Run("Message", func(t *testing.T) {
|
|
msgs := newTypedUserMessages[*schema.Message]("hello")
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, schema.User, msgs[0].Role)
|
|
assert.Equal(t, "hello", msgs[0].Content)
|
|
})
|
|
|
|
t.Run("AgenticMessage", func(t *testing.T) {
|
|
msgs := newTypedUserMessages[*schema.AgenticMessage]("hello")
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, schema.AgenticRoleTypeUser, msgs[0].Role)
|
|
})
|
|
}
|
|
|
|
func TestCoverage_TypedEndpointModel_NilEndpoints(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m := &typedEndpointModel[*schema.AgenticMessage]{}
|
|
|
|
_, err := m.Generate(ctx, nil)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "generate endpoint not set")
|
|
|
|
_, err = m.Stream(ctx, nil)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "stream endpoint not set")
|
|
}
|
|
|
|
func TestCoverage_TypedEndpointModel_WithEndpoints(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
expected := agenticMsg("generated")
|
|
m := &typedEndpointModel[*schema.AgenticMessage]{
|
|
generate: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return expected, nil
|
|
},
|
|
stream: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
|
r, w := schema.Pipe[*schema.AgenticMessage](1)
|
|
go func() {
|
|
defer w.Close()
|
|
w.Send(expected, nil)
|
|
}()
|
|
return r, nil
|
|
},
|
|
}
|
|
|
|
result, err := m.Generate(ctx, nil)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, expected, result)
|
|
|
|
stream, err := m.Stream(ctx, nil)
|
|
assert.NoError(t, err)
|
|
require.NotNil(t, stream)
|
|
msg, err := stream.Recv()
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, expected, msg)
|
|
_, err = stream.Recv()
|
|
assert.Equal(t, io.EOF, err)
|
|
}
|
|
|
|
func TestCoverage_SetAutomaticClose(t *testing.T) {
|
|
r, w := schema.Pipe[*schema.AgenticMessage](1)
|
|
go func() {
|
|
defer w.Close()
|
|
w.Send(agenticMsg("data"), nil)
|
|
}()
|
|
|
|
event := &TypedAgentEvent[*schema.AgenticMessage]{
|
|
Output: &TypedAgentOutput[*schema.AgenticMessage]{
|
|
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
|
|
IsStreaming: true,
|
|
MessageStream: r,
|
|
},
|
|
},
|
|
}
|
|
|
|
typedSetAutomaticClose(event)
|
|
}
|
|
|
|
func TestConcatMessageStream_AgenticClosesStream(t *testing.T) {
|
|
r, w := schema.Pipe[*schema.AgenticMessage](2)
|
|
go func() {
|
|
defer w.Close()
|
|
w.Send(agenticMsg("a"), nil)
|
|
w.Send(agenticMsg("b"), nil)
|
|
}()
|
|
|
|
result, err := concatMessageStream(r)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
_, recvErr := r.Recv()
|
|
assert.Error(t, recvErr,
|
|
"stream should be closed after concatMessageStream returns")
|
|
}
|
|
|
|
// --- Agentic retry/failover stream test helpers ---
|
|
|
|
func agenticStreamWithMidError(chunks []*schema.AgenticMessage, err error) *schema.StreamReader[*schema.AgenticMessage] {
|
|
sr, sw := schema.Pipe[*schema.AgenticMessage](len(chunks) + 1)
|
|
go func() {
|
|
defer sw.Close()
|
|
for _, c := range chunks {
|
|
sw.Send(c, nil)
|
|
}
|
|
sw.Send(nil, err)
|
|
}()
|
|
return sr
|
|
}
|
|
|
|
func agenticStreamOK(chunks []*schema.AgenticMessage) *schema.StreamReader[*schema.AgenticMessage] {
|
|
sr, sw := schema.Pipe[*schema.AgenticMessage](len(chunks))
|
|
go func() {
|
|
defer sw.Close()
|
|
for _, c := range chunks {
|
|
sw.Send(c, nil)
|
|
}
|
|
}()
|
|
return sr
|
|
}
|
|
|
|
func drainTypedAgenticEvents(t *testing.T, iter *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]]) *schema.AgenticMessage {
|
|
t.Helper()
|
|
var lastMsg *schema.AgenticMessage
|
|
for {
|
|
ev, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if ev.Err != nil {
|
|
var willRetry *WillRetryError
|
|
if errors.As(ev.Err, &willRetry) {
|
|
continue
|
|
}
|
|
t.Fatalf("unexpected error event: %v", ev.Err)
|
|
}
|
|
if ev.Output != nil && ev.Output.MessageOutput != nil {
|
|
if ev.Output.MessageOutput.IsStreaming && ev.Output.MessageOutput.MessageStream != nil {
|
|
sr := ev.Output.MessageOutput.MessageStream
|
|
for {
|
|
chunk, err := sr.Recv()
|
|
if err != nil {
|
|
break
|
|
}
|
|
lastMsg = chunk
|
|
}
|
|
} else if ev.Output.MessageOutput.Message != nil {
|
|
lastMsg = ev.Output.MessageOutput.Message
|
|
}
|
|
}
|
|
}
|
|
return lastMsg
|
|
}
|
|
|
|
func TestAgenticRetryWithShouldRetry_Generate(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
var callCount int32
|
|
var shouldRetryCalls int32
|
|
genErr := errors.New("transient generate error")
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
n := atomic.AddInt32(&callCount, 1)
|
|
if n == 1 {
|
|
return nil, genErr
|
|
}
|
|
return agenticMsg("retry ok"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "retry-gen-agent",
|
|
Description: "test retry generate",
|
|
Model: m,
|
|
ModelRetryConfig: &TypedModelRetryConfig[*schema.AgenticMessage]{
|
|
MaxRetries: 1,
|
|
ShouldRetry: func(_ context.Context, retryCtx *TypedRetryContext[*schema.AgenticMessage]) *TypedRetryDecision[*schema.AgenticMessage] {
|
|
n := atomic.AddInt32(&shouldRetryCalls, 1)
|
|
if n == 1 {
|
|
assert.Nil(t, retryCtx.OutputMessage, "OutputMessage should be nil when Generate returns error")
|
|
assert.ErrorIs(t, retryCtx.Err, genErr, "Err should be the generate error")
|
|
assert.Equal(t, 1, retryCtx.RetryAttempt)
|
|
return &TypedRetryDecision[*schema.AgenticMessage]{Retry: true}
|
|
}
|
|
return &TypedRetryDecision[*schema.AgenticMessage]{Retry: false}
|
|
},
|
|
BackoffFunc: func(_ context.Context, _ int) time.Duration { return time.Millisecond },
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{Agent: agent})
|
|
iter := runner.Run(ctx, []*schema.AgenticMessage{schema.UserAgenticMessage("hello")})
|
|
|
|
msg := drainTypedAgenticEvents(t, iter)
|
|
require.NotNil(t, msg, "should have received a final message")
|
|
assert.Equal(t, "retry ok", agenticTextContent(msg))
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(&callCount), "model should be called twice")
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(&shouldRetryCalls), "ShouldRetry should be called for both attempts")
|
|
}
|
|
|
|
func TestAgenticRetryWithShouldRetry_Stream(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
var streamCallCount int32
|
|
var shouldRetryCalls int32
|
|
streamErr := errors.New("mid-stream error")
|
|
|
|
m := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return nil, errors.New("generate should not be called")
|
|
},
|
|
streamFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
|
n := atomic.AddInt32(&streamCallCount, 1)
|
|
if n == 1 {
|
|
return agenticStreamWithMidError(
|
|
[]*schema.AgenticMessage{agenticMsg("partial")},
|
|
streamErr,
|
|
), nil
|
|
}
|
|
return agenticStreamOK([]*schema.AgenticMessage{agenticMsg("stream ok")}), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "retry-stream-agent",
|
|
Description: "test retry stream",
|
|
Model: m,
|
|
ModelRetryConfig: &TypedModelRetryConfig[*schema.AgenticMessage]{
|
|
MaxRetries: 1,
|
|
ShouldRetry: func(_ context.Context, retryCtx *TypedRetryContext[*schema.AgenticMessage]) *TypedRetryDecision[*schema.AgenticMessage] {
|
|
n := atomic.AddInt32(&shouldRetryCalls, 1)
|
|
if n == 1 {
|
|
assert.NotNil(t, retryCtx.OutputMessage, "OutputMessage should be non-nil from partial stream")
|
|
assert.Error(t, retryCtx.Err, "Err should be the stream error")
|
|
return &TypedRetryDecision[*schema.AgenticMessage]{Retry: true}
|
|
}
|
|
return nil
|
|
},
|
|
BackoffFunc: func(_ context.Context, _ int) time.Duration { return time.Millisecond },
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
|
|
Agent: agent,
|
|
EnableStreaming: true,
|
|
})
|
|
iter := runner.Run(ctx, []*schema.AgenticMessage{schema.UserAgenticMessage("hello")})
|
|
|
|
var lastMsg *schema.AgenticMessage
|
|
for {
|
|
ev, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if ev.Err != nil {
|
|
var willRetry *WillRetryError
|
|
if errors.As(ev.Err, &willRetry) {
|
|
continue
|
|
}
|
|
t.Fatalf("unexpected error: %v", ev.Err)
|
|
}
|
|
if ev.Output != nil && ev.Output.MessageOutput != nil {
|
|
if ev.Output.MessageOutput.IsStreaming && ev.Output.MessageOutput.MessageStream != nil {
|
|
sr := ev.Output.MessageOutput.MessageStream
|
|
for {
|
|
chunk, err := sr.Recv()
|
|
if err != nil {
|
|
break
|
|
}
|
|
lastMsg = chunk
|
|
}
|
|
} else if ev.Output.MessageOutput.Message != nil {
|
|
lastMsg = ev.Output.MessageOutput.Message
|
|
}
|
|
}
|
|
}
|
|
require.NotNil(t, lastMsg, "should have received final stream message")
|
|
assert.Contains(t, agenticTextContent(lastMsg), "stream ok")
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(&shouldRetryCalls), "ShouldRetry should be called for both attempts")
|
|
}
|
|
|
|
func TestAgenticFailoverGenerate(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m1Err := errors.New("m1 generate failed")
|
|
var m1Calls, m2Calls int32
|
|
|
|
m1 := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
atomic.AddInt32(&m1Calls, 1)
|
|
return nil, m1Err
|
|
},
|
|
}
|
|
m2 := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
atomic.AddInt32(&m2Calls, 1)
|
|
return agenticMsg("failover ok"), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "failover-gen-agent",
|
|
Description: "test failover generate",
|
|
Model: m1,
|
|
ModelFailoverConfig: &ModelFailoverConfig[*schema.AgenticMessage]{
|
|
MaxRetries: 1,
|
|
ShouldFailover: func(_ context.Context, _ *schema.AgenticMessage, err error) bool {
|
|
return err != nil
|
|
},
|
|
GetFailoverModel: func(_ context.Context, failoverCtx *FailoverContext[*schema.AgenticMessage]) (model.BaseModel[*schema.AgenticMessage], []*schema.AgenticMessage, error) {
|
|
assert.Equal(t, uint(1), failoverCtx.FailoverAttempt)
|
|
assert.Nil(t, failoverCtx.LastOutputMessage, "LastOutputMessage should be nil when Generate returns error")
|
|
assert.ErrorIs(t, failoverCtx.LastErr, m1Err)
|
|
return m2, nil, nil
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{Agent: agent})
|
|
iter := runner.Run(ctx, []*schema.AgenticMessage{schema.UserAgenticMessage("hello")})
|
|
|
|
msg := drainTypedAgenticEvents(t, iter)
|
|
require.NotNil(t, msg)
|
|
assert.Equal(t, "failover ok", agenticTextContent(msg))
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&m1Calls))
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&m2Calls))
|
|
}
|
|
|
|
func TestAgenticFailoverStream_MidStreamError(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
streamErr := errors.New("m1 mid-stream error")
|
|
var m1Calls, m2Calls int32
|
|
var capturedLastOutput *schema.AgenticMessage
|
|
|
|
m1 := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return nil, errors.New("unused")
|
|
},
|
|
streamFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
|
atomic.AddInt32(&m1Calls, 1)
|
|
return agenticStreamWithMidError(
|
|
[]*schema.AgenticMessage{agenticMsg("partial chunk")},
|
|
streamErr,
|
|
), nil
|
|
},
|
|
}
|
|
m2 := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
return nil, errors.New("unused")
|
|
},
|
|
streamFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
|
atomic.AddInt32(&m2Calls, 1)
|
|
return agenticStreamOK([]*schema.AgenticMessage{agenticMsg("failover stream ok")}), nil
|
|
},
|
|
}
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "failover-stream-agent",
|
|
Description: "test failover stream",
|
|
Model: m1,
|
|
ModelFailoverConfig: &ModelFailoverConfig[*schema.AgenticMessage]{
|
|
MaxRetries: 1,
|
|
ShouldFailover: func(_ context.Context, _ *schema.AgenticMessage, err error) bool {
|
|
return err != nil
|
|
},
|
|
GetFailoverModel: func(_ context.Context, failoverCtx *FailoverContext[*schema.AgenticMessage]) (model.BaseModel[*schema.AgenticMessage], []*schema.AgenticMessage, error) {
|
|
capturedLastOutput = failoverCtx.LastOutputMessage
|
|
return m2, nil, nil
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
|
|
Agent: agent,
|
|
EnableStreaming: true,
|
|
})
|
|
iter := runner.Run(ctx, []*schema.AgenticMessage{schema.UserAgenticMessage("hello")})
|
|
|
|
var lastMsg *schema.AgenticMessage
|
|
for {
|
|
ev, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if ev.Err != nil {
|
|
var willRetry *WillRetryError
|
|
if errors.As(ev.Err, &willRetry) {
|
|
continue
|
|
}
|
|
t.Fatalf("unexpected error: %v", ev.Err)
|
|
}
|
|
if ev.Output != nil && ev.Output.MessageOutput != nil {
|
|
if ev.Output.MessageOutput.IsStreaming && ev.Output.MessageOutput.MessageStream != nil {
|
|
sr := ev.Output.MessageOutput.MessageStream
|
|
for {
|
|
chunk, err := sr.Recv()
|
|
if err != nil {
|
|
break
|
|
}
|
|
lastMsg = chunk
|
|
}
|
|
} else if ev.Output.MessageOutput.Message != nil {
|
|
lastMsg = ev.Output.MessageOutput.Message
|
|
}
|
|
}
|
|
}
|
|
|
|
require.NotNil(t, lastMsg, "should have received final stream from m2")
|
|
assert.Contains(t, agenticTextContent(lastMsg), "failover stream ok")
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&m1Calls))
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&m2Calls))
|
|
assert.NotNil(t, capturedLastOutput, "failoverCtx.LastOutputMessage should contain partial stream from m1")
|
|
}
|
|
|
|
// TestAgenticFailoverGenerate_WithTools verifies that ModelFailoverConfig is
|
|
// honored on the ReAct (with-tools) path for *schema.AgenticMessage. This
|
|
// guards against the regression where buildAgenticReActRunFunc dropped the
|
|
// failover config, leaving ModelFailoverConfig as a no-op for typed agents
|
|
// that have any tools configured.
|
|
func TestAgenticFailoverGenerate_WithTools(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
m1Err := errors.New("m1 generate failed")
|
|
var m1Calls, m2Calls, getFailoverCalls int32
|
|
|
|
m1 := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
atomic.AddInt32(&m1Calls, 1)
|
|
return nil, m1Err
|
|
},
|
|
}
|
|
m2 := &mockAgenticModel{
|
|
generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
|
atomic.AddInt32(&m2Calls, 1)
|
|
return agenticMsg("failover ok with tools"), nil
|
|
},
|
|
}
|
|
|
|
dummyTool := newSlowTool("dummy_tool", 0, "ok")
|
|
|
|
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
|
Name: "failover-react-agent",
|
|
Description: "test failover on ReAct path",
|
|
Model: m1,
|
|
ToolsConfig: ToolsConfig{
|
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
|
Tools: []tool.BaseTool{dummyTool},
|
|
},
|
|
},
|
|
ModelFailoverConfig: &ModelFailoverConfig[*schema.AgenticMessage]{
|
|
MaxRetries: 1,
|
|
ShouldFailover: func(_ context.Context, _ *schema.AgenticMessage, err error) bool {
|
|
return err != nil
|
|
},
|
|
GetFailoverModel: func(_ context.Context, failoverCtx *FailoverContext[*schema.AgenticMessage]) (model.BaseModel[*schema.AgenticMessage], []*schema.AgenticMessage, error) {
|
|
atomic.AddInt32(&getFailoverCalls, 1)
|
|
assert.Equal(t, uint(1), failoverCtx.FailoverAttempt)
|
|
assert.ErrorIs(t, failoverCtx.LastErr, m1Err)
|
|
return m2, nil, nil
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{Agent: agent})
|
|
iter := runner.Run(ctx, []*schema.AgenticMessage{schema.UserAgenticMessage("hello")})
|
|
|
|
msg := drainTypedAgenticEvents(t, iter)
|
|
require.NotNil(t, msg)
|
|
assert.Equal(t, "failover ok with tools", agenticTextContent(msg))
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&m1Calls))
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&m2Calls))
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&getFailoverCalls),
|
|
"GetFailoverModel must be invoked on the ReAct path; if zero, the failover config was dropped by buildAgenticReActRunFunc")
|
|
}
|