diff --git a/internal/agent/component/retrieval_empty_response_test.go b/internal/agent/component/retrieval_empty_response_test.go new file mode 100644 index 0000000000..bd17623e22 --- /dev/null +++ b/internal/agent/component/retrieval_empty_response_test.go @@ -0,0 +1,52 @@ +// +// 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 component + +import ( + "context" + "testing" + + agenttool "ragflow/internal/agent/tool" + + "gorm.io/gorm" +) + +type emptyRetrievalService struct{} + +func (emptyRetrievalService) Search(context.Context, *gorm.DB, agenttool.RetrievalRequest) ([]agenttool.RetrievalChunk, error) { + return []agenttool.RetrievalChunk{}, nil +} + +func TestRetrievalComponent_PreservesConfiguredEmptyResponse(t *testing.T) { + previous := agenttool.GetRetrievalService() + agenttool.SetRetrievalService(emptyRetrievalService{}) + t.Cleanup(func() { agenttool.SetRetrievalService(previous) }) + + component, err := newRetrievalComponent(map[string]any{ + "empty_response": "No relevant content.", + }) + if err != nil { + t.Fatalf("newRetrievalComponent: %v", err) + } + output, err := component.Invoke(context.Background(), nil, map[string]any{"query": "love"}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + if output["formalized_content"] != "No relevant content." { + t.Fatalf("formalized_content = %#v", output["formalized_content"]) + } +} diff --git a/internal/agent/component/universe_a_wrappers.go b/internal/agent/component/universe_a_wrappers.go index 9413620450..0fae38a723 100644 --- a/internal/agent/component/universe_a_wrappers.go +++ b/internal/agent/component/universe_a_wrappers.go @@ -80,9 +80,7 @@ type retrievalParams struct { // "default everything". This matches Python's // component.retrieval.RetrievalParam.__init__ tolerance. func parseRetrievalParams(params map[string]any) retrievalParams { - out := retrievalParams{ - EmptyResponse: "Sorry, no relevant content was found in the knowledge base.", - } + out := retrievalParams{} if params == nil { return out } diff --git a/internal/agent/tool/registry.go b/internal/agent/tool/registry.go index 2130bcdf53..b64e209291 100644 --- a/internal/agent/tool/registry.go +++ b/internal/agent/tool/registry.go @@ -403,6 +403,13 @@ func buildRetrievalTool(params map[string]any) (einotool.BaseTool, error) { } defaults.KeywordsSimilarityWeight = &v } + if value, ok := params["empty_response"]; ok { + emptyResponse, ok := value.(string) + if !ok { + return nil, fmt.Errorf("agent tool: retrieval tool requires string node-level param empty_response") + } + defaults.EmptyResponse = emptyResponse + } return NewRetrievalToolWithDefaults(defaults), nil } diff --git a/internal/agent/tool/retrieval.go b/internal/agent/tool/retrieval.go index c50ea199ed..a24fde6db5 100644 --- a/internal/agent/tool/retrieval.go +++ b/internal/agent/tool/retrieval.go @@ -64,13 +64,14 @@ type retrievalArgs struct { KeywordsSimilarityWeight *float64 `json:"keywords_similarity_weight,omitempty"` UseKG bool `json:"use_kg,omitempty"` SimilarityThreshold float64 `json:"similarity_threshold,omitempty"` + EmptyResponse string `json:"empty_response,omitempty"` } // retrievalResult is the JSON shape returned to the model. The `_ERROR` // field matches the Python tool's output convention; downstream components // can pattern-match on it. type retrievalResult struct { - FormalizedContent string `json:"formalized_content,omitempty"` + FormalizedContent string `json:"formalized_content"` Chunks []chunkPayload `json:"chunks,omitempty"` Stub bool `json:"stub,omitempty"` Error string `json:"_ERROR,omitempty"` @@ -146,6 +147,9 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string zap.Float64p("keywords_similarity_weight", args.KeywordsSimilarityWeight), zap.Bool("use_kg", args.UseKG), ) + if args.Query == "" { + return stubJSONWithErr(retrievalResult{FormalizedContent: args.EmptyResponse}) + } if args.UseKG { // Plan + §9 Q3: GraphRAG is out of scope for the Go @@ -193,10 +197,11 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string Score: c.Score, }) } - out := retrievalResult{ - FormalizedContent: renderChunks(chunks, args.Query), - Chunks: payload, + formalizedContent := renderChunks(chunks, args.Query) + if len(chunks) == 0 { + formalizedContent = args.EmptyResponse } + out := retrievalResult{FormalizedContent: formalizedContent, Chunks: payload} // Record chunks into canvas state so the Agent's post-stream // citation grounding call can read them. The recording is // best-effort — when the canvas state is not @@ -230,6 +235,9 @@ func (r *RetrievalTool) mergeDefaults(args retrievalArgs) retrievalArgs { if args.SimilarityThreshold <= 0 { args.SimilarityThreshold = r.defaults.SimilarityThreshold } + if args.EmptyResponse == "" { + args.EmptyResponse = r.defaults.EmptyResponse + } args.UseKG = args.UseKG || r.defaults.UseKG return args } diff --git a/internal/agent/tool/retrieval_test.go b/internal/agent/tool/retrieval_test.go index 89f79f5e18..baac86a8c0 100644 --- a/internal/agent/tool/retrieval_test.go +++ b/internal/agent/tool/retrieval_test.go @@ -106,12 +106,16 @@ func TestRetrieval_EmptyArgsIsHandled(t *testing.T) { t.Parallel() rt := NewRetrievalTool() - // Empty arguments should still return a stub error (not panic) — the - // Python tool defaults to empty_response in this case. Without - // wiring, the Go side surfaces the service-missing error. - _, err := rt.InvokableRun(context.Background(), "") - if !errors.Is(err, ErrRetrievalServiceMissing) { - t.Fatalf("err = %v, want ErrRetrievalServiceMissing", err) + out, err := rt.InvokableRun(context.Background(), "") + if err != nil { + t.Fatalf("InvokableRun: %v", err) + } + var result retrievalResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if result.FormalizedContent != "" { + t.Fatalf("FormalizedContent = %q, want empty default", result.FormalizedContent) } } @@ -197,7 +201,7 @@ func TestRetrieval_UsesNodeParamsAsDefaults(t *testing.T) { } } -func TestRetrieval_IgnoresPythonOnlyNodeParams(t *testing.T) { +func TestRetrieval_AcceptsEmptyResponseNodeParam(t *testing.T) { t.Parallel() built, err := BuildByName("retrieval", map[string]any{ @@ -218,7 +222,56 @@ func TestRetrieval_IgnoresPythonOnlyNodeParams(t *testing.T) { t.Fatalf("BuildByName(retrieval) returned %T, want *RetrievalTool", built) } if rt.defaults.TopN != 0 || rt.defaults.TopK != 0 || rt.defaults.KeywordsSimilarityWeight != nil { - t.Fatalf("python-only params should not mutate retrieval defaults: %#v", rt.defaults) + t.Fatalf("unimplemented params should not mutate retrieval defaults: %#v", rt.defaults) + } + if rt.defaults.EmptyResponse != "empty" { + t.Fatalf("EmptyResponse = %q, want empty", rt.defaults.EmptyResponse) + } +} + +func TestRetrieval_UsesEmptyResponseForEmptyQuery(t *testing.T) { + t.Parallel() + + rt := NewRetrievalToolWithDefaults(retrievalArgs{EmptyResponse: "No query or result."}) + out, err := rt.InvokableRun(context.Background(), `{"query":""}`) + if err != nil { + t.Fatalf("InvokableRun: %v", err) + } + var result retrievalResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if result.FormalizedContent != "No query or result." { + t.Fatalf("FormalizedContent = %q", result.FormalizedContent) + } +} + +func TestRetrieval_OmitsUnsetEmptyResponseFromArguments(t *testing.T) { + arguments, err := json.Marshal(retrievalArgs{Query: "love"}) + if err != nil { + t.Fatalf("marshal arguments: %v", err) + } + if strings.Contains(string(arguments), `"empty_response"`) { + t.Fatalf("arguments unexpectedly include empty_response: %s", arguments) + } +} + +func TestRetrieval_UsesEmptyResponseWhenSearchHasNoChunks(t *testing.T) { + prev := GetRetrievalService() + SetRetrievalService(staticRetrievalService{}) + t.Cleanup(func() { SetRetrievalService(prev) }) + + rt := NewRetrievalToolWithDefaults(retrievalArgs{EmptyResponse: "No matching chunk."}) + out, err := rt.InvokableRun(context.Background(), `{"query":"love"}`) + if err != nil { + t.Fatalf("InvokableRun: %v", err) + } + var result retrievalResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if result.FormalizedContent != "No matching chunk." { + t.Fatalf("FormalizedContent = %q", result.FormalizedContent) } }