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

1242 lines
40 KiB
Go

/*
* Copyright 2025 CloudWeGo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package adk
import (
"context"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"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 mockChatModelForAttack struct {
generateFn func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error)
}
func (m *mockChatModelForAttack) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
return m.generateFn(ctx, input, opts...)
}
func (m *mockChatModelForAttack) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
result, err := m.generateFn(ctx, input, opts...)
if err != nil {
return nil, err
}
r, w := schema.Pipe[*schema.Message](1)
go func() { defer w.Close(); w.Send(result, nil) }()
return r, nil
}
// mockAgent implements the Agent interface for testing
type mockAgentForTool struct {
name string
description string
responses []*AgentEvent
}
func (a *mockAgentForTool) Name(_ context.Context) string {
return a.name
}
func (a *mockAgentForTool) Description(_ context.Context) string {
return a.description
}
func (a *mockAgentForTool) Run(_ context.Context, _ *AgentInput, _ ...AgentRunOption) *AsyncIterator[*AgentEvent] {
iterator, generator := NewAsyncIteratorPair[*AgentEvent]()
go func() {
defer generator.Close()
for _, event := range a.responses {
generator.Send(event)
// If the event has an Exit action, stop sending events
if event.Action != nil && event.Action.Exit {
break
}
}
}()
return iterator
}
func newMockAgentForTool(name, description string, responses []*AgentEvent) *mockAgentForTool {
return &mockAgentForTool{
name: name,
description: description,
responses: responses,
}
}
func TestAgentTool_Info(t *testing.T) {
// Create a mock agent
mockAgent_ := newMockAgentForTool("TestAgent", "Test agent description", nil)
// Create an agentTool with the mock agent
agentTool_ := NewAgentTool(context.Background(), mockAgent_)
// Test the Info method
ctx := context.Background()
info, err := agentTool_.Info(ctx)
// Verify results
assert.NoError(t, err)
assert.NotNil(t, info)
assert.Equal(t, "TestAgent", info.Name)
assert.Equal(t, "Test agent description", info.Desc)
assert.NotNil(t, info.ParamsOneOf)
}
func TestAgentTool_Info_EmptyName(t *testing.T) {
agentTool_ := NewAgentTool(context.Background(), newMockAgentForTool("", "desc", nil))
_, err := agentTool_.Info(context.Background())
assert.ErrorContains(t, err, "non-empty Name")
}
func TestAgentTool_Info_EmptyDescription(t *testing.T) {
agentTool_ := NewAgentTool(context.Background(), newMockAgentForTool("name", "", nil))
_, err := agentTool_.Info(context.Background())
assert.ErrorContains(t, err, "non-empty Description")
}
func TestAgentTool_SharedParentSessionValues(t *testing.T) {
ctx := context.Background()
inner := &sessionValuesAgent{name: "inner"}
innerTool := NewAgentTool(ctx, inner).(tool.InvokableTool)
input := &AgentInput{Messages: []Message{schema.UserMessage("q")}}
ctx, _ = initRunCtx(ctx, "outer", input)
AddSessionValue(ctx, "parent_key", "parent_val")
parentSession := getRunCtx(ctx).Session
_, err := innerTool.InvokableRun(ctx, `{"request":"hello"}`)
assert.NoError(t, err)
assert.Equal(t, "parent_val", inner.seenParentValue)
assert.NotNil(t, inner.capturedSession)
assert.NotSame(t, parentSession, inner.capturedSession)
assert.NotNil(t, parentSession.valuesMtx)
assert.Same(t, parentSession.valuesMtx, inner.capturedSession.valuesMtx)
mtx := parentSession.valuesMtx
mtx.Lock()
inner.capturedSession.Values["direct_child_key"] = "direct_child_val"
mtx.Unlock()
mtx.Lock()
v2, ok2 := parentSession.Values["direct_child_key"]
mtx.Unlock()
assert.True(t, ok2)
assert.Equal(t, "direct_child_val", v2)
mtx.Lock()
parentSession.Values["direct_parent_key"] = "direct_parent_val"
mtx.Unlock()
mtx.Lock()
v3, ok3 := inner.capturedSession.Values["direct_parent_key"]
mtx.Unlock()
assert.True(t, ok3)
assert.Equal(t, "direct_parent_val", v3)
v, ok := GetSessionValue(ctx, "child_key")
assert.True(t, ok)
assert.Equal(t, "child_val", v)
}
type sessionValuesAgent struct {
name string
seenParentValue any
capturedSession *runSession
}
func (a *sessionValuesAgent) Name(context.Context) string { return a.name }
func (a *sessionValuesAgent) Description(context.Context) string { return "test" }
func (a *sessionValuesAgent) Run(ctx context.Context, _ *AgentInput, _ ...AgentRunOption) *AsyncIterator[*AgentEvent] {
if rc := getRunCtx(ctx); rc != nil {
a.capturedSession = rc.Session
}
a.seenParentValue, _ = GetSessionValue(ctx, "parent_key")
AddSessionValue(ctx, "child_key", "child_val")
it, gen := NewAsyncIteratorPair[*AgentEvent]()
gen.Send(&AgentEvent{
AgentName: a.name,
Output: &AgentOutput{
MessageOutput: &MessageVariant{
IsStreaming: false,
Message: schema.AssistantMessage("ok", nil),
Role: schema.Assistant,
},
},
})
gen.Close()
return it
}
func TestAgentTool_InvokableRun(t *testing.T) {
// Create a context
ctx := context.Background()
// Test cases
tests := []struct {
name string
agentResponses []*AgentEvent
request string
expectedOutput string
expectError bool
}{
{
name: "successful model response",
agentResponses: []*AgentEvent{
{
AgentName: "TestAgent",
Output: &AgentOutput{
MessageOutput: &MessageVariant{
IsStreaming: false,
Message: schema.AssistantMessage("Test response", nil),
Role: schema.Assistant,
},
},
},
},
request: `{"request":"Test request"}`,
expectedOutput: "Test response",
expectError: false,
},
{
name: "successful tool call response",
agentResponses: []*AgentEvent{
{
AgentName: "TestAgent",
Output: &AgentOutput{
MessageOutput: &MessageVariant{
IsStreaming: false,
Message: schema.ToolMessage("Tool response", "test-id"),
Role: schema.Tool,
},
},
},
},
request: `{"request":"Test tool request"}`,
expectedOutput: "Tool response",
expectError: false,
},
{
name: "invalid request JSON",
agentResponses: nil,
request: `invalid json`,
expectedOutput: "",
expectError: true,
},
{
name: "no events returned",
agentResponses: []*AgentEvent{},
request: `{"request":"Test request"}`,
expectedOutput: "",
expectError: true,
},
{
name: "error in event",
agentResponses: []*AgentEvent{
{
AgentName: "TestAgent",
Err: assert.AnError,
},
},
request: `{"request":"Test request"}`,
expectedOutput: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock agent with the test responses
mockAgent_ := newMockAgentForTool("TestAgent", "Test agent description", tt.agentResponses)
// Create an agentTool with the mock agent
agentTool_ := NewAgentTool(ctx, mockAgent_)
// Call InvokableRun
output, err := agentTool_.(tool.InvokableTool).InvokableRun(ctx, tt.request)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedOutput, output)
}
})
}
}
func TestGetReactHistory(t *testing.T) {
g := compose.NewGraph[string, []Message](compose.WithGenLocalState(func(ctx context.Context) (state *State) {
return &State{
Messages: []Message{
schema.UserMessage("user query"),
schema.AssistantMessage("", []schema.ToolCall{{ID: "tool call id 1", Function: schema.FunctionCall{Name: "tool1", Arguments: "arguments1"}}}),
schema.ToolMessage("tool result 1", "tool call id 1", schema.WithToolName("tool1")),
schema.AssistantMessage("", []schema.ToolCall{{ID: "tool call id 2", Function: schema.FunctionCall{Name: "tool2", Arguments: "arguments2"}}}),
},
}
}))
assert.NoError(t, g.AddLambdaNode("1", compose.InvokableLambda(func(ctx context.Context, input string) (output []Message, err error) {
return getReactChatHistory(ctx, "DestAgentName")
})))
assert.NoError(t, g.AddEdge(compose.START, "1"))
assert.NoError(t, g.AddEdge("1", compose.END))
ctx := context.Background()
ctx, _ = initRunCtx(ctx, "MyAgent", nil)
runner, err := g.Compile(ctx)
assert.NoError(t, err)
result, err := runner.Invoke(ctx, "")
assert.NoError(t, err)
assert.Equal(t, []Message{
schema.UserMessage("user query"),
schema.UserMessage("For context: [MyAgent] called tool: `tool1` with arguments: arguments1."),
schema.UserMessage("For context: [MyAgent] `tool1` tool returned result: tool result 1."),
schema.UserMessage("For context: [MyAgent] called tool: `transfer_to_agent` with arguments: DestAgentName."),
schema.UserMessage("For context: [MyAgent] `transfer_to_agent` tool returned result: successfully transferred to agent [DestAgentName]."),
}, result)
}
// mockAgentWithInputCapture implements the Agent interface for testing and captures the input it receives
type mockAgentWithInputCapture struct {
name string
description string
capturedInput []Message
responses []*AgentEvent
}
func (a *mockAgentWithInputCapture) Name(_ context.Context) string {
return a.name
}
func (a *mockAgentWithInputCapture) Description(_ context.Context) string {
return a.description
}
func (a *mockAgentWithInputCapture) Run(_ context.Context, input *AgentInput, _ ...AgentRunOption) *AsyncIterator[*AgentEvent] {
a.capturedInput = input.Messages
iterator, generator := NewAsyncIteratorPair[*AgentEvent]()
go func() {
defer generator.Close()
for _, event := range a.responses {
generator.Send(event)
// If the event has an Exit action, stop sending events
if event.Action != nil && event.Action.Exit {
break
}
}
}()
return iterator
}
func newMockAgentWithInputCapture(name, description string, responses []*AgentEvent) *mockAgentWithInputCapture {
return &mockAgentWithInputCapture{
name: name,
description: description,
responses: responses,
}
}
func TestAgentToolWithOptions(t *testing.T) {
// Test Case 1: WithFullChatHistoryAsInput
t.Run("WithFullChatHistoryAsInput", func(t *testing.T) {
ctx := context.Background()
// 1. Set up a mock agent that will capture the input it receives
mockAgent := newMockAgentWithInputCapture("test-agent", "a test agent", []*AgentEvent{
{
AgentName: "test-agent",
Output: &AgentOutput{
MessageOutput: &MessageVariant{
IsStreaming: false,
Message: schema.AssistantMessage("done", nil),
Role: schema.Assistant,
},
},
},
})
// 2. Create an agentTool with the option
agentTool := NewAgentTool(ctx, mockAgent, WithFullChatHistoryAsInput())
// 3. Set up a context with a chat history using a graph
history := []Message{
schema.UserMessage("first user message"),
schema.AssistantMessage("first assistant response", nil),
}
g := compose.NewGraph[string, string](compose.WithGenLocalState(func(ctx context.Context) (state *State) {
return &State{
Messages: append(history, schema.AssistantMessage("tool call msg", nil)),
}
}))
assert.NoError(t, g.AddLambdaNode("1", compose.InvokableLambda(func(ctx context.Context, input string) (output string, err error) {
// Run the tool within the graph context that has the state
_, err = agentTool.(tool.InvokableTool).InvokableRun(ctx, `{"request":"some ignored input"}`)
return "done", err
})))
assert.NoError(t, g.AddEdge(compose.START, "1"))
assert.NoError(t, g.AddEdge("1", compose.END))
ctx, _ = initRunCtx(ctx, "react-agent", nil)
runner, err := g.Compile(ctx)
assert.NoError(t, err)
// 4. Run the graph which will execute the tool with the state
_, err = runner.Invoke(ctx, "")
assert.NoError(t, err)
// 5. Assert that the agent received the full history
// The agent should receive: history (minus last assistant message) + transfer messages
assert.Len(t, mockAgent.capturedInput, 4) // 2 from history + 2 transfer messages
assert.Equal(t, "first user message", mockAgent.capturedInput[0].Content)
assert.Equal(t, "For context: [react-agent] said: first assistant response.", mockAgent.capturedInput[1].Content)
assert.Equal(t, "For context: [react-agent] called tool: `transfer_to_agent` with arguments: test-agent.", mockAgent.capturedInput[2].Content)
assert.Equal(t, "For context: [react-agent] `transfer_to_agent` tool returned result: successfully transferred to agent [test-agent].", mockAgent.capturedInput[3].Content)
})
// Test Case 2: WithAgentInputSchema
t.Run("WithAgentInputSchema", func(t *testing.T) {
ctx := context.Background()
// 1. Define a custom schema
customSchema := schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"custom_arg": {
Desc: "a custom argument",
Required: true,
Type: schema.String,
},
})
// 2. Set up a mock agent to capture input
mockAgent := newMockAgentWithInputCapture("schema-agent", "agent with custom schema", []*AgentEvent{
{
AgentName: "schema-agent",
Output: &AgentOutput{
MessageOutput: &MessageVariant{
IsStreaming: false,
Message: schema.AssistantMessage("schema processed", nil),
Role: schema.Assistant,
},
},
},
})
// 3. Create agentTool with the custom schema option
agentTool := NewAgentTool(ctx, mockAgent, WithAgentInputSchema(customSchema))
// 4. Verify the Info() method returns the custom schema
info, err := agentTool.Info(ctx)
assert.NoError(t, err)
assert.Equal(t, customSchema, info.ParamsOneOf)
// 5. Run the tool with arguments matching the custom schema
_, err = agentTool.(tool.InvokableTool).InvokableRun(ctx, `{"custom_arg":"hello world"}`)
assert.NoError(t, err)
// 6. Assert that the agent received the correctly parsed argument
// With custom schema, the agent should receive the raw JSON as input
assert.Len(t, mockAgent.capturedInput, 1)
assert.Equal(t, `{"custom_arg":"hello world"}`, mockAgent.capturedInput[0].Content)
})
// Test Case 3: WithAgentInputSchema with complex schema
t.Run("WithAgentInputSchema_ComplexSchema", func(t *testing.T) {
ctx := context.Background()
// 1. Define a complex custom schema with multiple parameters
complexSchema := schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"name": {
Desc: "user name",
Required: true,
Type: schema.String,
},
"age": {
Desc: "user age",
Required: false,
Type: schema.Integer,
},
"active": {
Desc: "user status",
Required: false,
Type: schema.Boolean,
},
})
// 2. Set up a mock agent
mockAgent := newMockAgentWithInputCapture("complex-agent", "agent with complex schema", []*AgentEvent{
{
AgentName: "complex-agent",
Output: &AgentOutput{
MessageOutput: &MessageVariant{
IsStreaming: false,
Message: schema.AssistantMessage("complex processed", nil),
Role: schema.Assistant,
},
},
},
})
// 3. Create agentTool with the complex schema option
agentTool := NewAgentTool(ctx, mockAgent, WithAgentInputSchema(complexSchema))
// 4. Verify the Info() method returns the complex schema
info, err := agentTool.Info(ctx)
assert.NoError(t, err)
assert.Equal(t, complexSchema, info.ParamsOneOf)
// 5. Run the tool with complex arguments
_, err = agentTool.(tool.InvokableTool).InvokableRun(ctx, `{"name":"John","age":30,"active":true}`)
assert.NoError(t, err)
// 6. Assert that the agent received the complex JSON
assert.Len(t, mockAgent.capturedInput, 1)
assert.Equal(t, `{"name":"John","age":30,"active":true}`, mockAgent.capturedInput[0].Content)
})
// Test Case 4: Both options together
t.Run("BothOptionsTogether", func(t *testing.T) {
ctx := context.Background()
// 1. Define a custom schema
customSchema := schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"query": {
Desc: "search query",
Required: true,
Type: schema.String,
},
})
// 2. Set up a mock agent
mockAgent := newMockAgentWithInputCapture("combined-agent", "agent with both options", []*AgentEvent{
{
AgentName: "combined-agent",
Output: &AgentOutput{
MessageOutput: &MessageVariant{
IsStreaming: false,
Message: schema.AssistantMessage("combined processed", nil),
Role: schema.Assistant,
},
},
},
})
// 3. Create agentTool with both options
agentTool := NewAgentTool(ctx, mockAgent, WithAgentInputSchema(customSchema), WithFullChatHistoryAsInput())
// 4. Set up a context with chat history using a graph
history := []Message{
schema.UserMessage("previous conversation"),
schema.AssistantMessage("previous response", nil),
}
g := compose.NewGraph[string, string](compose.WithGenLocalState(func(ctx context.Context) (state *State) {
return &State{
Messages: append(history, schema.AssistantMessage("tool call", nil)),
}
}))
assert.NoError(t, g.AddLambdaNode("1", compose.InvokableLambda(func(ctx context.Context, input string) (output string, err error) {
// Run the tool within the graph context that has the state
_, err = agentTool.(tool.InvokableTool).InvokableRun(ctx, `{"query":"current query"}`)
return "done", err
})))
assert.NoError(t, g.AddEdge(compose.START, "1"))
assert.NoError(t, g.AddEdge("1", compose.END))
ctx, _ = initRunCtx(ctx, "react-agent", nil)
runner, err := g.Compile(ctx)
assert.NoError(t, err)
// 5. Run the graph which will execute the tool with the state
_, err = runner.Invoke(ctx, "")
assert.NoError(t, err)
// 6. Verify both options work together
info, err := agentTool.Info(ctx)
assert.NoError(t, err)
assert.Equal(t, customSchema, info.ParamsOneOf)
// The agent should receive full history + the custom query
assert.Len(t, mockAgent.capturedInput, 4) // 2 history + 2 transfer messages
assert.Equal(t, "previous conversation", mockAgent.capturedInput[0].Content)
assert.Equal(t, "For context: [react-agent] said: previous response.", mockAgent.capturedInput[1].Content)
assert.Equal(t, "For context: [react-agent] called tool: `transfer_to_agent` with arguments: combined-agent.", mockAgent.capturedInput[2].Content)
assert.Equal(t, "For context: [react-agent] `transfer_to_agent` tool returned result: successfully transferred to agent [combined-agent].", mockAgent.capturedInput[3].Content)
})
}
type fakeTCM struct{}
func (f *fakeTCM) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
o := model.GetCommonOptions(&model.Options{}, opts...)
tc := schema.ToolCall{ID: "id-1", Type: "function"}
if len(o.Tools) > 0 {
tc.Function.Name = o.Tools[0].Name
}
tc.Function.Arguments = `{"request":"hello"}`
return schema.AssistantMessage("", []schema.ToolCall{tc}), nil
}
func (f *fakeTCM) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
msg, _ := f.Generate(ctx, input, opts...)
return schema.StreamReaderFromArray([]*schema.Message{msg}), nil
}
func (f *fakeTCM) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return f, nil
}
type emitOnceModel struct{}
func (e *emitOnceModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) {
return schema.AssistantMessage("inner2", nil), nil
}
func (e *emitOnceModel) Stream(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
m, _ := e.Generate(ctx, input)
return schema.StreamReaderFromArray([]*schema.Message{m}), nil
}
func (e *emitOnceModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return e, nil
}
type emitEventsAgent struct{ events []*AgentEvent }
func (e *emitEventsAgent) Name(context.Context) string { return "emit" }
func (e *emitEventsAgent) Description(context.Context) string { return "test" }
func (e *emitEventsAgent) Run(context.Context, *AgentInput, ...AgentRunOption) *AsyncIterator[*AgentEvent] {
it, gen := NewAsyncIteratorPair[*AgentEvent]()
go func() {
for _, ev := range e.events {
gen.Send(ev)
}
gen.Close()
}()
return it
}
// spyAgent captures runSession from ctx in a single nested run
type spyAgent struct {
a Agent
mu sync.Mutex
captured *runSession
}
func (s *spyAgent) Name(ctx context.Context) string { return s.a.Name(ctx) }
func (s *spyAgent) Description(ctx context.Context) string { return s.a.Description(ctx) }
func (s *spyAgent) Run(ctx context.Context, input *AgentInput, options ...AgentRunOption) *AsyncIterator[*AgentEvent] {
if rc := getRunCtx(ctx); rc != nil {
s.mu.Lock()
s.captured = rc.Session
s.mu.Unlock()
}
return s.a.Run(ctx, input, options...)
}
func (s *spyAgent) getCaptured() *runSession {
s.mu.Lock()
defer s.mu.Unlock()
return s.captured
}
func TestNestedAgentTool_RunPath(t *testing.T) {
ctx := context.Background()
inner2, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "inner2",
Description: "leaf",
Model: &emitOnceModel{},
ToolsConfig: ToolsConfig{EmitInternalEvents: true},
})
inner2Spy := &spyAgent{a: inner2}
inner2Tool := NewAgentTool(ctx, inner2Spy)
inner, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "inner",
Description: "mid",
Model: &fakeTCM{},
ToolsConfig: ToolsConfig{EmitInternalEvents: true, ToolsNodeConfig: compose.ToolsNodeConfig{Tools: []tool.BaseTool{inner2Tool}}},
})
innerSpy := &spyAgent{a: inner}
innerTool := NewAgentTool(ctx, innerSpy)
outer, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "outer",
Description: "top",
Model: &fakeTCM{},
ToolsConfig: ToolsConfig{EmitInternalEvents: true, ToolsNodeConfig: compose.ToolsNodeConfig{Tools: []tool.BaseTool{innerTool}}},
})
input := &AgentInput{Messages: []Message{schema.UserMessage("q")}}
ctx, outerRunCtx := initRunCtx(ctx, "outer", input)
r := NewRunner(ctx, RunnerConfig{Agent: outer, EnableStreaming: false, CheckPointStore: newBridgeStore()})
it := r.Run(ctx, []Message{schema.UserMessage("q")})
var target *AgentEvent
for {
ev, ok := it.Next()
if !ok {
break
}
if ev.Output != nil && ev.Output.MessageOutput != nil && !ev.Output.MessageOutput.IsStreaming {
if ev.Output.MessageOutput.Message != nil && ev.Output.MessageOutput.Message.Content == "inner2" {
target = ev
break
}
}
}
if target == nil {
t.Fatalf("no inner2 event found in ephemerals")
}
got := make([]string, len(target.RunPath))
for i := range target.RunPath {
got[i] = target.RunPath[i].agentName
}
want := []string{"outer", "inner", "inner2"}
if len(got) != len(want) {
t.Fatalf("unexpected runPath len: got %d want %d: %+v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("runPath mismatch at %d: got %s want %s; full: %+v", i, got[i], want[i], got)
}
}
for _, w := range outerRunCtx.Session.getEvents() {
if w.AgentName != "outer" {
t.Fatalf("outer session contains non-outer event: %s", w.AgentName)
}
}
if innerSpy.getCaptured() == nil {
t.Fatalf("inner spy did not capture session")
}
for _, w := range innerSpy.getCaptured().getEvents() {
if w.AgentName != "inner" {
t.Fatalf("inner session contains non-inner event: %s", w.AgentName)
}
}
if inner2Spy.getCaptured() == nil {
t.Fatalf("inner2 spy did not capture session")
}
for _, w := range inner2Spy.getCaptured().getEvents() {
if w.AgentName != "inner2" {
t.Fatalf("inner2 session contains non-inner2 event: %s", w.AgentName)
}
}
}
func TestNestedAgentTool_NoInternalEventsWhenDisabled(t *testing.T) {
ctx := context.Background()
inner2, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "inner2",
Description: "leaf",
Model: &emitOnceModel{},
ToolsConfig: ToolsConfig{EmitInternalEvents: false},
})
inner2Tool := NewAgentTool(ctx, inner2)
inner, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "inner",
Description: "mid",
Model: &fakeTCM{},
ToolsConfig: ToolsConfig{EmitInternalEvents: false, ToolsNodeConfig: compose.ToolsNodeConfig{Tools: []tool.BaseTool{inner2Tool}}},
})
innerTool := NewAgentTool(ctx, inner)
outer, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "outer",
Description: "top",
Model: &fakeTCM{},
ToolsConfig: ToolsConfig{EmitInternalEvents: false, ToolsNodeConfig: compose.ToolsNodeConfig{Tools: []tool.BaseTool{innerTool}}},
})
r := NewRunner(ctx, RunnerConfig{Agent: outer, EnableStreaming: false, CheckPointStore: newBridgeStore()})
it := r.Run(ctx, []Message{schema.UserMessage("q")})
for {
ev, ok := it.Next()
if !ok {
break
}
if ev.AgentName == "inner2" {
t.Fatalf("inner2 internal event should not be emitted when disabled")
}
}
}
func TestNestedAgentTool_InnerToolResultNotEmittedToOuter(t *testing.T) {
ctx := context.Background()
innerTool := &simpleTool{name: "inner_tool", result: "inner_tool_result"}
inner, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "inner",
Description: "inner agent with tool",
Model: &fakeTCM{},
ToolsConfig: ToolsConfig{ToolsNodeConfig: compose.ToolsNodeConfig{Tools: []tool.BaseTool{innerTool}}},
})
innerAgentTool := NewAgentTool(ctx, inner)
outer, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "outer",
Description: "outer agent",
Model: &fakeTCM{},
ToolsConfig: ToolsConfig{ToolsNodeConfig: compose.ToolsNodeConfig{Tools: []tool.BaseTool{innerAgentTool}}},
})
r := NewRunner(ctx, RunnerConfig{Agent: outer, EnableStreaming: false, CheckPointStore: newBridgeStore()})
it := r.Run(ctx, []Message{schema.UserMessage("q")})
var allEvents []*AgentEvent
for {
ev, ok := it.Next()
if !ok {
break
}
allEvents = append(allEvents, ev)
}
for _, ev := range allEvents {
if ev.Output != nil && ev.Output.MessageOutput != nil &&
ev.Output.MessageOutput.Message != nil &&
ev.Output.MessageOutput.Message.Role == schema.Tool &&
ev.AgentName == "outer" &&
ev.Output.MessageOutput.Message.Content == "inner_tool_result" {
t.Fatalf("inner agent's tool result (inner_tool_result) should not be emitted as outer agent's event, but got event with AgentName=%s, Content=%s",
ev.AgentName, ev.Output.MessageOutput.Message.Content)
}
}
}
type simpleTool struct {
name string
result string
}
func (s *simpleTool) Info(context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{Name: s.name, Desc: "simple tool"}, nil
}
func (s *simpleTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
return s.result, nil
}
func TestAgentTool_InterruptWithoutCheckpoint(t *testing.T) {
ctx := context.Background()
ctx, _ = initRunCtx(ctx, "TestAgent", &AgentInput{Messages: []Message{}})
interrupted := &AgentEvent{AgentName: "TestAgent"}
interrupted.Action = StatefulInterrupt(ctx, "info", "state").Action
err := compositeInterruptFromLast(ctx, &bridgeStore{}, interrupted)
if err == nil {
t.Fatalf("expected error for interrupt without checkpoint")
}
if !strings.Contains(err.Error(), "interrupt occurred but checkpoint data is missing") {
t.Fatalf("unexpected error: %v", err)
}
}
func compositeInterruptFromLast(ctx context.Context, ms *bridgeStore, lastEvent *AgentEvent) error {
if lastEvent == nil || lastEvent.Action == nil || lastEvent.Action.Interrupted == nil {
return nil
}
data, existed, err := ms.Get(ctx, bridgeCheckpointID)
if err != nil {
return fmt.Errorf("failed to get interrupt info: %w", err)
}
if !existed {
return fmt.Errorf("interrupt occurred but checkpoint data is missing")
}
return tool.CompositeInterrupt(ctx, "agent tool interrupt", data, lastEvent.Action.internalInterrupted)
}
func TestAgentTool_InvokableRun_FinalOnly(t *testing.T) {
ctx := context.Background()
inner2, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "inner2",
Description: "leaf",
Model: &emitOnceModel{},
ToolsConfig: ToolsConfig{EmitInternalEvents: true},
})
invTool := NewAgentTool(ctx, inner2)
out, err := invTool.(tool.InvokableTool).InvokableRun(ctx, `{"request":"q"}`)
if err != nil {
t.Fatalf("invokable run error: %v", err)
}
if out != "inner2" {
t.Fatalf("unexpected output: %s", out)
}
}
type streamingAgent struct{}
func (s *streamingAgent) Name(context.Context) string { return "stream" }
func (s *streamingAgent) Description(context.Context) string { return "test" }
func (s *streamingAgent) Run(context.Context, *AgentInput, ...AgentRunOption) *AsyncIterator[*AgentEvent] {
it, gen := NewAsyncIteratorPair[*AgentEvent]()
go func() {
mv := &MessageVariant{IsStreaming: true, MessageStream: schema.StreamReaderFromArray([]Message{schema.AssistantMessage("1", nil), schema.AssistantMessage("2", nil)})}
gen.Send(&AgentEvent{AgentName: "stream", Output: &AgentOutput{MessageOutput: mv}})
mv = &MessageVariant{IsStreaming: true, MessageStream: schema.StreamReaderFromArray([]Message{schema.AssistantMessage("a", nil), schema.AssistantMessage("b", nil)})}
gen.Send(&AgentEvent{AgentName: "stream", Output: &AgentOutput{MessageOutput: mv}})
gen.Close()
}()
return it
}
func TestAgentTool_InvokableRun_StreamingVariant(t *testing.T) {
ctx := context.Background()
agent := &streamingAgent{}
it := NewAgentTool(ctx, agent)
out, err := it.(tool.InvokableTool).InvokableRun(ctx, `{"request":"q"}`)
if err != nil {
t.Fatalf("invokable run error: %v", err)
}
if out != "ab" {
t.Fatalf("unexpected output: %s", out)
}
}
func TestSequentialWorkflow_WithChatModelAgentTool_NestedRunPathAndSessions(t *testing.T) {
ctx := context.Background()
inner2, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "inner2",
Description: "leaf",
Model: &emitOnceModel{},
ToolsConfig: ToolsConfig{EmitInternalEvents: true},
})
inner2Spy := &spyAgent{a: inner2}
inner2ToolSpy := NewAgentTool(ctx, inner2Spy)
innerWithSpy, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "inner",
Description: "mid",
Model: &fakeTCM{},
ToolsConfig: ToolsConfig{EmitInternalEvents: true, ToolsNodeConfig: compose.ToolsNodeConfig{Tools: []tool.BaseTool{inner2ToolSpy}}},
})
innerSpy := &spyAgent{a: innerWithSpy}
outer, err := NewSequentialAgent(ctx, &SequentialAgentConfig{
Name: "outer-seq",
Description: "workflow",
SubAgents: []Agent{innerSpy},
})
if err != nil {
t.Fatalf("new sequential agent err: %v", err)
}
input := &AgentInput{Messages: []Message{schema.UserMessage("q")}}
ctx, outerRunCtx := initRunCtx(ctx, "outer-seq", input)
r := NewRunner(ctx, RunnerConfig{Agent: outer, EnableStreaming: false, CheckPointStore: newBridgeStore()})
it := r.Run(ctx, []Message{schema.UserMessage("q")})
var target *AgentEvent
for {
ev, ok := it.Next()
if !ok {
break
}
if ev.Output != nil && ev.Output.MessageOutput != nil && !ev.Output.MessageOutput.IsStreaming {
if ev.Output.MessageOutput.Message != nil && ev.Output.MessageOutput.Message.Content == "inner2" {
target = ev
break
}
}
}
if target == nil {
t.Fatalf("no inner2 event found")
}
got := make([]string, len(target.RunPath))
for i := range target.RunPath {
got[i] = target.RunPath[i].agentName
}
want := []string{"outer-seq", "inner", "inner2"}
if len(got) != len(want) {
t.Fatalf("unexpected runPath len: got %d want %d: %+v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("runPath mismatch at %d: got %s want %s; full: %+v", i, got[i], want[i], got)
}
}
for _, w := range outerRunCtx.Session.getEvents() {
if w.AgentName != "outer-seq" {
t.Fatalf("outer session contains non-outer event: %s", w.AgentName)
}
}
if innerSpy.getCaptured() == nil {
t.Fatalf("inner spy did not capture session")
}
for _, w := range innerSpy.getCaptured().getEvents() {
if w.AgentName != "inner" {
t.Fatalf("inner session contains non-inner event: %s", w.AgentName)
}
}
if inner2Spy.getCaptured() == nil {
t.Fatalf("inner2 spy did not capture session")
}
for _, w := range inner2Spy.getCaptured().getEvents() {
if w.AgentName != "inner2" {
t.Fatalf("inner2 session contains non-inner2 event: %s", w.AgentName)
}
}
}
func TestRunPathGating_IgnoresInnerExitAndAllowsOutput(t *testing.T) {
ctx := context.Background()
innerExit := &AgentEvent{Action: &AgentAction{Exit: true}, RunPath: []RunStep{{agentName: "inner"}}}
finalOut := EventFromMessage(schema.AssistantMessage("ok", nil), nil, schema.Assistant, "")
sub := &emitEventsAgent{events: []*AgentEvent{innerExit, finalOut}}
fa := toFlowAgent(ctx, sub)
it := fa.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("q")}})
var sawFinal bool
for {
ev, ok := it.Next()
if !ok {
break
}
if ev.Output != nil && ev.Output.MessageOutput != nil && !ev.Output.MessageOutput.IsStreaming {
if ev.Output.MessageOutput.Message != nil && ev.Output.MessageOutput.Message.Content == "ok" {
sawFinal = true
}
}
}
if !sawFinal {
t.Fatalf("final output not observed; parent may have exited on inner Exit action")
}
}
func TestRunPathGating_IgnoresInnerTransfer(t *testing.T) {
ctx := context.Background()
innerTransfer := &AgentEvent{Action: NewTransferToAgentAction("ghost"), RunPath: []RunStep{{agentName: "inner"}}}
finalOut := EventFromMessage(schema.AssistantMessage("done", nil), nil, schema.Assistant, "")
sub := &emitEventsAgent{events: []*AgentEvent{innerTransfer, finalOut}}
fa := toFlowAgent(ctx, sub)
it := fa.Run(ctx, &AgentInput{Messages: []Message{schema.UserMessage("q")}})
var outputs int
for {
ev, ok := it.Next()
if !ok {
break
}
if ev.Output != nil && ev.Output.MessageOutput != nil && !ev.Output.MessageOutput.IsStreaming {
if ev.Output.MessageOutput.Message != nil {
outputs++
}
}
}
if outputs == 0 {
t.Fatalf("no outputs observed; parent may have transferred on inner transfer action")
}
}
type streamAgent struct{}
func (s *streamAgent) Name(context.Context) string { return "s" }
func (s *streamAgent) Description(context.Context) string { return "s" }
func (s *streamAgent) Run(context.Context, *AgentInput, ...AgentRunOption) *AsyncIterator[*AgentEvent] {
it, gen := NewAsyncIteratorPair[*AgentEvent]()
go func() {
frames := []*schema.Message{
schema.AssistantMessage("hello ", nil),
schema.AssistantMessage("world", nil),
}
stream := schema.StreamReaderFromArray(frames)
gen.Send(EventFromMessage(nil, stream, schema.Assistant, ""))
gen.Close()
}()
return it
}
func TestInvokableAgentTool_InfoAndRun(t *testing.T) {
ctx := context.Background()
at := NewAgentTool(ctx, &streamAgent{})
info, err := at.Info(ctx)
assert.NoError(t, err)
assert.Equal(t, "s", info.Name)
assert.Equal(t, "s", info.Desc)
js, err := info.ParamsOneOf.ToJSONSchema()
assert.NoError(t, err)
found := false
for _, r := range js.Required {
if r == "request" {
found = true
break
}
}
assert.True(t, found)
prop, ok := js.Properties.Get("request")
assert.True(t, ok)
assert.Equal(t, string(schema.String), prop.Type)
custom := schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"x": {Desc: "arg", Required: true, Type: schema.String},
})
at2 := NewAgentTool(ctx, &streamAgent{}, WithAgentInputSchema(custom))
info2, err := at2.Info(ctx)
assert.NoError(t, err)
assert.Equal(t, custom, info2.ParamsOneOf)
out, err := at.(tool.InvokableTool).InvokableRun(ctx, `{"request":"x"}`)
assert.NoError(t, err)
assert.Equal(t, "hello world", out)
}
type emptyAgent struct{}
func (e *emptyAgent) Name(context.Context) string { return "empty" }
func (e *emptyAgent) Description(context.Context) string { return "empty" }
func (e *emptyAgent) Run(context.Context, *AgentInput, ...AgentRunOption) *AsyncIterator[*AgentEvent] {
it, gen := NewAsyncIteratorPair[*AgentEvent]()
go func() { gen.Close() }()
return it
}
type noOutputAgent struct{}
func (n *noOutputAgent) Name(context.Context) string { return "no" }
func (n *noOutputAgent) Description(context.Context) string { return "no" }
func (n *noOutputAgent) Run(context.Context, *AgentInput, ...AgentRunOption) *AsyncIterator[*AgentEvent] {
it, gen := NewAsyncIteratorPair[*AgentEvent]()
go func() { gen.Send(&AgentEvent{}); gen.Close() }()
return it
}
func TestInvokableAgentTool_ErrorCases(t *testing.T) {
ctx := context.Background()
atEmpty := NewAgentTool(ctx, &emptyAgent{})
out, err := atEmpty.(tool.InvokableTool).InvokableRun(ctx, `{"request":"x"}`)
assert.Equal(t, "", out)
assert.Error(t, err)
atNo := NewAgentTool(ctx, &noOutputAgent{})
out2, err := atNo.(tool.InvokableTool).InvokableRun(ctx, `{"request":"x"}`)
assert.NoError(t, err)
assert.Equal(t, "", out2)
}
func TestCrossTypeAgentToolGracefulError(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 result"), nil
},
}
innerAgent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "AgenticInner",
Description: "An agentic agent used as a tool",
Model: innerModel,
})
require.NoError(t, err)
agenticAgentTool := NewTypedAgentTool(ctx, TypedAgent[*schema.AgenticMessage](innerAgent))
var outerCallCount int32
outerModel := &mockChatModelForAttack{
generateFn: func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
count := atomic.AddInt32(&outerCallCount, 1)
if count == 1 {
return &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{ID: "c1", Function: schema.FunctionCall{Name: "AgenticInner", Arguments: `{"request":"test"}`}},
},
}, nil
}
return schema.AssistantMessage("done", nil), nil
},
}
outerAgent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "OuterMessageAgent",
Description: "A Message agent using an AgenticMessage sub-agent tool",
Model: outerModel,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{agenticAgentTool},
},
},
})
require.NoError(t, err)
runner := NewRunner(ctx, RunnerConfig{Agent: outerAgent, EnableStreaming: true})
iter := runner.Query(ctx, "test cross-type")
var capturedErr error
for {
event, ok := iter.Next()
if !ok {
break
}
if event.Err != nil {
capturedErr = event.Err
t.Logf("Cross-type error message: %v", event.Err)
}
}
if capturedErr == nil {
t.Log("DESIGN CONCERN: Cross-type agent tool (AgenticMessage sub-agent in Message agent) " +
"only errors at event forwarding time when streaming is enabled. " +
"The error check happens in the gen.Send path, which is only exercised " +
"when the outer agent actually calls the tool AND streaming is enabled. " +
"Without streaming, the tool result is returned as a string, so no type mismatch occurs.")
} else {
assert.Contains(t, capturedErr.Error(), "cross-message-type",
"Error should mention cross-message-type incompatibility")
}
}