mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
Replaces the Python agent canvas runtime with a Go implementation that runs inside `cmd/server_main`. The canvas compiles into an eino Workflow that pauses on wait-for-user via native Interrupt/Resume (no sentinel flag) and resumes from a Redis-backed CheckPointStore. All 21 Python agent components and ~35 tools are ported with functional parity. Sandbox providers now read their JSON config from the admin-panel system_settings table with env fallback. 234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate added); drops the v2 DSL E2E step and the gap-analysis plan (both redundant after the port ships). ## Type of change - [x] Refactoring - [x] New feature - [x] Bug fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
425 lines
13 KiB
Go
425 lines
13 KiB
Go
// Package component — Agent unit tests.
|
|
//
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestAgent_Invoke_RespectsParentCancellation: when the parent
|
|
// ctx is cancelled, the runner observes it and the error
|
|
// propagates through Invoke.
|
|
func TestAgent_Invoke_RespectsParentCancellation(t *testing.T) {
|
|
withAgentRunner(t, func(ctx context.Context, _ AgentParam) (*schema.Message, error) {
|
|
// Honor ctx cancellation — real runners do; a stub that
|
|
// ignores ctx would defeat the test's purpose.
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &schema.Message{Content: "ok"}, nil
|
|
})
|
|
c := NewAgentComponent(AgentParam{ModelID: "echo", MaxRounds: 1})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // pre-cancel
|
|
|
|
_, err := c.Invoke(ctx, map[string]any{"user_prompt": "hi"})
|
|
if err == nil {
|
|
t.Fatal("expected error from pre-cancelled context")
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("expected context.Canceled, got %v", 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)
|
|
}
|
|
}
|