diff --git a/README.md b/README.md index e6c02d5..bf83b2f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Also available in [TypeScript](https://github.com/codeany-ai/open-agent-sdk-type - **Agent Loop** — Streaming agentic loop with tool execution, multi-turn conversations, and cost tracking - **Multi-Provider** — Native support for both Anthropic and OpenAI-compatible APIs (auto-detected) -- **32 Built-in Tools** — Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Agent (subagents), SendMessage, Tasks, Todo, Config, Cron, PlanMode, Worktree, LSP, NotebookEdit, MCP Resources, and more +- **Built-in Tools** — Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, ExaSearch, Agent (subagents), SendMessage, Tasks, Todo, Config, Cron, PlanMode, Worktree, LSP, NotebookEdit, MCP Resources, and more - **MCP Support** — Connect to MCP servers via stdio, HTTP, SSE transports, plus in-process SDK server - **Permission System** — Configurable tool approval with allow/deny rules, runtime mode changes, filesystem path validation, and directory allowlisting - **Hook System** — 11 hook events: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, SubagentStop, SubagentStart, PreCompact, Notification, PermissionRequest, PostSampling @@ -383,6 +383,7 @@ Environment variables: | `CODEANY_CUSTOM_HEADERS` | Custom headers (comma-separated `key:value`) | | `API_TIMEOUT_MS` | API request timeout in ms | | `HTTPS_PROXY` / `HTTP_PROXY` | Proxy URL | +| `EXA_API_KEY` | API key for the `ExaSearch` tool (optional) | Also supports `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` for compatibility. diff --git a/tools/exasearch.go b/tools/exasearch.go new file mode 100644 index 0000000..d3b8cc0 --- /dev/null +++ b/tools/exasearch.go @@ -0,0 +1,339 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/codeany-ai/open-agent-sdk-go/types" +) + +const ( + exaDefaultEndpoint = "https://api.exa.ai/search" + exaDefaultTimeout = 30 * time.Second + exaIntegrationID = "open-agent-sdk-go" +) + +// ExaSearchTool performs web searches using the Exa AI-powered search API +// (https://exa.ai). Requires the EXA_API_KEY environment variable to be set +// when the tool is invoked. +type ExaSearchTool struct { + // APIKey is the Exa API key. If empty, the value of EXA_API_KEY at call + // time is used. + APIKey string + // HTTPClient is the HTTP client used for requests. Defaults to a client + // with a 30s timeout. + HTTPClient *http.Client + // Endpoint overrides the Exa search endpoint. Defaults to the public API. + Endpoint string +} + +// NewExaSearchTool creates a new Exa search tool. The API key is read from +// EXA_API_KEY lazily on each call, so tests and config flows can set the +// variable after construction. +func NewExaSearchTool() *ExaSearchTool { + return &ExaSearchTool{} +} + +func (t *ExaSearchTool) Name() string { return "ExaSearch" } + +func (t *ExaSearchTool) Description() string { + return `Performs a web search using Exa (https://exa.ai) and returns results. + +Exa is an AI-powered search engine that understands natural language queries +and returns high-quality web pages, research papers, company profiles, news +articles, and more. Unlike a keyword search engine, Exa supports neural +retrieval, category filtering, domain filtering, published-date ranges, and +rich content modes (text, highlights, summaries). + +Use this when you need current, high-quality information from the web. +Requires the EXA_API_KEY environment variable to be set.` +} + +func (t *ExaSearchTool) InputSchema() types.ToolInputSchema { + return types.ToolInputSchema{ + Type: "object", + Properties: map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "The search query.", + }, + "num_results": map[string]interface{}{ + "type": "number", + "description": "Maximum number of results (default 5, max 100).", + }, + "type": map[string]interface{}{ + "type": "string", + "description": "Search type: 'auto' (default), 'neural', 'fast', 'deep-lite', 'deep', 'deep-reasoning', or 'instant'.", + }, + "category": map[string]interface{}{ + "type": "string", + "description": "Category filter: 'company', 'research paper', 'news', 'personal site', 'financial report', or 'people'.", + }, + "include_domains": map[string]interface{}{ + "type": "array", + "description": "Only include results from these domains.", + "items": map[string]interface{}{"type": "string"}, + }, + "exclude_domains": map[string]interface{}{ + "type": "array", + "description": "Exclude results from these domains.", + "items": map[string]interface{}{"type": "string"}, + }, + "start_published_date": map[string]interface{}{ + "type": "string", + "description": "Only include results published on or after this ISO 8601 date.", + }, + "end_published_date": map[string]interface{}{ + "type": "string", + "description": "Only include results published on or before this ISO 8601 date.", + }, + "user_location": map[string]interface{}{ + "type": "string", + "description": "Two-letter ISO country code for localized results.", + }, + "include_text": map[string]interface{}{ + "type": "boolean", + "description": "If true, return full page text for each result (default false).", + }, + "include_highlights": map[string]interface{}{ + "type": "boolean", + "description": "If true, return AI-generated highlights for each result (default true).", + }, + "include_summary": map[string]interface{}{ + "type": "boolean", + "description": "If true, return an AI-generated summary for each result (default false).", + }, + }, + Required: []string{"query"}, + } +} + +func (t *ExaSearchTool) IsConcurrencySafe(_ map[string]interface{}) bool { return true } +func (t *ExaSearchTool) IsReadOnly(_ map[string]interface{}) bool { return true } + +// exaContents mirrors the Exa /search `contents` field. Each content mode +// (text, highlights, summary) is independent and may be requested together. +type exaContents struct { + Text bool `json:"text,omitempty"` + Highlights bool `json:"highlights,omitempty"` + Summary *exaSummaryOpts `json:"summary,omitempty"` +} + +type exaSummaryOpts struct{} + +// exaRequest is the POST /search request body. +type exaRequest struct { + Query string `json:"query"` + Type string `json:"type,omitempty"` + NumResults int `json:"numResults,omitempty"` + Category string `json:"category,omitempty"` + IncludeDomains []string `json:"includeDomains,omitempty"` + ExcludeDomains []string `json:"excludeDomains,omitempty"` + StartPublishedDate string `json:"startPublishedDate,omitempty"` + EndPublishedDate string `json:"endPublishedDate,omitempty"` + UserLocation string `json:"userLocation,omitempty"` + Contents *exaContents `json:"contents,omitempty"` +} + +// ExaResult is a single search result returned by the Exa API. +type ExaResult struct { + Title string `json:"title"` + URL string `json:"url"` + ID string `json:"id"` + Score float64 `json:"score"` + PublishedDate string `json:"publishedDate,omitempty"` + Author string `json:"author,omitempty"` + Text string `json:"text,omitempty"` + Summary string `json:"summary,omitempty"` + Highlights []string `json:"highlights,omitempty"` +} + +// ExaResponse is the POST /search response body. +type ExaResponse struct { + Results []ExaResult `json:"results"` + AutopromptString string `json:"autopromptString,omitempty"` +} + +func (t *ExaSearchTool) Call(ctx context.Context, input map[string]interface{}, _ *types.ToolUseContext) (*types.ToolResult, error) { + apiKey := t.APIKey + if apiKey == "" { + apiKey = os.Getenv("EXA_API_KEY") + } + if apiKey == "" { + return &types.ToolResult{ + IsError: true, + Error: "EXA_API_KEY environment variable is not set. Get a key at https://exa.ai/", + }, nil + } + + query, _ := input["query"].(string) + if query == "" { + return &types.ToolResult{IsError: true, Error: "query is required"}, nil + } + + req := exaRequest{ + Query: query, + Type: "auto", + NumResults: 5, + } + if v, ok := input["num_results"].(float64); ok && v > 0 { + req.NumResults = int(v) + } + if v, ok := input["type"].(string); ok && v != "" { + req.Type = v + } + if v, ok := input["category"].(string); ok && v != "" { + req.Category = v + } + if v, ok := input["start_published_date"].(string); ok { + req.StartPublishedDate = v + } + if v, ok := input["end_published_date"].(string); ok { + req.EndPublishedDate = v + } + if v, ok := input["user_location"].(string); ok { + req.UserLocation = v + } + req.IncludeDomains = toStringSlice(input["include_domains"]) + req.ExcludeDomains = toStringSlice(input["exclude_domains"]) + + // Default to highlights on; caller can opt into full text or summary and + // can turn highlights off. + contents := &exaContents{Highlights: true} + if v, ok := input["include_highlights"].(bool); ok { + contents.Highlights = v + } + if v, ok := input["include_text"].(bool); ok { + contents.Text = v + } + if v, ok := input["include_summary"].(bool); ok && v { + contents.Summary = &exaSummaryOpts{} + } + req.Contents = contents + + body, err := json.Marshal(req) + if err != nil { + return &types.ToolResult{IsError: true, Error: fmt.Sprintf("marshal request: %v", err)}, nil + } + + endpoint := t.Endpoint + if endpoint == "" { + endpoint = exaDefaultEndpoint + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return &types.ToolResult{IsError: true, Error: fmt.Sprintf("build request: %v", err)}, nil + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("x-api-key", apiKey) + httpReq.Header.Set("x-exa-integration", exaIntegrationID) + + client := t.HTTPClient + if client == nil { + client = &http.Client{Timeout: exaDefaultTimeout} + } + + resp, err := client.Do(httpReq) + if err != nil { + return &types.ToolResult{IsError: true, Error: fmt.Sprintf("Exa request failed: %v", err)}, nil + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return &types.ToolResult{IsError: true, Error: fmt.Sprintf("read Exa response: %v", err)}, nil + } + + if resp.StatusCode >= 400 { + return &types.ToolResult{ + IsError: true, + Error: fmt.Sprintf("Exa API error %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))), + }, nil + } + + var parsed ExaResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + return &types.ToolResult{IsError: true, Error: fmt.Sprintf("parse Exa response: %v", err)}, nil + } + + text := formatExaResults(parsed.Results) + if text == "" { + text = "No results found." + } + + return &types.ToolResult{ + Data: map[string]interface{}{ + "numResults": len(parsed.Results), + "autopromptString": parsed.AutopromptString, + }, + Content: []types.ContentBlock{{ + Type: types.ContentBlockText, + Text: text, + }}, + }, nil +} + +// exaSnippet returns the best available short content for a result. The API +// may return any combination of highlights, summary, and text, so we cascade +// through them rather than assuming one is present. +func exaSnippet(r ExaResult) string { + if len(r.Highlights) > 0 { + parts := make([]string, 0, len(r.Highlights)) + for _, h := range r.Highlights { + if s := strings.TrimSpace(h); s != "" { + parts = append(parts, s) + } + } + if len(parts) > 0 { + return strings.Join(parts, " … ") + } + } + if s := strings.TrimSpace(r.Summary); s != "" { + return s + } + if s := strings.TrimSpace(r.Text); s != "" { + if len(s) > 300 { + return s[:300] + "…" + } + return s + } + return "" +} + +func formatExaResults(results []ExaResult) string { + var b strings.Builder + for i, r := range results { + fmt.Fprintf(&b, "%d. **%s**\n %s\n", i+1, r.Title, r.URL) + if r.PublishedDate != "" { + fmt.Fprintf(&b, " Published: %s\n", r.PublishedDate) + } + if snippet := exaSnippet(r); snippet != "" { + fmt.Fprintf(&b, " %s\n", snippet) + } + b.WriteString("\n") + } + return b.String() +} + +func toStringSlice(v interface{}) []string { + arr, ok := v.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out +} diff --git a/tools/exasearch_test.go b/tools/exasearch_test.go new file mode 100644 index 0000000..0cb8d95 --- /dev/null +++ b/tools/exasearch_test.go @@ -0,0 +1,214 @@ +package tools + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestExaSearch_MissingAPIKey(t *testing.T) { + t.Setenv("EXA_API_KEY", "") + tool := NewExaSearchTool() + + r, err := tool.Call(context.Background(), map[string]interface{}{ + "query": "test", + }, testToolCtx(t)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !r.IsError { + t.Fatal("expected IsError when EXA_API_KEY is unset") + } + if !strings.Contains(r.Error, "EXA_API_KEY") { + t.Errorf("expected error mentioning EXA_API_KEY, got: %s", r.Error) + } +} + +func TestExaSearch_MissingQuery(t *testing.T) { + tool := &ExaSearchTool{APIKey: "test-key"} + r, err := tool.Call(context.Background(), map[string]interface{}{}, testToolCtx(t)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !r.IsError || !strings.Contains(r.Error, "query is required") { + t.Errorf("expected 'query is required' error, got: %+v", r) + } +} + +func TestExaSearch_SuccessfulResponse(t *testing.T) { + var gotBody exaRequest + var gotAuthHeader, gotIntegration string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthHeader = r.Header.Get("x-api-key") + gotIntegration = r.Header.Get("x-exa-integration") + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotBody) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "results": [ + { + "title": "Result A", + "url": "https://a.example.com", + "id": "a", + "score": 0.9, + "publishedDate": "2026-01-02T00:00:00Z", + "highlights": ["highlight one", "highlight two"] + }, + { + "title": "Result B", + "url": "https://b.example.com", + "id": "b", + "score": 0.8, + "summary": "B summary" + } + ], + "autopromptString": "refined: test query" + }`)) + })) + defer server.Close() + + tool := &ExaSearchTool{ + APIKey: "secret-key", + Endpoint: server.URL, + } + + r, err := tool.Call(context.Background(), map[string]interface{}{ + "query": "test query", + "num_results": float64(3), + "type": "neural", + "category": "news", + "include_domains": []interface{}{"example.com"}, + "include_summary": true, + "include_highlights": true, + }, testToolCtx(t)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.IsError { + t.Fatalf("unexpected tool error: %s", r.Error) + } + + if gotAuthHeader != "secret-key" { + t.Errorf("expected x-api-key=secret-key, got %q", gotAuthHeader) + } + if gotIntegration != "open-agent-sdk-go" { + t.Errorf("expected x-exa-integration=open-agent-sdk-go, got %q", gotIntegration) + } + + if gotBody.Query != "test query" { + t.Errorf("query not passed through: %q", gotBody.Query) + } + if gotBody.NumResults != 3 { + t.Errorf("num_results not passed through: %d", gotBody.NumResults) + } + if gotBody.Type != "neural" { + t.Errorf("type not passed through: %q", gotBody.Type) + } + if gotBody.Category != "news" { + t.Errorf("category not passed through: %q", gotBody.Category) + } + if len(gotBody.IncludeDomains) != 1 || gotBody.IncludeDomains[0] != "example.com" { + t.Errorf("include_domains not passed through: %+v", gotBody.IncludeDomains) + } + if gotBody.Contents == nil || !gotBody.Contents.Highlights || gotBody.Contents.Summary == nil { + t.Errorf("expected contents.highlights=true and contents.summary set: %+v", gotBody.Contents) + } + + data, _ := r.Data.(map[string]interface{}) + if data["numResults"].(int) != 2 { + t.Errorf("expected numResults=2, got %v", data["numResults"]) + } + if data["autopromptString"].(string) != "refined: test query" { + t.Errorf("autoprompt missing: %v", data["autopromptString"]) + } + + text := r.Content[0].Text + if !strings.Contains(text, "Result A") || !strings.Contains(text, "https://a.example.com") { + t.Errorf("formatted result missing title/url: %s", text) + } + if !strings.Contains(text, "highlight one") { + t.Errorf("expected highlight text, got: %s", text) + } + if !strings.Contains(text, "B summary") { + t.Errorf("expected summary fallback for result B, got: %s", text) + } +} + +func TestExaSearch_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid api key"}`)) + })) + defer server.Close() + + tool := &ExaSearchTool{APIKey: "bad-key", Endpoint: server.URL} + r, err := tool.Call(context.Background(), map[string]interface{}{"query": "x"}, testToolCtx(t)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !r.IsError { + t.Fatal("expected IsError for 401 response") + } + if !strings.Contains(r.Error, "401") { + t.Errorf("expected status code in error, got: %s", r.Error) + } +} + +func TestExaSnippet_Fallbacks(t *testing.T) { + cases := []struct { + name string + result ExaResult + want string + }{ + { + name: "highlights preferred over summary and text", + result: ExaResult{Highlights: []string{"h1", "h2"}, Summary: "summary", Text: "text"}, + want: "h1 … h2", + }, + { + name: "summary used when highlights missing", + result: ExaResult{Summary: "just summary", Text: "ignored"}, + want: "just summary", + }, + { + name: "text used when highlights and summary missing", + result: ExaResult{Text: "plain text"}, + want: "plain text", + }, + { + name: "text truncated beyond 300 chars", + result: ExaResult{Text: strings.Repeat("a", 400)}, + want: strings.Repeat("a", 300) + "…", + }, + { + name: "empty when nothing available", + result: ExaResult{}, + want: "", + }, + { + name: "empty highlight strings skipped", + result: ExaResult{Highlights: []string{" ", ""}, Summary: "fallback"}, + want: "fallback", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := exaSnippet(tc.result); got != tc.want { + t.Errorf("exaSnippet = %q, want %q", got, tc.want) + } + }) + } +} + +func TestExaSearch_RegisteredInDefaultRegistry(t *testing.T) { + reg := DefaultRegistry() + if reg.Get("ExaSearch") == nil { + t.Fatal("ExaSearch tool not registered in DefaultRegistry") + } +} diff --git a/tools/registry.go b/tools/registry.go index 0e4691a..83d5153 100644 --- a/tools/registry.go +++ b/tools/registry.go @@ -84,6 +84,7 @@ func GetAllBaseTools() []types.Tool { NewGrepTool(), NewWebFetchTool(), NewWebSearchTool(), + NewExaSearchTool(), &TaskCreateTool{Store: store}, &TaskGetTool{Store: store}, &TaskListTool{Store: store},