feat(go-agent): Ported retrieval node, added Keenable web search tool (#16396)

Ported retrieval node, added Keenable web search tool
- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Zhichang Yu
2026-06-26 22:55:49 +08:00
committed by GitHub
parent 9d18f33296
commit 70546ea406
91 changed files with 5920 additions and 3817 deletions

View File

@@ -189,6 +189,10 @@ func initialUserFillUpData(ctx context.Context, inputSpec map[string]any) (any,
if err != nil || raw == nil {
return nil, false
}
if values, ok := raw.(map[string]any); ok {
state.Sys["__initial_user_input_consumed__"] = true
return values, true
}
text, ok := raw.(string)
if !ok || text == "" {
return nil, false

View File

@@ -352,6 +352,29 @@ func TestInitialUserFillUpData_UsesSysQueryWhenSchemaPresent(t *testing.T) {
}
}
func TestInitialUserFillUpData_UsesStructuredSysQueryWhenSchemaPresent(t *testing.T) {
state := NewCanvasState("run-1", "task-1")
state.Sys["query"] = map[string]any{"kb": "da1", "query": "合同"}
ctx := WithState(context.Background(), state)
got, ok := initialUserFillUpData(ctx, map[string]any{
"inputs": map[string]any{
"kb": map[string]any{"type": "line"},
"query": map[string]any{"type": "line"},
},
})
if !ok {
t.Fatal("expected initialUserFillUpData to consume structured sys.query")
}
values, ok := got.(map[string]any)
if !ok {
t.Fatalf("got type %T, want map[string]any", got)
}
if values["kb"] != "da1" || values["query"] != "合同" {
t.Fatalf("got %#v, want kb=da1 query=合同", values)
}
}
func TestInitialUserFillUpData_SkipsWhenNoSchema(t *testing.T) {
state := NewCanvasState("run-1", "task-1")
state.Sys["query"] = "loop"

View File

@@ -221,7 +221,8 @@ func (r *Runner) getInterruptID(canvasID, sessionID string) string {
func (r *Runner) Run(
ctx context.Context,
run RunFunc,
canvasID, sessionID, userInput string,
canvasID, sessionID string,
userInput any,
root map[string]any,
) <-chan RunEvent {
out := make(chan RunEvent, 8)
@@ -295,7 +296,7 @@ func (r *Runner) Run(
// invoking the workflow. The sentinel keys are deleted from
// root inside the RunFunc — see service/agent.go's
// buildRunFunc.
if userInput != "" {
if userInput != nil {
if id := r.getInterruptID(canvasID, sessionID); id != "" {
root["__resume_interrupt_id__"] = id
root["__resume_data__"] = userInput

View File

@@ -34,7 +34,13 @@ import (
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"ragflow/internal/agent/runtime"
agenttool "ragflow/internal/agent/tool"
"ragflow/internal/dao"
"ragflow/internal/entity"
)
type codeExecSandboxRecorder struct {
@@ -190,6 +196,203 @@ func TestRetrieval_KbIDsTranslatedToDatasetIDs(t *testing.T) {
}
}
func TestRetrieval_LegacyQueryStringNormalized(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("failed to unwrap sql db: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&entity.Knowledgebase{}); err != nil {
t.Fatalf("failed to migrate knowledgebase: %v", err)
}
if err := db.AutoMigrate(&entity.UserTenant{}); err != nil {
t.Fatalf("failed to migrate user_tenant: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
activeStatus := "1"
if err := db.Create(&entity.UserTenant{
ID: "ut-1",
UserID: "user-1",
TenantID: "tenant-1",
Role: "owner",
InvitedBy: "user-1",
Status: &activeStatus,
}).Error; err != nil {
t.Fatalf("failed to seed user_tenant: %v", err)
}
if err := db.Create(&entity.Knowledgebase{
ID: "kb-da1",
Name: "da1",
TenantID: "tenant-1",
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
Permission: "me",
CreatedBy: "user-1",
Status: func() *string { s := string(entity.StatusValid); return &s }(),
}).Error; err != nil {
t.Fatalf("failed to seed kb: %v", err)
}
c, err := newRetrievalComponent(nil)
if err != nil {
t.Fatalf("newRetrievalComponent: %v", err)
}
rc := c.(*retrievalComponent)
merged := rc.applyDefaults(map[string]any{
"query": "UserFillUp: da1\nInput diamond necklace\n",
})
state := runtime.NewCanvasState("run-1", "task-1")
state.Sys["user_id"] = "user-1"
normalizeLegacyRetrievalInputs(runtime.WithState(context.Background(), state), merged)
if got, _ := merged["query"].(string); got != "diamond necklace" {
t.Fatalf("query = %q, want diamond necklace", got)
}
ds, ok := merged["dataset_ids"].([]string)
if !ok || len(ds) != 1 || ds[0] != "kb-da1" {
t.Fatalf("dataset_ids = %#v, want []string{\"kb-da1\"}", merged["dataset_ids"])
}
}
func TestRetrieval_StructuredUserFillInputNormalized(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("failed to unwrap sql db: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.UserTenant{}); err != nil {
t.Fatalf("failed to migrate tables: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
activeStatus := "1"
if err := db.Create(&entity.UserTenant{
ID: "ut-1",
UserID: "user-1",
TenantID: "tenant-1",
Role: "owner",
InvitedBy: "user-1",
Status: &activeStatus,
}).Error; err != nil {
t.Fatalf("failed to seed user_tenant: %v", err)
}
if err := db.Create(&entity.Knowledgebase{
ID: "kb-da1",
Name: "da1",
TenantID: "tenant-1",
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
Permission: "me",
CreatedBy: "user-1",
Status: func() *string { s := string(entity.StatusValid); return &s }(),
}).Error; err != nil {
t.Fatalf("failed to seed kb: %v", err)
}
c, err := newRetrievalComponent(nil)
if err != nil {
t.Fatalf("newRetrievalComponent: %v", err)
}
rc := c.(*retrievalComponent)
merged := rc.applyDefaults(map[string]any{
"state": map[string]any{
"UserFillUp:KBInput": map[string]any{
"kb": "da1",
"query": "合同",
},
},
})
state := runtime.NewCanvasState("run-1", "task-1")
state.Sys["user_id"] = "user-1"
normalizeLegacyRetrievalInputs(runtime.WithState(context.Background(), state), merged)
if got, _ := merged["query"].(string); got != "合同" {
t.Fatalf("query = %q, want 合同", got)
}
ds, ok := merged["dataset_ids"].([]string)
if !ok || len(ds) != 1 || ds[0] != "kb-da1" {
t.Fatalf("dataset_ids = %#v, want []string{\"kb-da1\"}", merged["dataset_ids"])
}
}
func TestRetrieval_ResolveDatasetIDByTenantName(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("failed to unwrap sql db: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&entity.Knowledgebase{}); err != nil {
t.Fatalf("failed to migrate knowledgebase: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
if err := db.Create(&entity.Knowledgebase{
ID: "kb-da1",
Name: "da1",
TenantID: "tenant-1",
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
Permission: "me",
CreatedBy: "user-1",
Status: func() *string { s := string(entity.StatusValid); return &s }(),
}).Error; err != nil {
t.Fatalf("failed to seed kb: %v", err)
}
state := runtime.NewCanvasState("run-1", "task-1")
state.Sys["tenant_id"] = "tenant-1"
ctx := runtime.WithState(context.Background(), state)
if got := resolveRetrievalDatasetID(ctx, "da1"); got != "kb-da1" {
t.Fatalf("resolveRetrievalDatasetID = %q, want kb-da1", got)
}
}
func TestRetrieval_StructuredInputPreservesQueryWhenDatasetIDsAlreadyPresent(t *testing.T) {
c, err := newRetrievalComponent(nil)
if err != nil {
t.Fatalf("newRetrievalComponent: %v", err)
}
rc := c.(*retrievalComponent)
merged := rc.applyDefaults(map[string]any{
"dataset_ids": []string{"kb-fixed"},
"state": map[string]any{
"UserFillUp:KBInput": map[string]any{
"kb": "da1",
"query": "合同",
},
},
})
consumed := normalizeStructuredRetrievalInputs(context.Background(), merged)
if !consumed {
t.Fatal("normalizeStructuredRetrievalInputs should consume structured query")
}
if got, _ := merged["query"].(string); got != "合同" {
t.Fatalf("query = %q, want 合同", got)
}
ds, ok := merged["dataset_ids"].([]string)
if !ok || len(ds) != 1 || ds[0] != "kb-fixed" {
t.Fatalf("dataset_ids = %#v, want []string{\"kb-fixed\"}", merged["dataset_ids"])
}
}
// TestRetrieval_KbIDsEndToEndThroughTool is the wire-level
// companion to TestRetrieval_KbIDsTranslatedToDatasetIDs: it
// installs the simple retrieval service, builds a wrapper with

View File

@@ -353,6 +353,22 @@ func evaluateClause(clause map[string]any, state *runtime.CanvasState) (bool, er
right := clause["right"]
lv := leftValue(left, state)
// Port of python PR #16320: for the four string operators,
// coerce nil on either side to "". In Python this avoids
// AttributeError on `.lower()`; in Go there's no crash (fmt
// renders nil as "<nil>"), but the Python post-fix semantic —
// where "foo" contains None is True — diverges from Go's
// "<nil>" rendering. Coercing to "" aligns the Go port with
// the Python workflow.
if op == "contains" || op == "not contains" || op == "start with" || op == "end with" {
if lv == nil {
lv = ""
}
if right == nil {
right = ""
}
}
switch op {
case "==":
return equalFoldValues(lv, right), nil

View File

@@ -241,6 +241,149 @@ func TestSwitch_LegacyConditionsAndArrayTo(t *testing.T) {
}
}
// TestSwitch_NilUpstreamContainsEmptyNeedleMatches ports the
// regression covered by python PR #16320: when an upstream
// component yields nil and the configured value is the empty
// string, the "contains" operator must match (Python semantics
// after the fix: "" in "anything"). Pre-fix Python crashed with
// AttributeError; pre-port Go returned false because fmt rendered
// nil as "<nil>" instead of "". The fix coerces nil → "" before
// formatting, restoring parity with the Python workflow.
func TestSwitch_NilUpstreamContainsEmptyNeedleMatches(t *testing.T) {
s, _ := NewSwitchComponent(nil)
state := canvas.NewCanvasState("run-nil-contains", "task-nil-contains")
state.Sys["answer"] = nil
ctx := withStateForTest(context.Background(), state)
inputs := map[string]any{
"conditions": []any{
map[string]any{
"op": "and",
"to": []any{"case_target"},
"clauses": []any{
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": ""},
},
},
},
"default": "else_target",
}
out, err := s.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
targets := nextTargets(out)
if len(targets) != 1 || targets[0] != "case_target" {
t.Errorf("_next: got %v, want [\"case_target\"] (nil coerced to \"\" should match empty needle)", targets)
}
}
// TestSwitch_NilUpstreamContainsNonEmptyDoesNotMatch verifies
// the inverse: nil coerced to "" still must NOT match a
// non-empty needle (we only coerce, we don't synthesize a match).
func TestSwitch_NilUpstreamContainsNonEmptyDoesNotMatch(t *testing.T) {
s, _ := NewSwitchComponent(nil)
state := canvas.NewCanvasState("run-nil-needle", "task-nil-needle")
state.Sys["answer"] = nil
ctx := withStateForTest(context.Background(), state)
inputs := map[string]any{
"conditions": []any{
map[string]any{
"op": "and",
"to": []any{"case_target"},
"clauses": []any{
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": "foo"},
},
},
},
"default": "else_target",
}
out, err := s.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
targets := nextTargets(out)
if len(targets) != 1 || targets[0] != "else_target" {
t.Errorf("_next: got %v, want [\"else_target\"] (\"\" does not contain \"foo\")", targets)
}
}
// TestSwitch_NilValueContainsDoesNotRaise mirrors python test
// "test_switch_none_value_contains_does_not_raise": the configured
// value can also be nil and the operator must not crash. With
// nil coerced to "" on both sides, "foobar" contains "" matches.
func TestSwitch_NilValueContainsDoesNotRaise(t *testing.T) {
s, _ := NewSwitchComponent(nil)
state := canvas.NewCanvasState("run-nil-value", "task-nil-value")
state.Sys["answer"] = "foobar"
ctx := withStateForTest(context.Background(), state)
inputs := map[string]any{
"conditions": []any{
map[string]any{
"op": "and",
"to": []any{"case_target"},
"clauses": []any{
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": nil},
},
},
},
"default": "else_target",
}
out, err := s.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
targets := nextTargets(out)
if len(targets) != 1 || targets[0] != "case_target" {
t.Errorf("_next: got %v, want [\"case_target\"] (nil value coerced to \"\" matches any string)", targets)
}
}
// TestSwitch_NilUpstreamStartWithEndWithDoNotCrash guards the
// remaining two string operators covered by PR #16320. They were
// crash-prone in Python for the same reason; in Go they don't
// crash but the nil → "" coercion still applies, so a nil
// upstream with an empty prefix/suffix must match (rather than
// being rendered as "<nil>" and silently missing).
func TestSwitch_NilUpstreamStartWithEndWithDoNotCrash(t *testing.T) {
s, _ := NewSwitchComponent(nil)
state := canvas.NewCanvasState("run-nil-start-end", "task-nil-start-end")
state.Sys["answer"] = nil
ctx := withStateForTest(context.Background(), state)
for _, tc := range []struct {
name string
op string
}{
{name: "start with", op: "start with"},
{name: "end with", op: "end with"},
} {
t.Run(tc.name, func(t *testing.T) {
inputs := map[string]any{
"conditions": []any{
map[string]any{
"op": "and",
"to": []any{"case_target"},
"clauses": []any{
map[string]any{"left": "{{sys.answer}}", "op": tc.op, "right": ""},
},
},
},
"default": "else_target",
}
out, err := s.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
targets := nextTargets(out)
if len(targets) != 1 || targets[0] != "case_target" {
t.Errorf("_next: got %v, want [\"case_target\"] (nil coerced to \"\" %s \"\")", targets, tc.op)
}
})
}
}
// TestSwitch_MultiTargetTo verifies that Switch returns all cpn_ids
// from a multi-element "to" field. This mirrors Python's behavior
// where a condition can route to multiple downstream nodes

View File

@@ -31,16 +31,22 @@ package component
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
einotool "github.com/cloudwego/eino/components/tool"
"ragflow/internal/agent/runtime"
agenttool "ragflow/internal/agent/tool"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"go.uber.org/zap"
"gorm.io/gorm"
)
// tavilySearchComponent delegates to internal/agent/tool/TavilyTool.
@@ -153,6 +159,8 @@ type retrievalComponent struct {
params retrievalParams
}
var legacyRetrievalQueryPattern = regexp.MustCompile(`(?s)^\s*UserFillUp:\s*(.*?)\s+Input\s+(.*?)\s*$`)
func newRetrievalComponent(params map[string]any) (Component, error) {
return &retrievalComponent{
inner: agenttool.NewRetrievalTool(),
@@ -180,11 +188,19 @@ func (c *retrievalComponent) Outputs() map[string]string {
func (c *retrievalComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
merged := c.applyDefaults(inputs)
normalizeLegacyRetrievalInputs(ctx, merged)
common.Debug("agent retrieval component: invoke",
zap.Any("inputs", inputs),
zap.Any("merged", merged),
)
argsJSON, _ := json.Marshal(merged)
out, err := c.inner.InvokableRun(ctx, string(argsJSON))
if err != nil {
return nil, fmt.Errorf("canvas: Retrieval: %w", err)
}
common.Debug("agent retrieval component: output",
zap.String("tool_output", out),
)
return parseToolEnvelope(out), nil
}
@@ -261,6 +277,148 @@ func (c *retrievalComponent) applyDefaults(inputs map[string]any) map[string]any
return out
}
func normalizeLegacyRetrievalInputs(ctx context.Context, out map[string]any) {
if normalizeStructuredRetrievalInputs(ctx, out) {
return
}
rawQuery, _ := out["query"].(string)
rawQuery = strings.TrimSpace(rawQuery)
if rawQuery == "" {
return
}
matches := legacyRetrievalQueryPattern.FindStringSubmatch(rawQuery)
if len(matches) != 3 {
return
}
kbName := strings.TrimSpace(matches[1])
queryText := strings.TrimSpace(matches[2])
if queryText != "" {
out["query"] = queryText
}
if _, hasDatasetIDs := out["dataset_ids"]; hasDatasetIDs {
return
}
if kbName == "" {
return
}
if datasetID := resolveRetrievalDatasetID(ctx, kbName); datasetID != "" {
out["dataset_ids"] = []string{datasetID}
}
}
func normalizeStructuredRetrievalInputs(ctx context.Context, out map[string]any) bool {
_, hasDatasetIDs := out["dataset_ids"]
candidateMaps := []map[string]any{}
if stateMap, ok := out["state"].(map[string]any); ok {
if raw, ok := stateMap["UserFillUp:KBInput"].(map[string]any); ok {
candidateMaps = append(candidateMaps, raw)
}
}
candidateMaps = append(candidateMaps, out)
consumed := false
for _, candidate := range candidateMaps {
kbName, _ := candidate["kb"].(string)
queryText, _ := candidate["query"].(string)
if kbName == "" && legacyRetrievalQueryPattern.MatchString(strings.TrimSpace(queryText)) {
continue
}
if kbName == "" && queryText == "" {
continue
}
consumed = true
if queryText != "" {
out["query"] = queryText
}
if kbName != "" && !hasDatasetIDs {
if datasetID := resolveRetrievalDatasetID(ctx, strings.TrimSpace(kbName)); datasetID != "" {
out["dataset_ids"] = []string{datasetID}
common.Debug("agent retrieval component: resolved dataset id",
zap.String("kb", strings.TrimSpace(kbName)),
zap.String("dataset_id", datasetID))
}
}
if queryText != "" {
return true
}
if kbName != "" && out["dataset_ids"] != nil {
return true
}
}
return consumed
}
func resolveRetrievalDatasetID(ctx context.Context, kbName string) string {
if kbName == "" {
return ""
}
if kb, err := dao.NewKnowledgebaseDAO().GetByID(kbName); err == nil && kb != nil {
common.Debug("agent retrieval component: resolved dataset id by direct id",
zap.String("kb", kbName),
zap.String("dataset_id", kb.ID))
return kb.ID
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
common.Warn("agent retrieval component: resolve dataset id by id failed",
zap.String("kb", kbName),
zap.Error(err))
}
if state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err == nil && state != nil {
common.Debug("agent retrieval component: resolve dataset id context",
zap.String("kb", kbName),
zap.Any("sys_query", state.Sys["query"]),
zap.Any("tenant_id", state.Sys["tenant_id"]),
zap.Any("user_id", state.Sys["user_id"]))
if tenantID, _ := state.Sys["tenant_id"].(string); tenantID != "" {
if kb, lookupErr := dao.NewKnowledgebaseDAO().GetByName(kbName, tenantID); lookupErr == nil && kb != nil {
common.Debug("agent retrieval component: resolved dataset id by tenant",
zap.String("kb", kbName),
zap.String("tenant_id", tenantID),
zap.String("dataset_id", kb.ID))
return kb.ID
} else if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
common.Warn("agent retrieval component: resolve dataset id by tenant failed",
zap.String("kb", kbName),
zap.String("tenant_id", tenantID),
zap.Error(lookupErr))
} else {
common.Debug("agent retrieval component: tenant lookup missed",
zap.String("kb", kbName),
zap.String("tenant_id", tenantID))
}
}
if userID, _ := state.Sys["user_id"].(string); userID != "" {
if kbs, lookupErr := dao.NewKnowledgebaseDAO().GetKBByNameAndUserID(kbName, userID); lookupErr == nil && len(kbs) > 0 {
for _, kb := range kbs {
if kb == nil || kb.Status == nil || *kb.Status != string(entity.StatusValid) {
continue
}
common.Debug("agent retrieval component: resolved dataset id by user visibility",
zap.String("kb", kbName),
zap.String("user_id", userID),
zap.String("dataset_id", kb.ID))
return kb.ID
}
} else if lookupErr != nil {
common.Warn("agent retrieval component: resolve dataset id by name failed",
zap.String("kb", kbName),
zap.String("user_id", userID),
zap.Error(lookupErr))
} else {
common.Debug("agent retrieval component: user visibility lookup missed",
zap.String("kb", kbName),
zap.String("user_id", userID))
}
}
} else {
common.Debug("agent retrieval component: resolve dataset id missing canvas state",
zap.String("kb", kbName),
zap.Error(err))
}
common.Debug("agent retrieval component: dataset id unresolved",
zap.String("kb", kbName))
return ""
}
// exesqlComponent delegates to internal/agent/tool/ExeSQLTool. The
// connection params (db_type, host, port, database, username,
// password) are passed via the canvas node's params map at build

View File

@@ -20,7 +20,7 @@
// The Python provider uses the high-level `agentrun-sdk` Python
// package, which exposes a `Sandbox` class with `create()` /
// `connect()` / `context.execute()` / `delete_by_id()` methods.
// The Go SDK at v1.1.0 is the OpenAPI stub — it has lifecycle
// The Go SDK at v5.8.4 is the OpenAPI stub — it has lifecycle
// operations (CreateCodeInterpreter / DeleteCodeInterpreter /
// ListCodeInterpreters / GetCodeInterpreter) but does NOT expose
// the execute endpoint.
@@ -48,8 +48,8 @@ import (
"strings"
"time"
"github.com/alibabacloud-go/agentrun-20250910/client"
agentrun "github.com/alibabacloud-go/agentrun-20250910/client"
"github.com/alibabacloud-go/agentrun-20250910/v5/client"
agentrun "github.com/alibabacloud-go/agentrun-20250910/v5/client"
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
)
@@ -213,7 +213,7 @@ func (p *AliyunCodeInterpreterProvider) CreateInstance(ctx context.Context, temp
templateName = fmt.Sprintf("ragflow-%s-default", lang)
}
// NOTE: Go SDK v1.1.0's CreateCodeInterpreterInput does not
// NOTE: Go SDK v5.8.4's CreateCodeInterpreterInput does not
// expose a TemplateName field. The Python SDK creates the
// template via the high-level `Template.create()` API and
// then references it from `Sandbox.create(template_name=...)`.

View File

@@ -0,0 +1,312 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// 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 tool
import (
"context"
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"os"
"strings"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
const keenableToolName = "keenable"
// keenableToolDescription follows the upstream Python tool's description,
// trimmed for the chat model. The "no API key required" line is the
// differentiator from Tavily/DuckDuckGo/SearXNG.
const keenableToolDescription = `Keenable is a web search API built for AI agents. It returns fresh, relevant web results for a query and works without an API key by default (keyless free tier). When searching:
- Use a focused query of the most important terms (and synonyms).
- Optionally restrict to a single site/domain.`
// keenableParams is the JSON shape the model sends into InvokableRun.
// site is an optional single-domain filter. mode is "pro" (default,
// deeper) or "realtime" (requires a server-configured key). top_n caps
// how many results we keep from the upstream `results` array.
type keenableParams struct {
Query string `json:"query"`
Site string `json:"site"`
Mode string `json:"mode"`
TopN int `json:"top_n"`
}
// keenableRequestBody is the JSON body POSTed to the Keenable search
// endpoint. Mirrors the upstream Python tool — query, mode, and an
// optional site filter.
type keenableRequestBody struct {
Query string `json:"query"`
Mode string `json:"mode"`
Site string `json:"site,omitempty"`
}
// keenableResult mirrors one element of the upstream `results` array.
// The Python tool's _retrieve_chunks reads `title`, `url`, `description`,
// so we model those fields and pass everything else through verbatim
// when serializing to the model.
type keenableResult struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
}
// keenableResponse is the envelope returned by Keenable. We only model
// the fields we care about; the upstream API has more, but they are
// ignored.
type keenableResponse struct {
Results []keenableResult `json:"results"`
}
// keenableEnvelope is the shape the model actually sees, identical to
// the Python tool's output convention.
type keenableEnvelope struct {
Results []keenableResult `json:"results"`
Error string `json:"_ERROR,omitempty"`
}
// KeenableTool is the Keenable web search tool. It POSTs a search
// request to the public keyless endpoint by default and to the keyed
// endpoint (with X-API-Key) when an API key is provided. The upstream
// `results` array is returned as JSON.
type KeenableTool struct {
helper *HTTPHelper
apiKey string
// envBaseURL resolves the Keenable API base URL from the
// KEENABLE_API_URL env var (HTTPS enforced). Exposed as a
// function so tests can inject a fake without mutating process
// state — matches the envKey pattern used by TavilyTool.
envBaseURL func() string
}
// NewKeenableTool returns a KeenableTool using the default HTTPHelper
// and the KEENABLE_API_URL env var for base-URL resolution.
func NewKeenableTool() *KeenableTool {
return NewKeenableToolWith(NewHTTPHelper())
}
// NewKeenableToolWithAPIKey returns a KeenableTool that uses a
// server-provided API key instead of model-visible runtime args.
func NewKeenableToolWithAPIKey(h *HTTPHelper, apiKey string) *KeenableTool {
t := NewKeenableToolWith(h)
t.apiKey = strings.TrimSpace(apiKey)
return t
}
// NewKeenableToolWith returns a KeenableTool that uses the provided
// HTTPHelper. Useful for tests that want to inject a custom transport.
func NewKeenableToolWith(h *HTTPHelper) *KeenableTool {
if h == nil {
h = NewHTTPHelper()
}
return &KeenableTool{helper: h, envBaseURL: defaultKeenableEnvBaseURL}
}
// NewKeenableToolWithEnvBaseURL returns a KeenableTool with a custom
// base-URL resolver. Useful for tests that want to inject a fake env
// without mutating process state.
func NewKeenableToolWithEnvBaseURL(h *HTTPHelper, envBaseURL func() string) *KeenableTool {
if h == nil {
h = NewHTTPHelper()
}
if envBaseURL == nil {
envBaseURL = defaultKeenableEnvBaseURL
}
return &KeenableTool{helper: h, envBaseURL: envBaseURL}
}
// defaultKeenableEnvBaseURL is the production base-URL resolver.
// Pulled out as a named function (not a var) so tests cannot
// accidentally mutate it via package-var assignment.
func defaultKeenableEnvBaseURL() string {
if v := strings.TrimSpace(os.Getenv("KEENABLE_API_URL")); v != "" {
return v
}
return "https://api.keenable.ai"
}
// resolveKeenableBaseURL returns the validated Keenable base URL.
// HTTPS is required for any non-loopback host; loopback hosts may
// use plain http for local development. Mirrors the Python tool's
// _base_url() guard — a misconfigured URL fails fast at request time
// rather than silently making a request to the wrong host.
func resolveKeenableBaseURL(raw string) (string, error) {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return "", fmt.Errorf("keenable: empty base URL")
}
u, err := neturl.Parse(raw)
if err != nil {
return "", fmt.Errorf("keenable: parse KEENABLE_API_URL %q: %w", raw, err)
}
host := u.Hostname()
if host == "" {
return "", fmt.Errorf("keenable: KEENABLE_API_URL must have a host, got %q", raw)
}
if u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("keenable: KEENABLE_API_URL must not include query or fragment, got %q", raw)
}
switch u.Scheme {
case "https":
return raw, nil
case "http":
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return raw, nil
}
return "", fmt.Errorf("keenable: KEENABLE_API_URL must be https://, got %q", raw)
default:
return "", fmt.Errorf("keenable: KEENABLE_API_URL scheme %q not allowed (https required)", u.Scheme)
}
}
// Info returns the tool's metadata for the chat model. The description
// is the short prose above; the parameter schema lists the model-emitted
// fields with sane defaults documented inline.
func (k *KeenableTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: keenableToolName,
Desc: keenableToolDescription,
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"query": {
Type: schema.String,
Desc: "Search keywords to execute with Keenable. The most important words/terms (and synonyms) from the original request.",
Required: true,
},
"site": {
Type: schema.String,
Desc: "Optional. Restrict results to a single domain, e.g. 'techcrunch.com'. Defaults to '' (no filter).",
Required: false,
},
"mode": {
Type: schema.String,
Desc: `Search mode: "pro" (default, deeper) or "realtime" (low latency; requires a server-configured API key).`,
Required: false,
},
"top_n": {
Type: schema.Integer,
Desc: "Maximum number of results to return. Defaults to 10.",
Required: false,
},
}),
}, nil
}
// InvokableRun performs the Keenable search.
func (k *KeenableTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) {
var p keenableParams
if err := json.Unmarshal([]byte(argsJSON), &p); err != nil {
return keenableErrJSON(fmt.Errorf("keenable: parse arguments: %w", err)),
fmt.Errorf("keenable: parse arguments: %w", err)
}
if strings.TrimSpace(p.Query) == "" {
return keenableErrJSON(fmt.Errorf("query is required")),
fmt.Errorf("keenable: query is required")
}
mode := strings.TrimSpace(p.Mode)
if mode == "" {
mode = "pro"
}
if mode != "pro" && mode != "realtime" {
return keenableErrJSON(fmt.Errorf("keenable: mode %q must be one of [pro realtime]", p.Mode)),
fmt.Errorf("keenable: mode %q must be one of [pro realtime]", p.Mode)
}
// 'realtime' is only available on the keyed endpoint. Reject the
// invalid combination up front instead of letting the upstream
// return a confusing error — matches the Python tool's check().
if mode == "realtime" && strings.TrimSpace(k.apiKey) == "" {
return keenableErrJSON(fmt.Errorf("keenable: 'realtime' mode requires a configured api_key")),
fmt.Errorf("keenable: 'realtime' mode requires a configured api_key")
}
topN := p.TopN
if topN <= 0 {
topN = 10
}
baseURL, err := resolveKeenableBaseURL(k.envBaseURL())
if err != nil {
// Config/local error — won't be fixed by retrying, so fail fast
// (matches the Python tool's behavior for ValueError).
return keenableErrJSON(err), err
}
apiKey := strings.TrimSpace(k.apiKey)
path := "/v1/search/public"
headers := map[string]string{
"User-Agent": "keenable-ragflow",
"X-Keenable-Title": "RAGFlow",
}
if apiKey != "" {
path = "/v1/search"
headers["X-API-Key"] = apiKey
}
body := keenableRequestBody{
Query: p.Query,
Mode: mode,
}
if site := strings.TrimSpace(p.Site); site != "" {
body.Site = site
}
bodyJSON, _ := json.Marshal(body)
resp, err := k.helper.Do(ctx,
http.MethodPost, baseURL+path, string(bodyJSON), "application/json", headers,
)
if err != nil {
return keenableErrJSON(err), err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return keenableErrJSON(fmt.Errorf("keenable: upstream returned %d", resp.StatusCode)),
fmt.Errorf("keenable: upstream returned %d", resp.StatusCode)
}
var raw keenableResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return keenableErrJSON(fmt.Errorf("keenable: decode response: %w", err)),
fmt.Errorf("keenable: decode response: %w", err)
}
results := raw.Results
if len(results) > topN {
results = results[:topN]
}
return keenableJSON(keenableEnvelope{Results: results}), nil
}
// keenableJSON marshals the envelope to a JSON string for the model.
func keenableJSON(env keenableEnvelope) string {
b, err := json.Marshal(env)
if err != nil {
return fmt.Sprintf(`{"_ERROR":"keenable: marshal result: %s"}`, err)
}
return string(b)
}
// keenableErrJSON wraps an error in the standard envelope.
func keenableErrJSON(err error) string {
return keenableJSON(keenableEnvelope{Error: err.Error()})
}

View File

@@ -0,0 +1,392 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// 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 tool
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestKeenable_KeylessPath verifies that when no api_key is supplied the
// tool POSTs to /v1/search/public with the attribution headers but
// without an X-API-Key header.
func TestKeenable_KeylessPath(t *testing.T) {
t.Parallel()
var gotMethod, gotPath, gotUA, gotTitle, gotAPIKey, gotCT string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotUA = r.Header.Get("User-Agent")
gotTitle = r.Header.Get("X-Keenable-Title")
gotAPIKey = r.Header.Get("X-API-Key")
gotCT = r.Header.Get("Content-Type")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
if _, err := tool.InvokableRun(context.Background(), `{"query":"ragflow"}`); err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %q, want POST", gotMethod)
}
if gotPath != "/v1/search/public" {
t.Errorf("path = %q, want /v1/search/public (keyless endpoint)", gotPath)
}
if gotUA != "keenable-ragflow" {
t.Errorf("User-Agent = %q, want keenable-ragflow", gotUA)
}
if gotTitle != "RAGFlow" {
t.Errorf("X-Keenable-Title = %q, want RAGFlow", gotTitle)
}
if gotAPIKey != "" {
t.Errorf("X-API-Key = %q, want empty on keyless path", gotAPIKey)
}
if !strings.HasPrefix(gotCT, "application/json") {
t.Errorf("Content-Type = %q, want application/json", gotCT)
}
if gotBody["query"] != "ragflow" {
t.Errorf("body.query = %v, want ragflow", gotBody["query"])
}
if gotBody["mode"] != "pro" {
t.Errorf("body.mode = %v, want pro (default)", gotBody["mode"])
}
}
// TestKeenable_KeyedPath verifies that a server-configured api_key
// switches the tool to the /v1/search endpoint and sets X-API-Key on
// the request.
func TestKeenable_KeyedPath(t *testing.T) {
t.Parallel()
var gotPath, gotAPIKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAPIKey = r.Header.Get("X-API-Key")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithAPIKey(helper, "key-xyz")
tool.envBaseURL = func() string { return "https://" + srv.URL[len("http://"):] }
if _, err := tool.InvokableRun(context.Background(),
`{"query":"ragflow","mode":"realtime"}`); err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotPath != "/v1/search" {
t.Errorf("path = %q, want /v1/search (keyed endpoint)", gotPath)
}
if gotAPIKey != "key-xyz" {
t.Errorf("X-API-Key = %q, want key-xyz", gotAPIKey)
}
}
// TestKeenable_SiteAndTopN verifies the site filter is forwarded and
// that the result list is truncated to top_n.
func TestKeenable_SiteAndTopN(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[
{"title":"A","url":"https://a","description":"alpha"},
{"title":"B","url":"https://b","description":"beta"},
{"title":"C","url":"https://c","description":"gamma"},
{"title":"D","url":"https://d","description":"delta"}
]}`))
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
out, err := tool.InvokableRun(context.Background(),
`{"query":"x","site":"example.com","top_n":2}`)
if err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotBody["site"] != "example.com" {
t.Errorf("body.site = %v, want example.com", gotBody["site"])
}
var env keenableEnvelope
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
t.Fatalf("output not valid JSON: %v (raw=%s)", jerr, out)
}
if env.Error != "" {
t.Errorf("Error = %q, want empty", env.Error)
}
if len(env.Results) != 2 {
t.Fatalf("Results len = %d, want 2 (capped by top_n)", len(env.Results))
}
if env.Results[0].Title != "A" || env.Results[1].Title != "B" {
t.Errorf("Results = %+v, want first 2 upstream items", env.Results)
}
}
// TestKeenable_DefaultTopN verifies that omitting top_n keeps up to 10
// results from the upstream response (the default in the Python tool).
func TestKeenable_DefaultTopN(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 12 results; default top_n is 10, so we expect 10 in the envelope.
var results []map[string]string
for range 12 {
results = append(results, map[string]string{
"title": "T",
"url": "https://u",
"description": "d",
})
}
b, _ := json.Marshal(map[string]any{"results": results})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(b)
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
out, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
if err != nil {
t.Fatalf("InvokableRun: %v", err)
}
var env keenableEnvelope
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
t.Fatalf("output not valid JSON: %v", jerr)
}
if len(env.Results) != 10 {
t.Errorf("Results len = %d, want 10 (default top_n)", len(env.Results))
}
}
// TestKeenable_MissingQuery verifies that an empty query is rejected
// before any HTTP request is made.
func TestKeenable_MissingQuery(t *testing.T) {
t.Parallel()
tool := NewKeenableTool()
_, err := tool.InvokableRun(context.Background(), `{}`)
if err == nil {
t.Fatal("expected error for missing query")
}
if !strings.Contains(err.Error(), "query") {
t.Errorf("err = %v, want to mention query", err)
}
}
// TestKeenable_RealtimeRequiresAPIKey verifies the config-time rejection
// of realtime mode without a configured api_key.
func TestKeenable_RealtimeRequiresAPIKey(t *testing.T) {
t.Parallel()
tool := NewKeenableTool()
_, err := tool.InvokableRun(context.Background(), `{"query":"x","mode":"realtime"}`)
if err == nil {
t.Fatal("expected error for realtime mode without api_key")
}
if !strings.Contains(err.Error(), "configured api_key") {
t.Errorf("err = %v, want to mention configured api_key", err)
}
}
// TestKeenable_InvalidMode verifies that an unknown mode is rejected
// up front instead of being forwarded to the upstream.
func TestKeenable_InvalidMode(t *testing.T) {
t.Parallel()
tool := NewKeenableTool()
_, err := tool.InvokableRun(context.Background(), `{"query":"x","mode":"bogus"}`)
if err == nil {
t.Fatal("expected error for invalid mode")
}
if !strings.Contains(err.Error(), "mode") {
t.Errorf("err = %v, want to mention mode", err)
}
}
// TestKeenable_ResolveBaseURL exercises the HTTPS-only / loopback-http
// guard around KEENABLE_API_URL.
func TestKeenable_ResolveBaseURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
wantOK bool
wantValue string
}{
{"https default", "https://api.keenable.ai", true, "https://api.keenable.ai"},
{"https trailing slash", "https://api.keenable.ai/", true, "https://api.keenable.ai"},
{"http loopback ok", "http://localhost:8080", true, "http://localhost:8080"},
{"http 127 ok", "http://127.0.0.1:8080", true, "http://127.0.0.1:8080"},
{"http ::1 ok", "http://[::1]:8080", true, "http://[::1]:8080"},
{"http non-loopback rejected", "http://example.com", false, ""},
{"ftp rejected", "ftp://api.keenable.ai", false, ""},
{"query rejected", "https://api.keenable.ai?x=1", false, ""},
{"fragment rejected", "https://api.keenable.ai#frag", false, ""},
{"no host rejected", "https:///path", false, ""},
{"empty rejected", "", false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := resolveKeenableBaseURL(tc.raw)
if tc.wantOK {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if got != tc.wantValue {
t.Errorf("got = %q, want %q", got, tc.wantValue)
}
return
}
if err == nil {
t.Fatalf("got = %q, want error", got)
}
})
}
}
// TestKeenable_BaseURLFromEnv verifies that the KEENABLE_API_URL env var
// is honored. We use a fake resolver that does NOT touch os.Getenv so
// the test does not depend on the host environment.
func TestKeenable_BaseURLFromEnv(t *testing.T) {
t.Parallel()
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string {
return "https://" + srv.URL[len("http://"):]
})
if _, err := tool.InvokableRun(context.Background(), `{"query":"x"}`); err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotPath != "/v1/search/public" {
t.Errorf("path = %q, want /v1/search/public", gotPath)
}
}
// TestKeenable_BadBaseURL verifies that an invalid KEENABLE_API_URL is
// reported back to the caller instead of being silently sent.
func TestKeenable_BadBaseURL(t *testing.T) {
t.Parallel()
tool := NewKeenableToolWithEnvBaseURL(NewHTTPHelper(), func() string { return "http://example.com" })
_, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
if err == nil {
t.Fatal("expected error for non-https non-loopback base URL")
}
if !strings.Contains(err.Error(), "https") {
t.Errorf("err = %v, want to mention https requirement", err)
}
}
// TestKeenable_UpstreamError verifies that a non-2xx upstream response
// is surfaced as an error and an _ERROR envelope.
func TestKeenable_UpstreamError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
out, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
if err == nil {
t.Fatal("expected error for 5xx response")
}
var env keenableEnvelope
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
t.Fatalf("output not valid JSON: %v (raw=%s)", jerr, out)
}
if env.Error == "" {
t.Errorf("envelope Error = %q, want non-empty", env.Error)
}
}
// TestKeenable_Info verifies the model-facing metadata.
func TestKeenable_Info(t *testing.T) {
t.Parallel()
tool := NewKeenableTool()
info, err := tool.Info(context.Background())
if err != nil {
t.Fatalf("Info: %v", err)
}
if info.Name != "keenable" {
t.Errorf("Name = %q, want keenable", info.Name)
}
if !strings.Contains(info.Desc, "Keenable") {
t.Errorf("Desc = %q, want to mention Keenable", info.Desc)
}
if info.ParamsOneOf == nil {
t.Fatal("ParamsOneOf = nil, want schema definition")
}
paramsJSON, err := json.Marshal(info.ParamsOneOf)
if err != nil {
t.Fatalf("marshal ParamsOneOf: %v", err)
}
if strings.Contains(string(paramsJSON), "api_key") {
t.Fatalf("Info ParamsOneOf unexpectedly exposes api_key: %s", string(paramsJSON))
}
}

View File

@@ -31,7 +31,6 @@ func TestQWeather_BuildURL(t *testing.T) {
// `qweatherEndpoint` var, which other tests (running in parallel)
// temporarily replace with a httptest.Server URL.
cases := []struct {
name string
params qweatherParams

View File

@@ -42,6 +42,7 @@ var registry = map[string]Factory{
"google": noConfig("google", func() einotool.BaseTool { return NewGoogleTool() }),
"google_scholar": noConfig("google_scholar", func() einotool.BaseTool { return NewGoogleScholarTool() }),
"jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }),
"keenable": buildKeenableTool,
"pubmed": noConfig("pubmed", func() einotool.BaseTool { return NewPubMedTool() }),
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
"retrieval": noConfig("retrieval", func() einotool.BaseTool { return NewRetrievalTool() }),
@@ -112,6 +113,22 @@ func buildExeSQLTool(params map[string]any) (einotool.BaseTool, error) {
return NewExeSQLTool(conn), nil
}
func buildKeenableTool(params map[string]any) (einotool.BaseTool, error) {
if len(params) == 0 {
return NewKeenableTool(), nil
}
for key := range params {
if key != "api_key" {
return nil, fmt.Errorf("agent tool: tool %q only accepts node-level param api_key", "keenable")
}
}
apiKey, ok := params["api_key"].(string)
if !ok || strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("agent tool: tool %q requires non-empty string node-level param api_key", "keenable")
}
return NewKeenableToolWithAPIKey(nil, apiKey), nil
}
func decodeExeSQLConnParams(params map[string]any) (exesqlConnParams, error) {
if len(params) == 0 {
return exesqlConnParams{}, fmt.Errorf(

View File

@@ -49,12 +49,12 @@ func TestBuildAll_AllRegisteredTools(t *testing.T) {
}
params := map[string]map[string]any{
"execute_sql": {
"db_type": "mysql",
"host": "127.0.0.1",
"port": 3306,
"database": "demo",
"username": "u",
"password": "p",
"db_type": "mysql",
"host": "127.0.0.1",
"port": 3306,
"database": "demo",
"username": "u",
"password": "p",
"max_records": 10,
},
}
@@ -77,6 +77,18 @@ func TestBuildAll_ExeSQLRequiresNodeParams(t *testing.T) {
}
}
func TestBuildAll_KeenableRejectsEmptyNodeAPIKey(t *testing.T) {
_, err := BuildAll([]string{"keenable"}, map[string]map[string]any{
"keenable": {"api_key": ""},
})
if err == nil {
t.Fatal("expected keenable config error")
}
if !strings.Contains(err.Error(), "requires non-empty string node-level param api_key") {
t.Fatalf("err = %q, want keenable api_key validation error", err.Error())
}
}
// TestToolRegistry_SchemasAreComplete sweeps every name the public
// registry advertises (including the execute_sql/exesql and
// retrieval/search_my_dateset alias pairs), builds the tool, and
@@ -95,7 +107,7 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
names := []string{
"akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo",
"email", "execute_sql", "exesql", "github", "google",
"google_scholar", "jin10", "pubmed", "qweather", "retrieval",
"google_scholar", "jin10", "keenable", "pubmed", "qweather", "retrieval",
"search_my_dateset", "searxng", "tavily", "tushare", "wencai",
"wikipedia", "yahoo_finance",
}
@@ -118,6 +130,9 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
"password": "p",
"max_records": 10,
},
"keenable": {
"api_key": "key-xyz",
},
}
tools, err := BuildAll(names, params)
if err != nil {
@@ -150,9 +165,9 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
// search_my_dateset. A bug here would mean an alias was
// accidentally pointed at a different tool.
canonicalByAlias := map[string]string{
"execute_sql": "execute_sql",
"exesql": "execute_sql",
"retrieval": "search_my_dateset",
"execute_sql": "execute_sql",
"exesql": "execute_sql",
"retrieval": "search_my_dateset",
"search_my_dateset": "search_my_dateset",
}
for _, name := range names {

View File

@@ -25,8 +25,10 @@ import (
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
"ragflow/internal/agent/runtime"
"ragflow/internal/common"
)
// ErrGraphRAGNotSupported is returned by the Retrieval tool when
@@ -134,6 +136,12 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string
return "", fmt.Errorf("retrieval: parse arguments: %w", err)
}
}
common.Debug("agent retrieval tool: parsed arguments",
zap.String("query", args.Query),
zap.Strings("dataset_ids", args.DatasetIDs),
zap.Int("top_n", args.TopN),
zap.Bool("use_kg", args.UseKG),
)
if args.UseKG {
// Plan + §9 Q3: GraphRAG is out of scope for the Go
@@ -166,6 +174,9 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string
Error: err.Error(),
}), err
}
common.Debug("agent retrieval tool: search result",
zap.Int("chunks_count", len(chunks)),
)
// Map the chunks into the result envelope. The retrievalResult
// type carries the eino-tool envelope shape (chunkPayload, not
// RetrievalChunk), so we translate.

View File

@@ -62,6 +62,7 @@ import (
"ragflow/internal/dao"
"ragflow/internal/engine"
"ragflow/internal/entity"
"ragflow/internal/service/nlp"
)
@@ -71,13 +72,21 @@ import (
// beyond its docEngine + documentDAO handles, both of which the
// nlp package treats as concurrent-safe.
type NLPRetrievalAdapter struct {
svc *nlp.RetrievalService
svc *nlp.RetrievalService
kbDAO knowledgebaseLookup
}
type knowledgebaseLookup interface {
GetByIDs(ids []string) ([]*entity.Knowledgebase, error)
}
// NewNLPRetrievalAdapter wraps an already-constructed
// *nlp.RetrievalService.
func NewNLPRetrievalAdapter(svc *nlp.RetrievalService) *NLPRetrievalAdapter {
return &NLPRetrievalAdapter{svc: svc}
return &NLPRetrievalAdapter{
svc: svc,
kbDAO: dao.NewKnowledgebaseDAO(),
}
}
// NewNLPRetrievalAdapterFromDeps is the convenience constructor
@@ -88,7 +97,10 @@ func NewNLPRetrievalAdapter(svc *nlp.RetrievalService) *NLPRetrievalAdapter {
// matches chat_session.go's newChatSessionServiceWithRetrieval
// call site.
func NewNLPRetrievalAdapterFromDeps(docEngine engine.DocEngine, documentDAO *dao.DocumentDAO) *NLPRetrievalAdapter {
return &NLPRetrievalAdapter{svc: nlp.NewRetrievalService(docEngine, documentDAO)}
return &NLPRetrievalAdapter{
svc: nlp.NewRetrievalService(docEngine, documentDAO),
kbDAO: dao.NewKnowledgebaseDAO(),
}
}
// Search implements RetrievalService. The translation rules live
@@ -120,6 +132,7 @@ func (a *NLPRetrievalAdapter) Search(ctx context.Context, req RetrievalRequest)
// headroom — matches the chat_session.go call pattern).
nlpReq := &nlp.RetrievalRequest{
Question: req.Query,
TenantIDs: a.resolveTenantIDs(req),
KbIDs: append([]string(nil), req.DatasetIDs...),
Page: 1,
PageSize: topN,
@@ -148,6 +161,24 @@ func (a *NLPRetrievalAdapter) Search(ctx context.Context, req RetrievalRequest)
return out, nil
}
func (a *NLPRetrievalAdapter) resolveTenantIDs(req RetrievalRequest) []string {
seen := map[string]struct{}{}
tenantIDs := make([]string, 0, 1)
appendTenantID := func(tenantID string) {
if tenantID == "" {
return
}
if _, ok := seen[tenantID]; ok {
return
}
seen[tenantID] = struct{}{}
tenantIDs = append(tenantIDs, tenantID)
}
appendTenantID(req.TenantID)
return tenantIDs
}
// translateChunk converts one nlp chunk map into a RetrievalChunk.
// Tolerates missing fields (returns zero values) and wrong types
// (returns zero values) so a single bad chunk from the doc engine

View File

@@ -207,3 +207,18 @@ func TestNewNLPRetrievalAdapter_NilService(t *testing.T) {
t.Errorf("err = %v, want ErrRetrievalServiceMissing", err)
}
}
func TestNLPRetrievalAdapter_ResolveTenantIDsStaysWithinRequestTenant(t *testing.T) {
a := &NLPRetrievalAdapter{}
got := a.resolveTenantIDs(RetrievalRequest{
TenantID: "tenant-a",
DatasetIDs: []string{"kb-1", "kb-2", "kb-missing"},
})
if len(got) != 1 {
t.Fatalf("tenantIDs len=%d want 1, got=%v", len(got), got)
}
if got[0] != "tenant-a" {
t.Fatalf("tenantIDs=%v want [tenant-a]", got)
}
}

View File

@@ -24,7 +24,7 @@
// a normal workflow node. See the .claude/plans/eino-workflow-loop.md
// plan for the design rationale.
//
// Foundation for the canvas Loop component
// # Foundation for the canvas Loop component
//
// AddLoopNode is also the runtime driver for the RAGFlow agent canvas's
// "Loop" component (internal/agent/component/loop.go). The canvas engine
@@ -141,11 +141,11 @@ var (
type LoopOption func(*loopOptions)
type loopOptions struct {
maxIterations int
compileOpts []compose.GraphCompileOption
runOpts []compose.Option
streamMode LoopStreamMode
checkpointBuilder func(nodeKey string, iteration int) string
maxIterations int
compileOpts []compose.GraphCompileOption
runOpts []compose.Option
streamMode LoopStreamMode
checkpointBuilder func(nodeKey string, iteration int) string
enableSubCheckpoint bool
}
@@ -237,8 +237,8 @@ func defaultCheckpointBuilder(nodeKey string, iteration int) string {
func getLoopOptions(opts []LoopOption) *loopOptions {
o := &loopOptions{
streamMode: LoopStreamFinalOnly,
checkpointBuilder: defaultCheckpointBuilder,
streamMode: LoopStreamFinalOnly,
checkpointBuilder: defaultCheckpointBuilder,
enableSubCheckpoint: true,
}
for _, opt := range opts {
@@ -260,12 +260,12 @@ func getLoopOptions(opts []LoopOption) *loopOptions {
// by the loop itself) sidesteps the need for callers to register
// generic types with the schema package — see plan §"Type shape".
type loopInterruptState struct {
Iteration int `json:"iteration"`
CurrentInput []byte `json:"current_input"`
StreamMode LoopStreamMode `json:"stream_mode"`
SubCheckpointID string `json:"sub_checkpoint_id"`
SubCheckpoints map[string][]byte `json:"sub_checkpoints,omitempty"`
ReplayChunks [][]byte `json:"replay_chunks,omitempty"`
Iteration int `json:"iteration"`
CurrentInput []byte `json:"current_input"`
StreamMode LoopStreamMode `json:"stream_mode"`
SubCheckpointID string `json:"sub_checkpoint_id"`
SubCheckpoints map[string][]byte `json:"sub_checkpoints,omitempty"`
ReplayChunks [][]byte `json:"replay_chunks,omitempty"`
}
// AddLoopNode appends a loop node to the outer workflow `wf`. The
@@ -375,15 +375,15 @@ func loadLoopSnapshot[T any](ctx context.Context, defaultMode LoopStreamMode) (l
if streamMode == "" {
streamMode = defaultMode
}
return loopSnapshot{
startIteration: st.Iteration,
current: st.CurrentInput,
streamMode: streamMode,
subCheckID: st.SubCheckpointID,
subCheckpoints: cloneCheckpointMap(st.SubCheckpoints),
replayChunks: cloneByteSlices(st.ReplayChunks),
}, nil
}
return loopSnapshot{
startIteration: st.Iteration,
current: st.CurrentInput,
streamMode: streamMode,
subCheckID: st.SubCheckpointID,
subCheckpoints: cloneCheckpointMap(st.SubCheckpoints),
replayChunks: cloneByteSlices(st.ReplayChunks),
}, nil
}
// encodeState marshals a loop snapshot to the persisted form.
func encodeState(s loopSnapshot) ([]byte, error) {

View File

@@ -313,9 +313,9 @@ func TestOptions_CompileFailureIsolated(t *testing.T) {
func TestOptions_SentinelErrorsExist(t *testing.T) {
sentinels := map[string]error{
"ErrLoopMaxIterationsExceeded": ErrLoopMaxIterationsExceeded,
"ErrLoopSubGraphInterrupted": ErrLoopSubGraphInterrupted,
"ErrLoopResumeStateInvalid": ErrLoopResumeStateInvalid,
"ErrLoopQuitConditionFailed": ErrLoopQuitConditionFailed,
"ErrLoopSubGraphInterrupted": ErrLoopSubGraphInterrupted,
"ErrLoopResumeStateInvalid": ErrLoopResumeStateInvalid,
"ErrLoopQuitConditionFailed": ErrLoopQuitConditionFailed,
}
for name, e := range sentinels {
if e == nil {