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

666 lines
19 KiB
Go

/*
* Copyright 2026 CloudWeGo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package adk
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"time"
"github.com/eino-contrib/jsonschema"
"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"
)
func agenticMsg(text string) *schema.AgenticMessage {
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: text}),
},
}
}
func agenticTextContent(msg *schema.AgenticMessage) string {
for _, b := range msg.ContentBlocks {
if b.AssistantGenText != nil {
return b.AssistantGenText.Text
}
}
return ""
}
func TestAgenticIntegration_ChatModelSingleShot(t *testing.T) {
ctx := context.Background()
m := &mockAgenticModel{
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
return agenticMsg("Handled internally with tool result: 42"), nil
},
}
dummyTool := newSlowTool("calculator", 0, "42")
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "ToolCallAgent",
Description: "Agent with tools for agentic model",
Instruction: "You are a calculator.",
Model: m,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{dummyTool},
},
},
})
require.NoError(t, err)
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: agent,
})
iter := runner.Query(ctx, "What is 6*7?")
var events []*TypedAgentEvent[*schema.AgenticMessage]
for {
event, ok := iter.Next()
if !ok {
break
}
events = append(events, event)
}
require.Len(t, events, 1)
assertAgenticEventRoleFields(t, events)
lastEvent := events[len(events)-1]
require.Nil(t, lastEvent.Err)
require.NotNil(t, lastEvent.Output)
require.NotNil(t, lastEvent.Output.MessageOutput)
assert.Equal(t, "Handled internally with tool result: 42",
agenticTextContent(lastEvent.Output.MessageOutput.Message))
}
func TestAgenticIntegration_ChatModelToolsPassedViaOptions(t *testing.T) {
ctx := context.Background()
var receivedTools []*schema.ToolInfo
m := &mockAgenticModel{
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
o := model.GetCommonOptions(&model.Options{}, opts...)
receivedTools = o.Tools
return agenticMsg("done"), nil
},
}
dummyTool := newSlowTool("my_tool", 0, "result")
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "ToolOptAgent",
Description: "Agent verifying tools are passed via options",
Model: m,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{dummyTool},
},
},
})
require.NoError(t, err)
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: agent,
})
iter := runner.Query(ctx, "test tools")
for {
_, ok := iter.Next()
if !ok {
break
}
}
require.NotNil(t, receivedTools, "tools should be passed via model.Options")
require.Len(t, receivedTools, 1)
assert.Equal(t, "my_tool", receivedTools[0].Name)
}
func TestAgenticIntegration_StreamingWithRunner(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: "StreamRunner",
Description: "Streaming runner agent",
Model: m,
})
require.NoError(t, err)
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: agent,
EnableStreaming: true,
})
iter := runner.Query(ctx, "stream me")
event, ok := iter.Next()
require.True(t, ok)
assert.Nil(t, event.Err)
require.NotNil(t, event.Output)
require.NotNil(t, event.Output.MessageOutput)
if event.Output.MessageOutput.IsStreaming {
require.NotNil(t, event.Output.MessageOutput.MessageStream)
var chunks []*schema.AgenticMessage
for {
chunk, err := event.Output.MessageOutput.MessageStream.Recv()
if err != nil {
break
}
chunks = append(chunks, chunk)
}
assert.Equal(t, 2, len(chunks))
} else {
assert.NotNil(t, event.Output.MessageOutput.Message)
}
_, ok = iter.Next()
assert.False(t, ok)
}
func TestAgenticIntegration_CancelDuringExecution(t *testing.T) {
ctx := context.Background()
modelStarted := make(chan struct{}, 1)
modelBlocked := make(chan struct{})
m := &mockAgenticModel{
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
select {
case modelStarted <- struct{}{}:
default:
}
select {
case <-modelBlocked:
return agenticMsg("should not reach"), nil
case <-ctx.Done():
return nil, ctx.Err()
}
},
}
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "CancelAgent",
Description: "cancel test",
Model: m,
})
require.NoError(t, err)
cancelCtx, cancel := context.WithCancel(ctx)
defer cancel()
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: agent,
})
iter := runner.Run(cancelCtx, []*schema.AgenticMessage{
schema.UserAgenticMessage("Hi"),
})
<-modelStarted
cancel()
var capturedErr error
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Err != nil {
capturedErr = event.Err
}
}
require.Error(t, capturedErr, "should propagate cancel error")
assert.ErrorIs(t, capturedErr, context.Canceled)
}
func TestAgenticIntegration_CancelWithTimeout(t *testing.T) {
ctx := context.Background()
sa := &myAgenticAgent{
name: "slow-agent",
runFn: func(ctx context.Context, input *TypedAgentInput[*schema.AgenticMessage], options ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
iter, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer generator.Close()
select {
case <-time.After(10 * time.Second):
generator.Send(&TypedAgentEvent[*schema.AgenticMessage]{
Output: &TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
Message: agenticMsg("slow response"),
},
},
})
case <-ctx.Done():
generator.Send(&TypedAgentEvent[*schema.AgenticMessage]{
Err: ctx.Err(),
})
}
}()
return iter
},
}
timeoutCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: sa,
})
iter := runner.Run(timeoutCtx, []*schema.AgenticMessage{
schema.UserAgenticMessage("slow request"),
})
var capturedErr error
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Err != nil {
capturedErr = event.Err
}
}
require.Error(t, capturedErr, "should get timeout/cancel error")
assert.ErrorIs(t, capturedErr, context.DeadlineExceeded)
}
func TestAgenticIntegration_AgentTool(t *testing.T) {
ctx := context.Background()
innerModel := &mockAgenticModel{
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
return agenticMsg("inner tool result"), nil
},
}
innerAgent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "InnerAgent",
Description: "An agent used as a tool",
Model: innerModel,
})
require.NoError(t, err)
agentTool := NewTypedAgentTool(ctx, TypedAgent[*schema.AgenticMessage](innerAgent))
require.NotNil(t, agentTool)
info, err := agentTool.Info(ctx)
require.NoError(t, err)
assert.Equal(t, "InnerAgent", info.Name)
assert.Equal(t, "An agent used as a tool", info.Desc)
outerModel := &mockAgenticModel{
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
return agenticMsg("outer response after inner tool"), nil
},
}
outerAgent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "OuterAgent",
Description: "Outer agent with agent tool",
Model: outerModel,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{agentTool},
},
},
})
require.NoError(t, err)
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: outerAgent,
})
iter := runner.Query(ctx, "delegate to inner")
var events []*TypedAgentEvent[*schema.AgenticMessage]
for {
event, ok := iter.Next()
if !ok {
break
}
events = append(events, event)
}
require.NotEmpty(t, events)
assertAgenticEventRoleFields(t, events)
lastEvent := events[len(events)-1]
assert.Nil(t, lastEvent.Err)
assert.NotNil(t, lastEvent.Output)
}
func TestAgenticIntegration_InterruptEventFormation(t *testing.T) {
ctx := context.Background()
t.Run("simple interrupt", func(t *testing.T) {
agent := &myAgenticAgent{
name: "int-agent",
runFn: func(ctx context.Context, input *TypedAgentInput[*schema.AgenticMessage], options ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
iter, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer generator.Close()
intEvent := TypedInterrupt[*schema.AgenticMessage](ctx, "approval needed")
intEvent.Action.Interrupted.Data = "approval data"
generator.Send(intEvent)
}()
return iter
},
}
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: agent,
})
iter := runner.Query(ctx, "interrupt test")
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, "approval data", interruptEvent.Action.Interrupted.Data)
require.NotEmpty(t, interruptEvent.Action.Interrupted.InterruptContexts)
assert.NotEmpty(t, interruptEvent.Action.Interrupted.InterruptContexts[0].ID)
assert.Equal(t, "approval needed", interruptEvent.Action.Interrupted.InterruptContexts[0].Info)
assert.True(t, interruptEvent.Action.Interrupted.InterruptContexts[0].IsRootCause)
})
t.Run("stateful interrupt", func(t *testing.T) {
agent := &myAgenticAgent{
name: "st-agent",
runFn: func(ctx context.Context, input *TypedAgentInput[*schema.AgenticMessage], options ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
iter, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer generator.Close()
intEvent := TypedStatefulInterrupt[*schema.AgenticMessage](ctx, "state interrupt", "my-state")
intEvent.Action.Interrupted.Data = "stateful data"
generator.Send(intEvent)
}()
return iter
},
}
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: agent,
})
iter := runner.Query(ctx, "stateful test")
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, "stateful data", interruptEvent.Action.Interrupted.Data)
require.NotEmpty(t, interruptEvent.Action.Interrupted.InterruptContexts)
assert.Equal(t, "state interrupt", interruptEvent.Action.Interrupted.InterruptContexts[0].Info)
})
}
func TestAgenticIntegration_CheckpointInterruptResume(t *testing.T) {
ctx := context.Background()
var resumeCalled int32
agent := &myAgenticAgent{
name: "ckpt-agent",
runFn: func(ctx context.Context, input *TypedAgentInput[*schema.AgenticMessage], options ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
iter, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer generator.Close()
generator.Send(&TypedAgentEvent[*schema.AgenticMessage]{
AgentName: "ckpt-agent",
Output: &TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
Message: agenticMsg("before interrupt"),
},
},
})
intEvent := TypedInterrupt[*schema.AgenticMessage](ctx, "need approval")
intEvent.Action.Interrupted.Data = "approval data"
generator.Send(intEvent)
}()
return iter
},
resumeFn: func(ctx context.Context, info *ResumeInfo, opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
atomic.StoreInt32(&resumeCalled, 1)
iter, generator := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer generator.Close()
generator.Send(&TypedAgentEvent[*schema.AgenticMessage]{
AgentName: "ckpt-agent",
Output: &TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
Message: agenticMsg("after resume"),
},
},
})
}()
return iter
},
}
store := newMyStore()
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: agent,
CheckPointStore: store,
})
iter := runner.Query(ctx, "checkpoint test", WithCheckPointID("ckpt-1"))
var interruptEvent *TypedAgentEvent[*schema.AgenticMessage]
var preInterruptOutputs []string
for {
event, ok := iter.Next()
if !ok {
break
}
require.Nil(t, event.Err)
if event.Action != nil && event.Action.Interrupted != nil {
interruptEvent = event
}
if event.Output != nil && event.Output.MessageOutput != nil && event.Output.MessageOutput.Message != nil {
preInterruptOutputs = append(preInterruptOutputs, agenticTextContent(event.Output.MessageOutput.Message))
}
}
require.NotNil(t, interruptEvent, "should receive interrupt event")
assert.Contains(t, preInterruptOutputs, "before interrupt")
require.NotEmpty(t, interruptEvent.Action.Interrupted.InterruptContexts)
interruptID := interruptEvent.Action.Interrupted.InterruptContexts[0].ID
require.NotEmpty(t, interruptID)
resumeIter, err := runner.ResumeWithParams(ctx, "ckpt-1", &ResumeParams{
Targets: map[string]any{
interruptID: nil,
},
})
require.NoError(t, err)
var postResumeOutputs []string
for {
event, ok := resumeIter.Next()
if !ok {
break
}
if event.Err != nil {
t.Fatalf("unexpected error during resume: %v", event.Err)
}
if event.Output != nil && event.Output.MessageOutput != nil && event.Output.MessageOutput.Message != nil {
postResumeOutputs = append(postResumeOutputs, agenticTextContent(event.Output.MessageOutput.Message))
}
}
assert.Equal(t, int32(1), atomic.LoadInt32(&resumeCalled), "resume function should have been called")
assert.Contains(t, postResumeOutputs, "after resume")
}
func TestAgenticIntegration_CheckpointWithMCPListToolsResult(t *testing.T) {
ctx := context.Background()
inputSchemaJSON := `{
"type": "object",
"properties": {
"query": {"type": "string", "description": "search query"},
"limit": {"type": "integer", "description": "max results"}
},
"required": ["query"]
}`
var inputSchema jsonschema.Schema
require.NoError(t, json.Unmarshal([]byte(inputSchemaJSON), &inputSchema))
mcpMsg := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
{
Type: schema.ContentBlockTypeMCPListToolsResult,
MCPListToolsResult: &schema.MCPListToolsResult{
ServerLabel: "test-server",
Tools: []*schema.MCPListToolsItem{
{
Name: "search",
Description: "search the web",
InputSchema: &inputSchema,
},
},
},
},
schema.NewContentBlock(&schema.AssistantGenText{Text: "here are tools"}),
},
}
var resumeCalled int32
agent := &myAgenticAgent{
name: "mcp-agent",
runFn: func(ctx context.Context, input *TypedAgentInput[*schema.AgenticMessage], options ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
iter, gen := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer gen.Close()
gen.Send(&TypedAgentEvent[*schema.AgenticMessage]{
AgentName: "mcp-agent",
Output: &TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{Message: mcpMsg},
},
})
gen.Send(TypedInterrupt[*schema.AgenticMessage](ctx, "approve tools"))
}()
return iter
},
resumeFn: func(ctx context.Context, info *ResumeInfo, opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[*schema.AgenticMessage]] {
atomic.StoreInt32(&resumeCalled, 1)
iter, gen := NewAsyncIteratorPair[*TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer gen.Close()
gen.Send(&TypedAgentEvent[*schema.AgenticMessage]{
AgentName: "mcp-agent",
Output: &TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{Message: agenticMsg("tools approved")},
},
})
}()
return iter
},
}
store := newMyStore()
runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
Agent: agent,
CheckPointStore: store,
})
iter := runner.Query(ctx, "list tools", WithCheckPointID("mcp-1"))
var interruptEvent *TypedAgentEvent[*schema.AgenticMessage]
for {
ev, ok := iter.Next()
if !ok {
break
}
require.Nil(t, ev.Err)
if ev.Action != nil && ev.Action.Interrupted != nil {
interruptEvent = ev
}
}
require.NotNil(t, interruptEvent)
interruptID := interruptEvent.Action.Interrupted.InterruptContexts[0].ID
resumeIter, err := runner.ResumeWithParams(ctx, "mcp-1", &ResumeParams{
Targets: map[string]any{interruptID: nil},
})
require.NoError(t, err)
var outputs []string
for {
ev, ok := resumeIter.Next()
if !ok {
break
}
require.Nil(t, ev.Err)
if ev.Output != nil && ev.Output.MessageOutput != nil && ev.Output.MessageOutput.Message != nil {
outputs = append(outputs, agenticTextContent(ev.Output.MessageOutput.Message))
}
}
assert.Equal(t, int32(1), atomic.LoadInt32(&resumeCalled))
assert.Contains(t, outputs, "tools approved")
}