fix(agent): return tavily tool errors as result instead of crashing the ReAct agent (#17274)

This commit is contained in:
euvre
2026-07-24 10:57:18 +08:00
committed by GitHub
parent e66d617f4f
commit 80d61ac8e2
2 changed files with 51 additions and 39 deletions

View File

@@ -282,11 +282,17 @@ var tavilyExtractEndpoint = "https://api.tavily.com/extract"
// InvokableRun performs the Tavily search. The api_key may come from the
// argument or the TAVILY_API_KEY env var.
//
// All recoverable errors (API failures, network errors, missing API key) are
// returned as a JSON envelope with an _ERROR field and a nil Go error. This
// lets the ReAct agent's eino framework feed the error back to the LLM instead
// of discarding the result and crashing the entire agent run. (eino's
// wrapToolCall discards the result string when error is non-nil; returning nil
// ensures the error context reaches the model.)
func (t *TavilyTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) {
var p tavilyParams
if err := json.Unmarshal([]byte(argsJSON), &p); err != nil {
return tavilyErrJSON(fmt.Errorf("tavily: parse arguments: %w", err)),
fmt.Errorf("tavily: parse arguments: %w", err)
return tavilyErrJSON(fmt.Errorf("tavily: parse arguments: %w", err)), nil
}
if p.Query == "" {
return tavilyJSON(tavilyEnvelope{Results: []map[string]any{}}), nil
@@ -300,10 +306,13 @@ func (t *TavilyTool) InvokableRun(ctx context.Context, argsJSON string, _ ...too
apiKey = t.envKey()
}
if apiKey == "" {
return tavilyErrJSON(fmt.Errorf("tavily: api_key is required (or set TAVILY_API_KEY)")),
fmt.Errorf("tavily: api_key is required (or set TAVILY_API_KEY)")
return tavilyErrJSON(fmt.Errorf("tavily: api_key is required (or set TAVILY_API_KEY)")), nil
}
// Match the Python implementation: always disable images and raw content.
p.IncludeImages = false
p.IncludeRawContent = false
body, _ := json.Marshal(tavilyRequestBody{
Query: p.Query,
MaxResults: p.MaxResults,
@@ -323,19 +332,17 @@ func (t *TavilyTool) InvokableRun(ctx context.Context, argsJSON string, _ ...too
map[string]string{"Authorization": "Bearer " + apiKey},
)
if err != nil {
return tavilyErrJSON(err), err
return tavilyErrJSON(err), nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return tavilyErrJSON(fmt.Errorf("tavily: upstream returned %d", resp.StatusCode)),
fmt.Errorf("tavily: upstream returned %d", resp.StatusCode)
return tavilyErrJSON(fmt.Errorf("tavily: upstream returned %d", resp.StatusCode)), nil
}
var raw tavilyResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return tavilyErrJSON(fmt.Errorf("tavily: decode response: %w", err)),
fmt.Errorf("tavily: decode response: %w", err)
return tavilyErrJSON(fmt.Errorf("tavily: decode response: %w", err)), nil
}
return tavilyJSON(tavilyEnvelope{Results: raw.Results}), nil
}
@@ -508,17 +515,19 @@ func truncateTavilyRunes(value string, limit int) string {
// InvokableRun performs the Tavily Extract request. The api_key may come from
// the argument or the TAVILY_API_KEY env var.
//
// All recoverable errors are returned as a JSON envelope with an _ERROR field
// and a nil Go error, so the ReAct agent can feed the error back to the LLM
// instead of discarding the result and crashing the agent run.
func (t *TavilyExtractTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) {
var runtimeParams tavilyExtractParams
if err := json.Unmarshal([]byte(argsJSON), &runtimeParams); err != nil {
return tavilyExtractErrJSON(fmt.Errorf("tavily_extract: parse arguments: %w", err)),
fmt.Errorf("tavily_extract: parse arguments: %w", err)
return tavilyExtractErrJSON(fmt.Errorf("tavily_extract: parse arguments: %w", err)), nil
}
p := mergeTavilyExtractParams(t.defaults, runtimeParams)
urls := normalizeTavilyURLs(p.URLs)
if len(urls) == 0 {
return tavilyExtractErrJSON(fmt.Errorf("urls is required")),
fmt.Errorf("tavily_extract: urls is required")
return tavilyExtractErrJSON(fmt.Errorf("urls is required")), nil
}
if p.ExtractDepth == "" {
p.ExtractDepth = "basic"
@@ -532,8 +541,7 @@ func (t *TavilyExtractTool) InvokableRun(ctx context.Context, argsJSON string, _
apiKey = t.envKey()
}
if apiKey == "" {
return tavilyExtractErrJSON(fmt.Errorf("tavily_extract: api_key is required (or set TAVILY_API_KEY)")),
fmt.Errorf("tavily_extract: api_key is required (or set TAVILY_API_KEY)")
return tavilyExtractErrJSON(fmt.Errorf("tavily_extract: api_key is required (or set TAVILY_API_KEY)")), nil
}
body, _ := json.Marshal(tavilyExtractRequestBody{
@@ -547,19 +555,17 @@ func (t *TavilyExtractTool) InvokableRun(ctx context.Context, argsJSON string, _
map[string]string{"Authorization": "Bearer " + apiKey},
)
if err != nil {
return tavilyExtractErrJSON(err), err
return tavilyExtractErrJSON(err), nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return tavilyExtractErrJSON(fmt.Errorf("tavily_extract: upstream returned %d", resp.StatusCode)),
fmt.Errorf("tavily_extract: upstream returned %d", resp.StatusCode)
return tavilyExtractErrJSON(fmt.Errorf("tavily_extract: upstream returned %d", resp.StatusCode)), nil
}
var raw tavilyExtractResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return tavilyExtractErrJSON(fmt.Errorf("tavily_extract: decode response: %w", err)),
fmt.Errorf("tavily_extract: decode response: %w", err)
return tavilyExtractErrJSON(fmt.Errorf("tavily_extract: decode response: %w", err)), nil
}
return tavilyExtractJSON(tavilyExtractEnvelope{Results: raw.Results}), nil
}

View File

@@ -79,8 +79,11 @@ func TestTavily_BuildRequest(t *testing.T) {
if gotBody["search_depth"] != "advanced" {
t.Errorf("body.search_depth = %v, want advanced", gotBody["search_depth"])
}
if gotBody["include_raw_content"] != true || gotBody["include_images"] != true {
t.Errorf("include flags = raw:%v images:%v, want true/true", gotBody["include_raw_content"], gotBody["include_images"])
// include_raw_content and include_images are always forced to false
// to match the Python implementation (avoids bloating the context
// with base64 image data).
if gotBody["include_raw_content"] != false || gotBody["include_images"] != false {
t.Errorf("include flags = raw:%v images:%v, want false/false (forced)", gotBody["include_raw_content"], gotBody["include_images"])
}
}
@@ -133,12 +136,12 @@ func TestTavily_RequiresAPIKey(t *testing.T) {
// envKey always returns "" so we know the failure is from the
// missing api_key, not from a stray process env var.
tool := NewTavilyToolWithEnvKey(NewHTTPHelper(), func() string { return "" })
_, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
if err == nil {
t.Fatal("expected error for missing api_key")
out, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
if err != nil {
t.Fatalf("InvokableRun should not return a Go error for missing api_key: %v", err)
}
if !strings.Contains(err.Error(), "api_key") {
t.Errorf("err = %v, want to mention api_key", err)
if !strings.Contains(out, "api_key") {
t.Errorf("output = %q, want to mention api_key", out)
}
}
@@ -263,19 +266,22 @@ func TestTavily_ExplicitFlagsOverrideNodeDefaults(t *testing.T) {
helper := NewHTTPHelper().WithClient(&http.Client{Transport: rewriteHostTransport(srv.URL)})
tavily := newTavilyTool(helper, func() string { return "" }, tavilyParams{
APIKey: "stored-key", IncludeRawContent: true, IncludeImages: true,
APIKey: "stored-key", IncludeAnswer: true,
})
if _, err := tavily.InvokableRun(context.Background(), `{"query":"ragflow"}`); err != nil {
t.Fatalf("InvokableRun(node defaults): %v", err)
}
if gotBody["include_raw_content"] != true || gotBody["include_images"] != true {
t.Fatalf("node default flags were not sent: %#v", gotBody)
}
if _, err := tavily.InvokableRun(context.Background(), `{"query":"ragflow","include_raw_content":false,"include_images":false}`); err != nil {
t.Fatalf("InvokableRun: %v", err)
if gotBody["include_answer"] != true {
t.Fatalf("node default include_answer was not sent: %#v", gotBody)
}
if gotBody["include_raw_content"] != false || gotBody["include_images"] != false {
t.Fatalf("explicit false flags were not preserved: %#v", gotBody)
t.Fatalf("include_raw_content/images should be forced false: %#v", gotBody)
}
if _, err := tavily.InvokableRun(context.Background(), `{"query":"ragflow","include_answer":false}`); err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotBody["include_answer"] != false {
t.Fatalf("explicit false include_answer was not preserved: %#v", gotBody)
}
}
@@ -426,12 +432,12 @@ func TestTavilyExtract_RequiresAPIKey(t *testing.T) {
t.Parallel()
tool := NewTavilyExtractToolWithEnvKey(NewHTTPHelper(), func() string { return "" })
_, err := tool.InvokableRun(context.Background(), `{"urls":["https://a.example/"]}`)
if err == nil {
t.Fatal("expected error for missing api_key")
out, err := tool.InvokableRun(context.Background(), `{"urls":["https://a.example/"]}`)
if err != nil {
t.Fatalf("InvokableRun should not return a Go error for missing api_key: %v", err)
}
if !strings.Contains(err.Error(), "api_key") {
t.Errorf("err = %v, want to mention api_key", err)
if !strings.Contains(out, "api_key") {
t.Errorf("output = %q, want to mention api_key", out)
}
}