diff --git a/internal/agent/tool/tavily.go b/internal/agent/tool/tavily.go index aab4a49a3f..f85e825828 100644 --- a/internal/agent/tool/tavily.go +++ b/internal/agent/tool/tavily.go @@ -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 } diff --git a/internal/agent/tool/tavily_test.go b/internal/agent/tool/tavily_test.go index 1113f56b42..7f3f96c6f1 100644 --- a/internal/agent/tool/tavily_test.go +++ b/internal/agent/tool/tavily_test.go @@ -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) } }