377 lines
15 KiB
Go
377 lines
15 KiB
Go
package einoexperiment
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
applicationruntime "agent-desk/internal/ai/application/runtime"
|
|
"agent-desk/internal/ai/mcps"
|
|
"agent-desk/internal/ai/runtime/graphs"
|
|
aitooling "agent-desk/internal/ai/tooling"
|
|
"agent-desk/internal/models"
|
|
|
|
"github.com/cloudwego/eino/components/model"
|
|
"github.com/cloudwego/eino/components/tool"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
type scriptedToolCallingModel struct {
|
|
responses []*schema.Message
|
|
calls int
|
|
err error
|
|
block bool
|
|
lastInput []*schema.Message
|
|
}
|
|
|
|
type fakeMCPToolExecutor struct {
|
|
toolCode string
|
|
arguments map[string]any
|
|
policy aitooling.Policy
|
|
result *mcps.ToolCallResult
|
|
err error
|
|
}
|
|
|
|
type concurrentToolCallingModel struct {
|
|
calls atomic.Int32
|
|
}
|
|
|
|
var _ model.ToolCallingChatModel = (*concurrentToolCallingModel)(nil)
|
|
|
|
func (m *concurrentToolCallingModel) Generate(ctx context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
m.calls.Add(1)
|
|
return schema.AssistantMessage("并发调用完成。", nil), nil
|
|
}
|
|
|
|
func (m *concurrentToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
|
message, err := m.Generate(ctx, input, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
|
|
}
|
|
|
|
func (m *concurrentToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
|
return m, nil
|
|
}
|
|
|
|
func (e *fakeMCPToolExecutor) Execute(_ context.Context, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) {
|
|
e.toolCode = toolCode
|
|
e.arguments = arguments
|
|
e.policy = policy
|
|
return aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelRead}, e.result, e.err
|
|
}
|
|
|
|
var _ model.ToolCallingChatModel = (*scriptedToolCallingModel)(nil)
|
|
|
|
func (m *scriptedToolCallingModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
|
m.lastInput = append([]*schema.Message(nil), input...)
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if m.block {
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
}
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
if m.calls >= len(m.responses) {
|
|
return nil, errors.New("unexpected model call")
|
|
}
|
|
result := m.responses[m.calls]
|
|
m.calls++
|
|
return result, nil
|
|
}
|
|
|
|
func (m *scriptedToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
|
message, err := m.Generate(ctx, input, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
|
|
}
|
|
|
|
func (m *scriptedToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
|
return m, nil
|
|
}
|
|
|
|
func TestRunExecutesGuardedToolThenReturnsFinalAnswer(t *testing.T) {
|
|
called := false
|
|
guardedTool := &GuardedTool{
|
|
InfoDefinition: &schema.ToolInfo{Name: "customer_lookup", Desc: "Read customer data"},
|
|
Definition: aitooling.Definition{Code: "builtin/customer_lookup", RiskLevel: aitooling.RiskLevelRead},
|
|
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
|
|
Handler: func(_ context.Context, arguments map[string]any) (string, error) {
|
|
called = arguments["customerId"] == "42"
|
|
return "customer: Ada", nil
|
|
},
|
|
}
|
|
model := &scriptedToolCallingModel{responses: []*schema.Message{
|
|
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "customer_lookup", Arguments: `{"customerId":"42"}`}}}),
|
|
schema.AssistantMessage("已找到客户资料。", nil),
|
|
}}
|
|
|
|
result, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 4}, []*schema.Message{schema.UserMessage("查询客户")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if !called || result == nil || result.Content != "已找到客户资料。" || model.calls != 2 {
|
|
t.Fatalf("unexpected ReAct result: called=%t result=%#v modelCalls=%d", called, result, model.calls)
|
|
}
|
|
}
|
|
|
|
func TestRunInjectsProvidedConversationContext(t *testing.T) {
|
|
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("已理解上下文。", nil)}}
|
|
input := []*schema.Message{
|
|
schema.SystemMessage("你是客服助手,优先引用知识库。"),
|
|
schema.UserMessage("我的订单状态如何?"),
|
|
}
|
|
if _, err := Run(context.Background(), ReActConfig{Model: model}, input); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if len(model.lastInput) != len(input) || model.lastInput[0].Content != input[0].Content || model.lastInput[1].Content != input[1].Content {
|
|
t.Fatalf("conversation context was not passed to model: %#v", model.lastInput)
|
|
}
|
|
}
|
|
|
|
func TestNewOpenAICompatibleModelValidatesExistingAIConfig(t *testing.T) {
|
|
if _, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{}); err == nil {
|
|
t.Fatal("expected incomplete AI config error")
|
|
}
|
|
configured, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{
|
|
BaseURL: "https://api.example.test/v1", APIKey: "test-key", ModelName: "test-model", TimeoutMS: 1200, MaxOutputTokens: 256,
|
|
})
|
|
if err != nil || configured == nil {
|
|
t.Fatalf("expected OpenAI-compatible model adapter, model=%#v err=%v", configured, err)
|
|
}
|
|
}
|
|
|
|
func TestGuardedToolRejectsDisallowedPolicyBeforeHandler(t *testing.T) {
|
|
called := false
|
|
guardedTool := &GuardedTool{
|
|
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
|
|
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
|
|
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
|
|
Handler: func(context.Context, map[string]any) (string, error) {
|
|
called = true
|
|
return "unexpected", nil
|
|
},
|
|
}
|
|
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); err == nil {
|
|
t.Fatal("expected policy rejection")
|
|
}
|
|
if called {
|
|
t.Fatal("handler must not run after policy rejection")
|
|
}
|
|
}
|
|
|
|
func TestRunPropagatesCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
|
|
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected cancellation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunPropagatesDeadlineDuringModelCall(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
|
defer cancel()
|
|
model := &scriptedToolCallingModel{block: true}
|
|
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("expected deadline propagation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunPropagatesModelFailure(t *testing.T) {
|
|
modelErr := errors.New("model unavailable")
|
|
model := &scriptedToolCallingModel{err: modelErr}
|
|
if _, err := Run(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, modelErr) {
|
|
t.Fatalf("expected model error propagation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunPropagatesToolFailure(t *testing.T) {
|
|
toolErr := errors.New("customer service unavailable")
|
|
guardedTool := &GuardedTool{
|
|
InfoDefinition: &schema.ToolInfo{Name: "failing_lookup", Desc: "Read customer data"},
|
|
Definition: aitooling.Definition{Code: "builtin/failing_lookup", RiskLevel: aitooling.RiskLevelRead},
|
|
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/failing_lookup"}},
|
|
Handler: func(context.Context, map[string]any) (string, error) {
|
|
return "", toolErr
|
|
},
|
|
}
|
|
model := &scriptedToolCallingModel{responses: []*schema.Message{
|
|
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "failing_lookup", Arguments: `{}`}}}),
|
|
}}
|
|
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, toolErr) {
|
|
t.Fatalf("expected tool error propagation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGuardedToolEnforcesTimeout(t *testing.T) {
|
|
guardedTool := &GuardedTool{
|
|
InfoDefinition: &schema.ToolInfo{Name: "slow_lookup", Desc: "Read customer data"},
|
|
Definition: aitooling.Definition{Code: "builtin/slow_lookup", RiskLevel: aitooling.RiskLevelRead, TimeoutMS: 20},
|
|
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/slow_lookup"}},
|
|
Handler: func(ctx context.Context, _ map[string]any) (string, error) {
|
|
<-ctx.Done()
|
|
return "", ctx.Err()
|
|
},
|
|
}
|
|
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("expected tool timeout, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGuardedToolEmitsTraceForPolicyFailure(t *testing.T) {
|
|
var trace ToolTrace
|
|
guardedTool := &GuardedTool{
|
|
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
|
|
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
|
|
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/other_lookup"}},
|
|
Handler: func(context.Context, map[string]any) (string, error) {
|
|
return "unexpected", nil
|
|
},
|
|
Trace: func(item ToolTrace) { trace = item },
|
|
}
|
|
if _, err := guardedTool.InvokableRun(context.Background(), `{"customerId":"42"}`); err == nil {
|
|
t.Fatal("expected policy rejection")
|
|
}
|
|
if trace.ToolCode != "builtin/restricted_lookup" || trace.Status != "failed" || trace.Err == nil || trace.Arguments["customerId"] != "42" || trace.Duration < 0 {
|
|
t.Fatalf("unexpected trace: %#v", trace)
|
|
}
|
|
}
|
|
|
|
func TestMCPToolHandlerUsesSharedExecutorAndReducesResult(t *testing.T) {
|
|
executor := &fakeMCPToolExecutor{result: &mcps.ToolCallResult{Content: []mcps.ToolResultContent{{Type: "text", Text: "customer: Ada"}}}}
|
|
policy := aitooling.Policy{AllowedToolCodes: []string{"crm/customer_lookup"}, Confirmed: true}
|
|
handler := NewMCPToolHandler(executor, "crm/customer_lookup", policy)
|
|
result, err := handler(context.Background(), map[string]any{"customerId": "42"})
|
|
if err != nil || result != "customer: Ada" {
|
|
t.Fatalf("unexpected MCP handler result=%q err=%v", result, err)
|
|
}
|
|
if executor.toolCode != "crm/customer_lookup" || executor.arguments["customerId"] != "42" || !executor.policy.Confirmed {
|
|
t.Fatalf("unexpected MCP execution: %#v", executor)
|
|
}
|
|
}
|
|
|
|
func TestRunStopsAtConfiguredMaxSteps(t *testing.T) {
|
|
guardedTool := &GuardedTool{
|
|
InfoDefinition: &schema.ToolInfo{Name: "loop_lookup", Desc: "Read loop data"},
|
|
Definition: aitooling.Definition{Code: "builtin/loop_lookup", RiskLevel: aitooling.RiskLevelRead},
|
|
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/loop_lookup"}},
|
|
Handler: func(context.Context, map[string]any) (string, error) {
|
|
return "keep going", nil
|
|
},
|
|
}
|
|
responses := make([]*schema.Message, 8)
|
|
for i := range responses {
|
|
responses[i] = schema.AssistantMessage("", []schema.ToolCall{{
|
|
ID: "loop-call", Type: "function", Function: schema.FunctionCall{Name: "loop_lookup", Arguments: `{}`},
|
|
}})
|
|
}
|
|
model := &scriptedToolCallingModel{responses: responses}
|
|
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 2}, []*schema.Message{schema.UserMessage("循环查询")}); err == nil {
|
|
t.Fatal("expected configured maximum step limit to stop the loop")
|
|
}
|
|
}
|
|
|
|
func TestStreamReturnsModelOutput(t *testing.T) {
|
|
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("流式回复", nil)}}
|
|
stream, err := Stream(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")})
|
|
if err != nil {
|
|
t.Fatalf("Stream: %v", err)
|
|
}
|
|
defer stream.Close()
|
|
result, err := schema.ConcatMessageStream(stream)
|
|
if err != nil {
|
|
t.Fatalf("ConcatMessageStream: %v", err)
|
|
}
|
|
if result.Content != "流式回复" {
|
|
t.Fatalf("unexpected stream result: %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestStreamPropagatesCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
|
|
if _, err := Stream(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected stream cancellation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSupportsConcurrentIndependentCalls(t *testing.T) {
|
|
model := &concurrentToolCallingModel{}
|
|
const workers = 16
|
|
errs := make(chan error, workers)
|
|
var group sync.WaitGroup
|
|
for range workers {
|
|
group.Add(1)
|
|
go func() {
|
|
defer group.Done()
|
|
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, []*schema.Message{schema.UserMessage("并发查询")})
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
if result == nil || result.Content != "并发调用完成。" {
|
|
errs <- errors.New("unexpected concurrent result")
|
|
}
|
|
}()
|
|
}
|
|
group.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Fatal(err)
|
|
}
|
|
if model.calls.Load() != workers {
|
|
t.Fatalf("model calls = %d, want %d", model.calls.Load(), workers)
|
|
}
|
|
}
|
|
|
|
func TestConfirmationBridgeUsesGenericInterruptAndResumeContracts(t *testing.T) {
|
|
input := applicationruntime.RunInput{
|
|
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 22},
|
|
}
|
|
result, err := BuildConfirmationResult(input, ConfirmationRequest{
|
|
InterruptID: "confirm_refund", ToolCode: "graph/create_ticket_with_confirmation", Prompt: "是否确认提交退款工单?",
|
|
Arguments: map[string]any{"title": "退款申请"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildConfirmationResult: %v", err)
|
|
}
|
|
if !result.Interrupted || result.Status != "interrupted" || result.CheckPointID == "" || len(result.Interrupts) != 1 || result.Interrupts[0].Type != confirmationInterruptType || result.Interrupts[0].ID != "confirm_refund" {
|
|
t.Fatalf("unexpected confirmation result: %#v", result)
|
|
}
|
|
decision, checkpoint, err := ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "确认"}})
|
|
if err != nil || decision != string(graphs.ConfirmationDecisionConfirm) || checkpoint.ToolCode != "graph/create_ticket_with_confirmation" || checkpoint.Arguments["title"] != "退款申请" {
|
|
t.Fatalf("unexpected resume bridge decision=%q checkpoint=%#v err=%v", decision, checkpoint, err)
|
|
}
|
|
decision, _, err = ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "取消"}})
|
|
if err != nil || decision != string(graphs.ConfirmationDecisionCancel) {
|
|
t.Fatalf("unexpected cancellation decision=%q err=%v", decision, err)
|
|
}
|
|
}
|
|
|
|
func BenchmarkRunWithInjectedModel(b *testing.B) {
|
|
model := &concurrentToolCallingModel{}
|
|
input := []*schema.Message{schema.SystemMessage("你是客服助手。"), schema.UserMessage("查询订单状态")}
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for range b.N {
|
|
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, input)
|
|
if err != nil || result == nil || result.Content == "" {
|
|
b.Fatalf("Run result=%#v err=%v", result, err)
|
|
}
|
|
}
|
|
}
|