fix(go-agent): return configured retrieval empty responses (#17484)

## Summary

- Return the configured `empty_response` when retrieval has no query or
no chunks.
- Preserve `formalized_content` for downstream Message nodes.

## Testing

- `bash build.sh --go`
- `ok    ragflow/internal/agent/tool`
- `ok    ragflow/internal/agent/component`
This commit is contained in:
Hz_
2026-07-28 19:17:33 +08:00
committed by GitHub
parent 823e3dd871
commit 55a5254045
5 changed files with 133 additions and 15 deletions

View File

@@ -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"])
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}