// Package component — Agent unit tests (Phase 2 P0, plan §2.11.3 row 8). // // Tests inject a canned agentRunner to verify the component contract // without requiring a real model or eino react agent runtime: // // 1. NoToolsReAct: the runner returns a plain answer → component // surfaces content with empty tool_calls. // 2. ToolCallRound: the runner returns a message with ToolCalls → // component extracts them into the tool_calls output. // 3. ExhaustRoundsError: the runner returns an error → component // propagates it. // 4. MissingModelID: the component rejects before calling the runner. package component import ( "context" "database/sql" "errors" "fmt" "io" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/cloudwego/eino/components/model" einotool "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/flow/agent/react" "github.com/cloudwego/eino/schema" agenttool "ragflow/internal/agent/tool" ) // withAgentRunner replaces the package-level agentRunner for the duration // of t. func withAgentRunner(t *testing.T, fn func(context.Context, AgentParam) (*schema.Message, error)) { t.Helper() prev := agentRunner agentRunner = fn t.Cleanup(func() { agentRunner = prev }) } func TestAgent_NoToolsReAct(t *testing.T) { var calls int withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) { calls++ return &schema.Message{Role: schema.Assistant, Content: "the answer is 42"}, nil }) c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 3}) out, err := c.Invoke(context.Background(), map[string]any{ "user_prompt": "what is 6*7?", }) if err != nil { t.Fatalf("Invoke: %v", err) } if got, want := out["content"], "the answer is 42"; got != want { t.Errorf("content=%v, want %v", got, want) } toolCalls, ok := out["tool_calls"].([]map[string]any) if !ok { t.Fatalf("tool_calls missing or wrong type: %T", out["tool_calls"]) } if len(toolCalls) != 0 { t.Errorf("tool_calls=%d, want 0", len(toolCalls)) } if calls != 1 { t.Errorf("runner called %d times, want 1", calls) } } func TestAgent_ToolCallRound(t *testing.T) { var calls int withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) { calls++ return &schema.Message{ Role: schema.Assistant, Content: "final answer based on tool", ToolCalls: []schema.ToolCall{ { ID: "call_1", Type: "function", Function: schema.FunctionCall{ Name: "search", Arguments: `{"q": "ragflow"}`, }, }, }, }, nil }) c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 3}) out, err := c.Invoke(context.Background(), map[string]any{ "user_prompt": "find out about ragflow", }) if err != nil { t.Fatalf("Invoke: %v", err) } if got, want := out["content"], "final answer based on tool"; got != want { t.Errorf("content=%v, want %v", got, want) } toolCalls, ok := out["tool_calls"].([]map[string]any) if !ok { t.Fatalf("tool_calls missing or wrong type: %T", out["tool_calls"]) } if len(toolCalls) != 1 { t.Fatalf("tool_calls=%d, want 1", len(toolCalls)) } if toolCalls[0]["name"] != "search" { t.Errorf("tool name=%v, want search", toolCalls[0]["name"]) } if calls != 1 { t.Errorf("runner called %d times, want 1", calls) } } func TestAgent_ExhaustRoundsError(t *testing.T) { withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) { return nil, errors.New("agent: exhausted rounds without final answer") }) c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 2}) _, err := c.Invoke(context.Background(), map[string]any{ "user_prompt": "x", }) if err == nil { t.Fatal("expected error when loop exhausts without a final answer") } } func TestAgent_MissingModelID(t *testing.T) { c := NewAgentComponent(AgentParam{MaxRounds: 1}) _, err := c.Invoke(context.Background(), map[string]any{"user_prompt": "x"}) if err == nil { t.Fatal("expected ParamError for missing model_id") } var pe *ParamError if !errors.As(err, &pe) { t.Errorf("err type=%T, want *ParamError", err) } } func TestAgent_UnknownToolName(t *testing.T) { c := NewAgentComponent(AgentParam{ ModelID: "stub", MaxRounds: 1, Tools: []string{"does_not_exist"}, }) _, err := c.Invoke(context.Background(), map[string]any{ "user_prompt": "x", }) if err == nil { t.Fatal("expected error for unknown tool") } if !strings.Contains(err.Error(), `build tools: agent tool: unsupported tool "does_not_exist"`) { t.Fatalf("err = %q, want unsupported tool message", err.Error()) } } func TestAgent_AllRegisteredToolsConfigPassesToRunner(t *testing.T) { var captured AgentParam withAgentRunner(t, func(_ context.Context, p AgentParam) (*schema.Message, error) { captured = p return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil }) c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 1}) _, err := c.Invoke(context.Background(), map[string]any{ "user_prompt": "x", "tools": []any{ "akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo", "email", "github", "google", "google_scholar", "jin10", "pubmed", "qweather", "retrieval", "searxng", "tavily", "tushare", "wencai", "wikipedia", "yahoo_finance", "execute_sql", }, "tool_params": map[string]any{ "execute_sql": map[string]any{ "db_type": "mysql", "host": "127.0.0.1", "port": 3306, "database": "demo", "username": "u", "password": "p", "max_records": 10, }, }, }) if err != nil { t.Fatalf("Invoke: %v", err) } if len(captured.Tools) != 21 { t.Fatalf("captured.Tools len = %d, want 21", len(captured.Tools)) } if captured.ToolParams == nil || captured.ToolParams["execute_sql"] == nil { t.Fatalf("captured.ToolParams missing execute_sql: %#v", captured.ToolParams) } } type fakeToolCallingChatModel struct { tools []*schema.ToolInfo } func (m *fakeToolCallingChatModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) { return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil } func (m *fakeToolCallingChatModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) { sr, sw := schema.Pipe[*schema.Message](1) go func() { defer sw.Close() _ = sw.Send(&schema.Message{Role: schema.Assistant, Content: "ok"}, io.EOF) }() return sr, nil } func (m *fakeToolCallingChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) { cp := *m cp.tools = append([]*schema.ToolInfo(nil), tools...) return &cp, nil } func TestAgent_CanCreateReactAgentWithAllRegisteredTools(t *testing.T) { p := AgentParam{ Tools: []string{ "akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo", "email", "github", "google", "google_scholar", "jin10", "pubmed", "qweather", "retrieval", "searxng", "tavily", "tushare", "wencai", "wikipedia", "yahoo_finance", "execute_sql", }, ToolParams: map[string]map[string]any{ "execute_sql": { "db_type": "mysql", "host": "127.0.0.1", "port": 3306, "database": "demo", "username": "u", "password": "p", "max_records": 10, }, }, MaxRounds: 1, } tools, err := buildAgentTools(p) if err != nil { t.Fatalf("buildAgentTools: %v", err) } if len(tools) != len(p.Tools) { t.Fatalf("len(tools) = %d, want %d", len(tools), len(p.Tools)) } _, err = react.NewAgent(context.Background(), &react.AgentConfig{ ToolCallingModel: &fakeToolCallingChatModel{}, ToolsConfig: compose.ToolsNodeConfig{ Tools: tools, }, MaxStep: 1, }) if err != nil { t.Fatalf("react.NewAgent(all tools): %v", err) } } func TestAgent_Registered(t *testing.T) { c, err := New("Agent", map[string]any{"model_id": "stub", "user_prompt": "x"}) if err != nil { t.Fatalf("New(Agent): %v", err) } if c.Name() != "Agent" { t.Errorf("Name()=%q, want Agent", c.Name()) } } // exhaustStepsModel is a scripted ToolCallingChatModel that emits a // tool_call on every Generate and never returns final content. It // is the input driver for TestAgent_ReActExhaustsSteps, which needs // the eino ReAct loop to hit its MaxStep ceiling. type exhaustStepsModel struct { turn int rounds [][]*schema.Message boundTools []*schema.ToolInfo toolName string toolArgs string } func (m *exhaustStepsModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) { m.boundTools = tools return m, nil } func (m *exhaustStepsModel) Generate(_ context.Context, in []*schema.Message, _ ...model.Option) (*schema.Message, error) { cp := make([]*schema.Message, len(in)) copy(cp, in) m.rounds = append(m.rounds, cp) m.turn++ return &schema.Message{ Role: schema.Assistant, ToolCalls: []schema.ToolCall{{ ID: fmt.Sprintf("call_%d", m.turn), Type: "function", Function: schema.FunctionCall{ Name: m.toolName, Arguments: m.toolArgs, }, }}, }, nil } func (m *exhaustStepsModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) { sr, sw := schema.Pipe[*schema.Message](1) sw.Close() return sr, nil } // TestAgent_ReActExhaustsSteps drives a real react.NewAgent whose // scripted model always returns a tool_call and never returns final // content. With MaxStep: 2 the loop must terminate with an error // from eino's MaxStep guard, while the real ExeSQLTool is invoked // at least once on the way. This is the eino error-path counterpart // to TestExeSQL_RealReactAgent_ExecutesTool: the latter proves the // happy path (model returns tool_call, framework runs tool, model // returns final); this one proves the loop guard. func TestAgent_ReActExhaustsSteps(t *testing.T) { t.Parallel() // Real ExeSQLTool with sqlmock. The query is identical across // turns; sqlmock's QueryMatcherEqual will accept each call. // eino's MaxStep=2 with a tool_call-only model invokes the tool // exactly once before the loop guard fires (per eino's react // internals — the second iteration is the MaxStep check itself, // not a new tool call), so stage one ping + one query. db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) if err != nil { t.Fatalf("sqlmock.New: %v", err) } defer db.Close() mock.ExpectPing() mock.ExpectQuery("SELECT 1").WillReturnRows(sqlmock.NewRows([]string{"x"}).AddRow(1)) // Default sql.Open would try to connect to a real MySQL; the // dialer stub makes the tool talk to sqlmock instead. dialer := func(_, _ string) (*sql.DB, error) { return db, nil } // BuildByName goes through the public registry — the same path // AgentComponent.buildAgentTools takes. This proves the agent's // own wiring (ToolsConfig -> real BaseTool) works under the // MaxStep guard, not a backdoor constructor. built, err := agenttool.BuildByName("execute_sql", map[string]any{ "db_type": "mysql", "host": "127.0.0.1", "port": 3306, "database": "demo", "username": "u", "password": "p", "max_records": 10, }) if err != nil { t.Fatalf("agenttool.BuildByName(execute_sql): %v", err) } exeSQLTool, ok := built.(*agenttool.ExeSQLTool) if !ok { t.Fatalf("BuildByName(execute_sql) returned %T, want *ExeSQLTool", built) } realTool := exeSQLTool.WithExeSQLDialer(dialer) mdl := &exhaustStepsModel{ toolName: "execute_sql", toolArgs: `{"sql": "SELECT 1"}`, } agent, err := react.NewAgent(context.Background(), &react.AgentConfig{ ToolCallingModel: mdl, ToolsConfig: compose.ToolsNodeConfig{ Tools: []einotool.BaseTool{realTool}, }, MaxStep: 2, }) if err != nil { t.Fatalf("react.NewAgent: %v", err) } out, err := agent.Generate(context.Background(), []*schema.Message{ schema.UserMessage("loop forever"), }) if err == nil { t.Fatalf("agent.Generate returned no error; out=%+v — expected MaxStep exhaustion", out) } if mdl.turn < 1 { t.Errorf("model.Generate called %d times, want >= 1 (the loop should have invoked it before giving up)", mdl.turn) } if len(mdl.boundTools) != 1 || mdl.boundTools[0].Name != "execute_sql" { names := make([]string, 0, len(mdl.boundTools)) for _, ti := range mdl.boundTools { names = append(names, ti.Name) } t.Errorf("tools bound to model = %v, want [execute_sql]", names) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("sqlmock expectations: %v", err) } }