diff --git a/.changeset/warm-pandas-search.md b/.changeset/warm-pandas-search.md new file mode 100644 index 0000000000..c87db4517e --- /dev/null +++ b/.changeset/warm-pandas-search.md @@ -0,0 +1,7 @@ +--- +"@browserbasehq/stagehand-python": minor +"@browserbasehq/stagehand-go": minor +"@browserbasehq/stagehand": minor +--- + +Expose Browserbase Search and Fetch through the Stagehand browserbase facade in TypeScript, Python, and Go. diff --git a/README.md b/README.md index 3a059769f8..a515b5efe5 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,21 @@ const { See the [Python](./packages/sdk-python/README.md) and [Go](./packages/sdk-go/README.md) READMEs for equivalent examples. +The same `browserbase` facade also exposes Browserbase Search and Fetch without launching a browser: + +```typescript +const results = await browserbase.search({ + apiKey: BROWSERBASE_API_KEY, + query: "browser agent frameworks", + numResults: 5, +}); +const fetchResult = await browserbase.fetch({ + apiKey: BROWSERBASE_API_KEY, + url: results.results[0].url, + format: "markdown", +}); +``` + ## Documentation Visit [docs.stagehand.dev](https://docs.stagehand.dev) to view the full documentation. diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index d5aff66496..03c6c8b9ca 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -9,7 +9,7 @@ const root = import.meta.dirname; const outDir = path.join(root, "dist"); const artifactsDir = path.join(root, "artifacts"); const extensionArchivePath = path.join(artifactsDir, "stagehand-extension.zip"); -const zipModifiedAt = new Date(1980, 0, 1); +const deterministicZipModifiedAt = new Date(1980, 0, 1); function buildExtensionArtifacts() { return { @@ -99,7 +99,7 @@ async function readExtensionFiles(directory: string, relativeDirectory = ""): Pr } files[relativePath.split(path.sep).join("/")] = [ await readFile(path.join(directory, relativePath)), - { attrs: 0o644 << 16, mtime: zipModifiedAt, os: 3 }, + { attrs: 0o644 << 16, mtime: deterministicZipModifiedAt, os: 3 }, ]; } diff --git a/packages/sdk-go/README.md b/packages/sdk-go/README.md index 7b68ac819f..18ccae799c 100644 --- a/packages/sdk-go/README.md +++ b/packages/sdk-go/README.md @@ -144,6 +144,36 @@ func run(ctx context.Context) (err error) { } ``` +Browserbase Search and Fetch are also available without launching a browser: + +```go +numResults := 5 +results, err := stagehand.SearchBrowserbase(ctx, stagehand.BrowserbaseSearchOptions{ + APIKey: os.Getenv("BROWSERBASE_API_KEY"), + Query: "browser agent frameworks", + NumResults: &numResults, +}) +if err != nil { + return err +} +if len(results.Results) == 0 { + return fmt.Errorf("search returned no results") +} +fetched, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{ + APIKey: os.Getenv("BROWSERBASE_API_KEY"), + URL: results.Results[0].URL, + Format: stagehand.BrowserbaseFetchFormatMarkdown, +}) +if err != nil { + return err +} +content, ok := fetched.Content.AsString() +if !ok { + return fmt.Errorf("fetch returned non-string content") +} +fmt.Println(content) +``` + ## Navigation Navigation methods return the main-document response when the browser performs a network request: diff --git a/packages/sdk-go/browserbase_services.go b/packages/sdk-go/browserbase_services.go new file mode 100644 index 0000000000..143e6801a4 --- /dev/null +++ b/packages/sdk-go/browserbase_services.go @@ -0,0 +1,326 @@ +package stagehand + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "unicode/utf8" +) + +// BrowserbaseSearchOptions configures a Browserbase web search. +type BrowserbaseSearchOptions struct { + APIKey string + BaseURL string + Query string + NumResults *int +} + +// BrowserbaseSearchResult contains Browserbase web search results. +type BrowserbaseSearchResult struct { + Query string `json:"query"` + RequestID string `json:"requestId"` + Results []BrowserbaseSearchResultItem `json:"results"` +} + +// BrowserbaseSearchResultItem is one Browserbase web search result. +type BrowserbaseSearchResultItem struct { + ID string `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + Author *string `json:"author,omitempty"` + Favicon *string `json:"favicon,omitempty"` + Image *string `json:"image,omitempty"` + PublishedDate *string `json:"publishedDate,omitempty"` +} + +// BrowserbaseFetchFormat selects the content representation returned by Browserbase Fetch. +type BrowserbaseFetchFormat string + +const ( + BrowserbaseFetchFormatRaw BrowserbaseFetchFormat = "raw" + BrowserbaseFetchFormatJSON BrowserbaseFetchFormat = "json" + BrowserbaseFetchFormatMarkdown BrowserbaseFetchFormat = "markdown" +) + +// BrowserbaseFetchOptions configures a Browserbase Fetch request. +type BrowserbaseFetchOptions struct { + APIKey string + BaseURL string + URL string + AllowInsecureSSL *bool + AllowRedirects *bool + Format BrowserbaseFetchFormat + Proxies *bool + Schema map[string]any +} + +type browserbaseFetchContentKind uint8 + +const ( + browserbaseFetchContentUnset browserbaseFetchContentKind = iota + browserbaseFetchContentString + browserbaseFetchContentObject +) + +// BrowserbaseFetchContent is either string content for raw and markdown +// responses or an object for JSON responses. +type BrowserbaseFetchContent struct { + kind browserbaseFetchContentKind + text string + object map[string]any +} + +// AsString returns the string content variant, if present. +func (content BrowserbaseFetchContent) AsString() (string, bool) { + return content.text, content.kind == browserbaseFetchContentString +} + +// AsObject returns a copy of the object content variant, if present. +func (content BrowserbaseFetchContent) AsObject() (map[string]any, bool) { + if content.kind != browserbaseFetchContentObject { + return nil, false + } + result := make(map[string]any, len(content.object)) + for key, item := range content.object { + result[key] = item + } + return result, true +} + +// MarshalJSON implements json.Marshaler. +func (content BrowserbaseFetchContent) MarshalJSON() ([]byte, error) { + switch content.kind { + case browserbaseFetchContentString: + return json.Marshal(content.text) + case browserbaseFetchContentObject: + return json.Marshal(content.object) + default: + return nil, errors.New("stagehand.BrowserbaseFetchContent is unset") + } +} + +// UnmarshalJSON implements json.Unmarshaler. +func (content *BrowserbaseFetchContent) UnmarshalJSON(data []byte) error { + if content == nil { + return errors.New("stagehand.BrowserbaseFetchContent: UnmarshalJSON on nil pointer") + } + switch firstJSONByte(data) { + case '"': + var value string + if err := json.Unmarshal(data, &value); err != nil { + return fmt.Errorf("decode Browserbase fetch string content: %w", err) + } + *content = BrowserbaseFetchContent{kind: browserbaseFetchContentString, text: value} + return nil + case '{': + var value map[string]any + if err := json.Unmarshal(data, &value); err != nil { + return fmt.Errorf("decode Browserbase fetch object content: %w", err) + } + *content = BrowserbaseFetchContent{kind: browserbaseFetchContentObject, object: value} + return nil + default: + return errors.New("decode Browserbase fetch content: expected string or object") + } +} + +// BrowserbaseFetchResult contains content and response metadata returned by Browserbase Fetch. +type BrowserbaseFetchResult struct { + ID string `json:"id"` + Content BrowserbaseFetchContent `json:"content"` + ContentType string `json:"contentType"` + Encoding string `json:"encoding"` + Headers map[string]string `json:"headers"` + StatusCode int `json:"statusCode"` +} + +// SearchBrowserbase performs a Browserbase web search. +func SearchBrowserbase(ctx context.Context, options BrowserbaseSearchOptions) (BrowserbaseSearchResult, error) { + client, err := newBrowserbaseHTTPClient(options.APIKey, browserbaseHTTPClientOptions{ + baseURL: options.BaseURL, + }) + if err != nil { + return BrowserbaseSearchResult{}, err + } + response, err := sendBrowserbaseRequest[browserbaseSearchResponse]( + ctx, + client, + browserbaseSearchRequest{Query: options.Query, NumResults: options.NumResults}, + ) + if err != nil { + return BrowserbaseSearchResult{}, err + } + return response.result(), nil +} + +// FetchBrowserbase fetches a URL through Browserbase. +func FetchBrowserbase(ctx context.Context, options BrowserbaseFetchOptions) (BrowserbaseFetchResult, error) { + client, err := newBrowserbaseHTTPClient(options.APIKey, browserbaseHTTPClientOptions{ + baseURL: options.BaseURL, + }) + if err != nil { + return BrowserbaseFetchResult{}, err + } + var schema *map[string]any + if options.Schema != nil { + schema = &options.Schema + } + response, err := sendBrowserbaseRequest[browserbaseFetchResponse]( + ctx, + client, + browserbaseFetchRequest{ + URL: options.URL, AllowInsecureSSL: options.AllowInsecureSSL, + AllowRedirects: options.AllowRedirects, Format: options.Format, + Proxies: options.Proxies, Schema: schema, + }, + ) + if err != nil { + return BrowserbaseFetchResult{}, err + } + return response.result() +} + +type browserbaseSearchRequest struct { + Query string `json:"query"` + NumResults *int `json:"numResults,omitempty"` +} + +func (request browserbaseSearchRequest) encode() (browserbaseEncodedRequest, error) { + encoded := browserbaseEncodedRequest{ + method: http.MethodPost, path: "/v1/search", + } + if strings.TrimSpace(request.Query) == "" { + return encoded, errors.New("query is required") + } + if utf8.RuneCountInString(request.Query) > 200 { + return encoded, errors.New("query must be at most 200 characters") + } + if request.NumResults != nil && (*request.NumResults < 1 || *request.NumResults > 25) { + return encoded, errors.New("numResults must be between 1 and 25") + } + body, err := json.Marshal(request) + if err != nil { + return encoded, fmt.Errorf("encode search request: %w", err) + } + encoded.body = body + encoded.contentType = "application/json" + return encoded, nil +} + +type browserbaseSearchResponse struct { + Query *string `json:"query"` + RequestID *string `json:"requestId"` + Results *[]browserbaseSearchResponseItem `json:"results"` +} + +type browserbaseSearchResponseItem struct { + ID *string `json:"id"` + Title *string `json:"title"` + URL *string `json:"url"` + Author *string `json:"author,omitempty"` + Favicon *string `json:"favicon,omitempty"` + Image *string `json:"image,omitempty"` + PublishedDate *string `json:"publishedDate,omitempty"` +} + +func (response browserbaseSearchResponse) validate() error { + if err := requireBrowserbaseResponseFields(map[string]bool{ + "query": response.Query != nil, + "requestId": response.RequestID != nil, + "results": response.Results != nil, + }); err != nil { + return err + } + for index, result := range *response.Results { + if err := requireBrowserbaseResponseFields(map[string]bool{ + "id": result.ID != nil, "title": result.Title != nil, "url": result.URL != nil, + }); err != nil { + return fmt.Errorf("search result %d: %w", index, err) + } + } + return nil +} + +func (response browserbaseSearchResponse) result() BrowserbaseSearchResult { + results := make([]BrowserbaseSearchResultItem, len(*response.Results)) + for index, result := range *response.Results { + results[index] = BrowserbaseSearchResultItem{ + ID: *result.ID, Title: *result.Title, URL: *result.URL, + Author: result.Author, Favicon: result.Favicon, Image: result.Image, + PublishedDate: result.PublishedDate, + } + } + return BrowserbaseSearchResult{ + Query: *response.Query, RequestID: *response.RequestID, Results: results, + } +} + +type browserbaseFetchRequest struct { + URL string `json:"url"` + AllowInsecureSSL *bool `json:"allowInsecureSsl,omitempty"` + AllowRedirects *bool `json:"allowRedirects,omitempty"` + Format BrowserbaseFetchFormat `json:"format,omitempty"` + Proxies *bool `json:"proxies,omitempty"` + Schema *map[string]any `json:"schema,omitempty"` +} + +func (request browserbaseFetchRequest) encode() (browserbaseEncodedRequest, error) { + encoded := browserbaseEncodedRequest{ + method: http.MethodPost, path: "/v1/fetch", + } + if err := validateBrowserbaseURL("url", request.URL, "http", "https"); err != nil { + return encoded, err + } + switch request.Format { + case "", BrowserbaseFetchFormatRaw, BrowserbaseFetchFormatJSON, BrowserbaseFetchFormatMarkdown: + default: + return encoded, fmt.Errorf("invalid fetch format %q", request.Format) + } + if request.Schema != nil && request.Format != BrowserbaseFetchFormatJSON { + return encoded, errors.New(`schema is only valid when format is "json"`) + } + if request.Format == BrowserbaseFetchFormatJSON && request.Schema == nil { + return encoded, errors.New(`schema is required when format is "json"`) + } + body, err := json.Marshal(request) + if err != nil { + return encoded, fmt.Errorf("encode fetch request: %w", err) + } + encoded.body = body + encoded.contentType = "application/json" + return encoded, nil +} + +type browserbaseFetchResponse struct { + ID *string `json:"id"` + Content *json.RawMessage `json:"content"` + ContentType *string `json:"contentType"` + Encoding *string `json:"encoding"` + Headers *map[string]string `json:"headers"` + StatusCode *int `json:"statusCode"` +} + +func (response browserbaseFetchResponse) validate() error { + return requireBrowserbaseResponseFields(map[string]bool{ + "id": response.ID != nil, + "content": response.Content != nil, + "contentType": response.ContentType != nil, + "encoding": response.Encoding != nil, + "headers": response.Headers != nil, + "statusCode": response.StatusCode != nil, + }) +} + +func (response browserbaseFetchResponse) result() (BrowserbaseFetchResult, error) { + var content BrowserbaseFetchContent + if err := json.Unmarshal(*response.Content, &content); err != nil { + return BrowserbaseFetchResult{}, fmt.Errorf("decode Browserbase fetch content: %w", err) + } + return BrowserbaseFetchResult{ + ID: *response.ID, Content: content, ContentType: *response.ContentType, + Encoding: *response.Encoding, Headers: *response.Headers, StatusCode: *response.StatusCode, + }, nil +} diff --git a/packages/sdk-go/browserbase_services_test.go b/packages/sdk-go/browserbase_services_test.go new file mode 100644 index 0000000000..638551fe27 --- /dev/null +++ b/packages/sdk-go/browserbase_services_test.go @@ -0,0 +1,202 @@ +package stagehand + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func TestBrowserbaseSearchAndFetch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("X-BB-API-Key") != "bb_test" { + t.Errorf("X-BB-API-Key = %q", request.Header.Get("X-BB-API-Key")) + } + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + switch request.URL.Path { + case "/v1/search": + want := map[string]any{"query": "browser agents", "numResults": float64(5)} + if !reflect.DeepEqual(body, want) { + t.Errorf("search body = %#v, want %#v", body, want) + } + writeBrowserbaseTestJSON(writer, map[string]any{ + "query": "browser agents", + "requestId": "request_123", + "results": []map[string]any{{ + "id": "result_123", "title": "Stagehand", "url": "https://stagehand.dev", + }}, + }) + case "/v1/fetch": + want := map[string]any{"url": "https://stagehand.dev", "format": "markdown"} + if !reflect.DeepEqual(body, want) { + t.Errorf("fetch body = %#v, want %#v", body, want) + } + writeBrowserbaseTestJSON(writer, map[string]any{ + "id": "fetch_123", "content": "# Stagehand", "contentType": "text/markdown", + "encoding": "utf-8", "headers": map[string]string{"content-type": "text/html"}, + "statusCode": 200, + }) + default: + http.Error(writer, "unexpected endpoint", http.StatusNotFound) + } + })) + defer server.Close() + + numResults := 5 + search, err := SearchBrowserbase(context.Background(), BrowserbaseSearchOptions{ + APIKey: "bb_test", BaseURL: server.URL, Query: "browser agents", NumResults: &numResults, + }) + if err != nil { + t.Fatalf("SearchBrowserbase() error = %v", err) + } + if search.RequestID != "request_123" || len(search.Results) != 1 { + t.Fatalf("SearchBrowserbase() = %#v", search) + } + + fetch, err := FetchBrowserbase(context.Background(), BrowserbaseFetchOptions{ + APIKey: "bb_test", BaseURL: server.URL, URL: "https://stagehand.dev", + Format: BrowserbaseFetchFormatMarkdown, + }) + if err != nil { + t.Fatalf("FetchBrowserbase() error = %v", err) + } + content, stringContent := fetch.Content.AsString() + if fetch.StatusCode != 200 || !stringContent || content != "# Stagehand" { + t.Fatalf("FetchBrowserbase() = %#v", fetch) + } +} + +func TestBrowserbaseSearchAndFetchValidateOptions(t *testing.T) { + zero := 0 + if _, err := SearchBrowserbase(context.Background(), BrowserbaseSearchOptions{ + APIKey: "bb_test", Query: "browser agents", NumResults: &zero, + }); err == nil { + t.Fatal("SearchBrowserbase() expected numResults error") + } + if _, err := SearchBrowserbase(context.Background(), BrowserbaseSearchOptions{ + APIKey: "bb_test", Query: strings.Repeat("q", 201), + }); err == nil || !strings.Contains(err.Error(), "at most 200 characters") { + t.Fatalf("SearchBrowserbase() error = %v, want query length error", err) + } + if _, err := FetchBrowserbase(context.Background(), BrowserbaseFetchOptions{ + APIKey: "bb_test", URL: "https://stagehand.dev", Format: "xml", + }); err == nil { + t.Fatal("FetchBrowserbase() expected format error") + } + if _, err := FetchBrowserbase(context.Background(), BrowserbaseFetchOptions{ + APIKey: "bb_test", URL: "https://stagehand.dev", + Format: BrowserbaseFetchFormatMarkdown, Schema: map[string]any{"type": "object"}, + }); err == nil || !strings.Contains(err.Error(), `schema is only valid when format is "json"`) { + t.Fatalf("FetchBrowserbase() error = %v, want schema format error", err) + } + if _, err := FetchBrowserbase(context.Background(), BrowserbaseFetchOptions{ + APIKey: "bb_test", URL: "https://stagehand.dev", Format: BrowserbaseFetchFormatJSON, + }); err == nil || !strings.Contains(err.Error(), `schema is required when format is "json"`) { + t.Fatalf("FetchBrowserbase() error = %v, want required schema error", err) + } +} + +func TestBrowserbaseFetchContentVariants(t *testing.T) { + var stringContent BrowserbaseFetchContent + if err := json.Unmarshal([]byte(`"# Stagehand"`), &stringContent); err != nil { + t.Fatalf("decode string content: %v", err) + } + if value, ok := stringContent.AsString(); !ok || value != "# Stagehand" { + t.Fatalf("AsString() = %q, %t", value, ok) + } + if _, ok := stringContent.AsObject(); ok { + t.Fatal("AsObject() unexpectedly matched string content") + } + encodedString, err := json.Marshal(stringContent) + if err != nil || string(encodedString) != `"# Stagehand"` { + t.Fatalf("MarshalJSON() = %s, %v", encodedString, err) + } + + var objectContent BrowserbaseFetchContent + if err := json.Unmarshal([]byte(`{"title":"Stagehand"}`), &objectContent); err != nil { + t.Fatalf("decode object content: %v", err) + } + if value, ok := objectContent.AsObject(); !ok || value["title"] != "Stagehand" { + t.Fatalf("AsObject() = %#v, %t", value, ok) + } + if _, ok := objectContent.AsString(); ok { + t.Fatal("AsString() unexpectedly matched object content") + } + encodedObject, err := json.Marshal(objectContent) + if err != nil || string(encodedObject) != `{"title":"Stagehand"}` { + t.Fatalf("MarshalJSON() = %s, %v", encodedObject, err) + } + + for _, invalid := range []string{`null`, `[]`, `42`, `true`} { + var content BrowserbaseFetchContent + if err := json.Unmarshal([]byte(invalid), &content); err == nil { + t.Fatalf("UnmarshalJSON(%s) expected error", invalid) + } + } +} + +func TestBrowserbaseSearchAndFetchAreNotReplaySafe(t *testing.T) { + search, err := (browserbaseSearchRequest{Query: "browser agents"}).encode() + if err != nil { + t.Fatalf("encode search: %v", err) + } + fetch, err := (browserbaseFetchRequest{URL: "https://stagehand.dev"}).encode() + if err != nil { + t.Fatalf("encode fetch: %v", err) + } + if search.replaySafe || fetch.replaySafe { + t.Fatalf("replaySafe = search %t, fetch %t; want both false", search.replaySafe, fetch.replaySafe) + } +} + +func TestBrowserbaseSearchRejectsResultsMissingRequiredFields(t *testing.T) { + for _, field := range []string{"id", "title", "url"} { + t.Run(field, func(t *testing.T) { + item := map[string]any{ + "id": "result_123", "title": "Stagehand", "url": "https://stagehand.dev", + } + delete(item, field) + body, err := json.Marshal(map[string]any{ + "query": "browser agents", "requestId": "request_123", "results": []any{item}, + }) + if err != nil { + t.Fatalf("encode response: %v", err) + } + var response browserbaseSearchResponse + if err := json.Unmarshal(body, &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if err := response.validate(); err == nil || !strings.Contains(err.Error(), field) { + t.Fatalf("validate() error = %v, want missing %s error", err, field) + } + }) + } +} + +func TestBrowserbaseFetchPreservesExplicitEmptySchema(t *testing.T) { + schema := map[string]any{} + encoded, err := (browserbaseFetchRequest{ + URL: "https://stagehand.dev", Format: BrowserbaseFetchFormatJSON, Schema: &schema, + }).encode() + if err != nil { + t.Fatalf("encode() error = %v", err) + } + var body map[string]any + if err := json.Unmarshal(encoded.body, &body); err != nil { + t.Fatalf("decode request: %v", err) + } + want := map[string]any{ + "url": "https://stagehand.dev", "format": "json", "schema": map[string]any{}, + } + if !reflect.DeepEqual(body, want) { + t.Fatalf("fetch body = %#v, want %#v", body, want) + } +} diff --git a/packages/sdk-go/examples/fetch.go b/packages/sdk-go/examples/fetch.go new file mode 100644 index 0000000000..36d687815e --- /dev/null +++ b/packages/sdk-go/examples/fetch.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + + stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4" +) + +func main() { + if err := run(context.Background()); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context) error { + apiKey := os.Getenv("BROWSERBASE_API_KEY") + if apiKey == "" { + return errors.New("BROWSERBASE_API_KEY is required") + } + fetchResult, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{ + APIKey: apiKey, + URL: "https://example.com", + Format: stagehand.BrowserbaseFetchFormatMarkdown, + }) + if err != nil { + return err + } + content, ok := fetchResult.Content.AsString() + if !ok { + return errors.New("fetch returned non-string content") + } + fmt.Println(content) + return nil +} diff --git a/packages/sdk-go/examples/search.go b/packages/sdk-go/examples/search.go new file mode 100644 index 0000000000..cda95a3d05 --- /dev/null +++ b/packages/sdk-go/examples/search.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + + stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4" +) + +func main() { + if err := run(context.Background()); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context) error { + apiKey := os.Getenv("BROWSERBASE_API_KEY") + if apiKey == "" { + return errors.New("BROWSERBASE_API_KEY is required") + } + numResults := 5 + searchResult, err := stagehand.SearchBrowserbase(ctx, stagehand.BrowserbaseSearchOptions{ + APIKey: apiKey, + Query: "browser agent frameworks", + NumResults: &numResults, + }) + if err != nil { + return err + } + for _, result := range searchResult.Results { + fmt.Printf("%s: %s\n", result.Title, result.URL) + } + return nil +} diff --git a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip index c7d2871beb..bfa02d8a58 100644 Binary files a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip and b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip differ diff --git a/packages/sdk-go/internal/extensionpack/main.go b/packages/sdk-go/internal/extensionpack/main.go index 17a3b7cc5c..3d47bef091 100644 --- a/packages/sdk-go/internal/extensionpack/main.go +++ b/packages/sdk-go/internal/extensionpack/main.go @@ -5,6 +5,7 @@ package main import ( "archive/zip" "bytes" + "crypto/sha256" "encoding/json" "errors" "flag" @@ -72,7 +73,11 @@ func syncArchive(sourcePath, targetPath, packagePath string, check bool) error { err, ) case !bytes.Equal(current, source): - return errors.New("embedded Stagehand extension is stale; run `just generate`") + return fmt.Errorf( + "embedded Stagehand extension is stale (built sha256 %x, embedded sha256 %x); run `just generate`", + sha256.Sum256(source), + sha256.Sum256(current), + ) default: return nil } diff --git a/packages/sdk-go/internal/extensionpack/main_test.go b/packages/sdk-go/internal/extensionpack/main_test.go index 33d1692201..20207d23af 100644 --- a/packages/sdk-go/internal/extensionpack/main_test.go +++ b/packages/sdk-go/internal/extensionpack/main_test.go @@ -5,6 +5,7 @@ import ( "bytes" "os" "path/filepath" + "strings" "testing" ) @@ -34,6 +35,9 @@ func TestSyncArchiveRefreshesAndChecksCanonicalArtifact(t *testing.T) { } if err := syncArchive(sourcePath, targetPath, packagePath, true); err == nil { t.Fatal("syncArchive(check) accepted a stale archive") + } else if !strings.Contains(err.Error(), "built sha256") || + !strings.Contains(err.Error(), "embedded sha256") { + t.Fatalf("syncArchive(check) error = %v, want archive hashes", err) } } diff --git a/packages/sdk-python/README.md b/packages/sdk-python/README.md index 6f6f8f3524..524099f8b7 100644 --- a/packages/sdk-python/README.md +++ b/packages/sdk-python/README.md @@ -62,7 +62,7 @@ import os from pydantic import BaseModel -from stagehand import Stagehand, local_browser +from stagehand import Stagehand, browserbase, local_browser class PullRequest(BaseModel): @@ -118,6 +118,32 @@ same reason, so a `keep_alive=True` workflow that launches repeatedly accumulate they are deleted out of band. Sessions reached through `browserbase.connect()` are never released by `close()` — whoever created the session owns it. +The same `browserbase` facade exposes Browserbase Search and Fetch without launching a browser: + +```python +import asyncio +import os + +from stagehand import browserbase + + +async def search_and_fetch() -> None: + results = await browserbase.search( + api_key=os.environ["BROWSERBASE_API_KEY"], + query="browser agent frameworks", + num_results=5, + ) + fetched = await browserbase.fetch( + api_key=os.environ["BROWSERBASE_API_KEY"], + url=results.results[0].url, + format="markdown", + ) + print(fetched.content) + + +asyncio.run(search_and_fetch()) +``` + `Stagehand.act()`, `Stagehand.observe()`, and `Stagehand.extract()` use the active page by default. Pass `page=page` to target a specific SDK `Page`. diff --git a/packages/sdk-python/examples/fetch.py b/packages/sdk-python/examples/fetch.py new file mode 100644 index 0000000000..76027e69d7 --- /dev/null +++ b/packages/sdk-python/examples/fetch.py @@ -0,0 +1,17 @@ +import asyncio +import os + +from stagehand import browserbase + + +async def main() -> None: + fetch_result = await browserbase.fetch( + api_key=os.environ["BROWSERBASE_API_KEY"], + url="https://example.com", + format="markdown", + ) + print(fetch_result.content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/sdk-python/examples/search.py b/packages/sdk-python/examples/search.py new file mode 100644 index 0000000000..6100fe80c6 --- /dev/null +++ b/packages/sdk-python/examples/search.py @@ -0,0 +1,18 @@ +import asyncio +import os + +from stagehand import browserbase + + +async def main() -> None: + search_result = await browserbase.search( + api_key=os.environ["BROWSERBASE_API_KEY"], + query="browser agent frameworks", + num_results=5, + ) + for result in search_result.results: + print(f"{result.title}: {result.url}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/sdk-python/src/stagehand/__init__.py b/packages/sdk-python/src/stagehand/__init__.py index 6d7009879f..5609a55693 100644 --- a/packages/sdk-python/src/stagehand/__init__.py +++ b/packages/sdk-python/src/stagehand/__init__.py @@ -59,6 +59,9 @@ from .browser_clipboard import BrowserClipboard from .browser_context import BrowserContext from .client_models import ( + BrowserbaseFetchResult, + BrowserbaseSearchResult, + BrowserbaseSearchResultItem, DefaultExtract, ExtractResult, ) @@ -90,8 +93,11 @@ "BrowserClipboard", "BrowserContext", "BrowserbaseBrowserSettings", + "BrowserbaseFetchResult", "BrowserbaseProxyConfig", "BrowserbaseRegion", + "BrowserbaseSearchResult", + "BrowserbaseSearchResultItem", "CacheOptions", "CacheMetadata", "CacheStatus", diff --git a/packages/sdk-python/src/stagehand/browser.py b/packages/sdk-python/src/stagehand/browser.py index 312b554a7e..f84e46c1b2 100644 --- a/packages/sdk-python/src/stagehand/browser.py +++ b/packages/sdk-python/src/stagehand/browser.py @@ -21,14 +21,19 @@ BrowserbaseSessionCreateParams, BrowserSessionMetadata, ) +from .browserbase_services import fetch_browserbase, search_browserbase from .browserbase_session import DEFAULT_BROWSERBASE_URL, _create_browserbase_session_client from .cdp_client import CDPClient, CDPConnectionClosedError from .client_models import ( BrowserbaseConnectOptions, + BrowserbaseFetchResult, + BrowserbaseSearchResult, LocalBrowserConnectOptions, LocalBrowserLaunchOptions, LocalProxyConfig, LocalViewport, + _BrowserbaseFetchOptions, + _BrowserbaseSearchOptions, ) from .extension_assets import extension_directory from .timeouts import stagehand_init_deadline @@ -616,6 +621,54 @@ async def connect( ), ) + async def search( + self, + *, + api_key: str, + query: str, + base_url: str = DEFAULT_BROWSERBASE_URL, + num_results: int | None = None, + ) -> BrowserbaseSearchResult: + options = _BrowserbaseSearchOptions.model_validate({ + name: value + for name, value in ( + ("api_key", api_key), + ("base_url", base_url), + ("query", query), + ("num_results", num_results), + ) + if value is not None + }) + return await search_browserbase(options) + + async def fetch( + self, + *, + api_key: str, + url: str, + base_url: str = DEFAULT_BROWSERBASE_URL, + allow_insecure_ssl: bool | None = None, + allow_redirects: bool | None = None, + format: Literal["raw", "json", "markdown"] | None = None, + proxies: bool | None = None, + schema: Mapping[str, Any] | None = None, + ) -> BrowserbaseFetchResult: + options = _BrowserbaseFetchOptions.model_validate({ + name: value + for name, value in ( + ("api_key", api_key), + ("base_url", base_url), + ("url", url), + ("allow_insecure_ssl", allow_insecure_ssl), + ("allow_redirects", allow_redirects), + ("format", format), + ("proxies", proxies), + ("schema", dict(schema) if schema is not None else None), + ) + if value is not None + }) + return await fetch_browserbase(options) + local_browser = LocalBrowser() browserbase = BrowserbaseBrowser() diff --git a/packages/sdk-python/src/stagehand/browserbase_services.py b/packages/sdk-python/src/stagehand/browserbase_services.py new file mode 100644 index 0000000000..08cb417940 --- /dev/null +++ b/packages/sdk-python/src/stagehand/browserbase_services.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from browserbase import AsyncBrowserbase + +from .client_models import ( + BrowserbaseFetchResult, + BrowserbaseSearchResult, + BrowserbaseSearchResultItem, + _BrowserbaseFetchOptions, + _BrowserbaseSearchOptions, +) + + +async def search_browserbase(options: _BrowserbaseSearchOptions) -> BrowserbaseSearchResult: + async with AsyncBrowserbase(api_key=options.api_key, base_url=options.base_url) as client: + response = await client.search.web( + query=options.query, + **({"num_results": options.num_results} if options.num_results is not None else {}), + ) + return BrowserbaseSearchResult( + query=response.query, + request_id=response.request_id, + results=[ + BrowserbaseSearchResultItem( + id=result.id, + title=result.title, + url=result.url, + author=result.author, + favicon=result.favicon, + image=result.image, + published_date=( + result.published_date.isoformat() if result.published_date is not None else None + ), + ) + for result in response.results + ], + ) + + +async def fetch_browserbase(options: _BrowserbaseFetchOptions) -> BrowserbaseFetchResult: + request = options.model_dump( + exclude={"api_key", "base_url"}, + exclude_none=True, + by_alias=True, + ) + async with AsyncBrowserbase(api_key=options.api_key, base_url=options.base_url) as client: + response = await client.fetch_api.create(**request) + return BrowserbaseFetchResult( + id=response.id, + content=response.content, + content_type=response.content_type, + encoding=response.encoding, + headers=response.headers, + status_code=response.status_code, + ) diff --git a/packages/sdk-python/src/stagehand/client_models.py b/packages/sdk-python/src/stagehand/client_models.py index 42a8015bb0..d1ace00325 100644 --- a/packages/sdk-python/src/stagehand/client_models.py +++ b/packages/sdk-python/src/stagehand/client_models.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable from typing import Annotated, Any, Generic, Literal, TypeVar -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from ._generated import models as _models from ._generated.models import ( @@ -16,7 +16,7 @@ StagehandResultMetadata, TelemetryConfig, ) -from ._validation import WireModel +from ._validation import WireModel, WireUrl from .browserbase_session import DEFAULT_BROWSERBASE_URL from .client_types import Cache as CacheInput @@ -115,6 +115,67 @@ class BrowserbaseConnectOptions(WireModel): extension_id: Annotated[str | None, Field(min_length=1)] = None +class _BrowserbaseSearchOptions(WireModel): + model_config = ConfigDict(extra="forbid", strict=True) + + api_key: Annotated[str, Field(min_length=1)] + base_url: Annotated[str, Field(min_length=1)] = DEFAULT_BROWSERBASE_URL + query: Annotated[str, Field(min_length=1, max_length=200)] + num_results: Annotated[int | None, Field(ge=1, le=25)] = None + + +class _BrowserbaseFetchOptions(WireModel): + model_config = ConfigDict(extra="forbid", strict=True) + + api_key: Annotated[str, Field(min_length=1)] + base_url: Annotated[str, Field(min_length=1)] = DEFAULT_BROWSERBASE_URL + url: WireUrl + allow_insecure_ssl: bool | None = None + allow_redirects: bool | None = None + format: Literal["raw", "json", "markdown"] | None = None + proxies: bool | None = None + json_schema: dict[str, Any] | None = Field(default=None, alias="schema") + + @model_validator(mode="after") + def validate_schema_format(self) -> _BrowserbaseFetchOptions: + if self.json_schema is not None and self.format != "json": + raise ValueError('schema is only valid when format is "json"') + if self.format == "json" and self.json_schema is None: + raise ValueError('schema is required when format is "json"') + return self + + +class BrowserbaseSearchResultItem(WireModel): + model_config = ConfigDict(extra="forbid") + + id: str + title: str + url: str + author: str | None = None + favicon: str | None = None + image: str | None = None + published_date: str | None = None + + +class BrowserbaseSearchResult(WireModel): + model_config = ConfigDict(extra="forbid") + + query: str + request_id: str + results: list[BrowserbaseSearchResultItem] + + +class BrowserbaseFetchResult(WireModel): + model_config = ConfigDict(extra="forbid") + + id: str + content: str | dict[str, Any] + content_type: str + encoding: str + headers: dict[str, str] + status_code: int + + LLMGenerateInput = LLMStructuredGenerateParams | LLMMessageGenerateParams LLMGenerateOutput = LLMStructuredGenerateResult | LLMMessageGenerateResult LLMGenerateCallback = Callable[[LLMGenerateInput], Awaitable[LLMGenerateOutput]] diff --git a/packages/sdk-python/tests/test_browser.py b/packages/sdk-python/tests/test_browser.py index 8827f7ede5..874b7940b2 100644 --- a/packages/sdk-python/tests/test_browser.py +++ b/packages/sdk-python/tests/test_browser.py @@ -779,6 +779,45 @@ async def test_browserbase_validation_precedes_api_calls( assert api_keys == [] +async def test_browserbase_search_and_fetch_delegate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + search_calls: list[object] = [] + fetch_calls: list[object] = [] + + async def search(options: object) -> object: + search_calls.append(options) + return cast(object, "search-result") + + async def fetch(options: object) -> object: + fetch_calls.append(options) + return cast(object, "fetch-result") + + monkeypatch.setattr(browser, "search_browserbase", search) + monkeypatch.setattr(browser, "fetch_browserbase", fetch) + + assert await browserbase.search( + api_key="bb_key", + query="browser agents", + num_results=5, + ) == cast(object, "search-result") + assert await browserbase.fetch( + api_key="bb_key", + url="https://stagehand.dev", + format="markdown", + ) == cast(object, "fetch-result") + assert len(search_calls) == 1 + assert len(fetch_calls) == 1 + search_options = cast(browser._BrowserbaseSearchOptions, search_calls[0]) + assert search_options.api_key == "bb_key" + assert search_options.query == "browser agents" + assert search_options.num_results == 5 + fetch_options = cast(browser._BrowserbaseFetchOptions, fetch_calls[0]) + assert fetch_options.api_key == "bb_key" + assert str(fetch_options.url) == "https://stagehand.dev" + assert fetch_options.format == "markdown" + + async def test_local_browser_close_ignores_vanished_process_and_removes_profile( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/packages/sdk-python/tests/test_browserbase_services.py b/packages/sdk-python/tests/test_browserbase_services.py new file mode 100644 index 0000000000..af3bc78a25 --- /dev/null +++ b/packages/sdk-python/tests/test_browserbase_services.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Self + +import pytest + +from stagehand import browserbase_services +from stagehand.client_models import _BrowserbaseFetchOptions, _BrowserbaseSearchOptions + + +class FakeAsyncBrowserbase: + configurations: list[tuple[str, str]] = [] + search_calls: list[dict[str, object]] = [] + fetch_calls: list[dict[str, object]] = [] + + def __init__(self, *, api_key: str, base_url: str) -> None: + self.configurations.append((api_key, base_url)) + self.search = SimpleNamespace(web=self._search) + self.fetch_api = SimpleNamespace(create=self._fetch) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def _search(self, **kwargs: object) -> SimpleNamespace: + self.search_calls.append(kwargs) + return SimpleNamespace( + query="browser agents", + request_id="request_123", + results=[ + SimpleNamespace( + id="result_123", + title="Stagehand", + url="https://stagehand.dev", + author=None, + favicon=None, + image=None, + published_date=None, + ) + ], + ) + + async def _fetch(self, **kwargs: object) -> SimpleNamespace: + self.fetch_calls.append(kwargs) + return SimpleNamespace( + id="fetch_123", + content="# Stagehand", + content_type="text/markdown", + encoding="utf-8", + headers={"content-type": "text/html"}, + status_code=200, + ) + + +async def test_browserbase_services_proxy_official_sdk( + monkeypatch: pytest.MonkeyPatch, +) -> None: + FakeAsyncBrowserbase.configurations.clear() + FakeAsyncBrowserbase.search_calls.clear() + FakeAsyncBrowserbase.fetch_calls.clear() + monkeypatch.setattr(browserbase_services, "AsyncBrowserbase", FakeAsyncBrowserbase) + + search = await browserbase_services.search_browserbase( + _BrowserbaseSearchOptions( + api_key="bb_key", + base_url="https://api.dev.browserbase.com", + query="browser agents", + num_results=5, + ) + ) + fetch = await browserbase_services.fetch_browserbase( + _BrowserbaseFetchOptions( + api_key="bb_key", + base_url="https://api.dev.browserbase.com", + url="https://stagehand.dev", + format="markdown", + ) + ) + + assert search.request_id == "request_123" + assert fetch.status_code == 200 + assert FakeAsyncBrowserbase.configurations == [ + ("bb_key", "https://api.dev.browserbase.com"), + ("bb_key", "https://api.dev.browserbase.com"), + ] + assert FakeAsyncBrowserbase.search_calls == [{"query": "browser agents", "num_results": 5}] + assert FakeAsyncBrowserbase.fetch_calls == [ + {"url": "https://stagehand.dev", "format": "markdown"} + ] + + +def test_browserbase_services_validate_request_constraints() -> None: + with pytest.raises(ValueError): + _BrowserbaseSearchOptions(api_key="bb_key", query="q" * 201) + + with pytest.raises(ValueError, match='schema is only valid when format is "json"'): + _BrowserbaseFetchOptions( + api_key="bb_key", + url="https://stagehand.dev", + format="markdown", + schema={"type": "object"}, + ) + + with pytest.raises(ValueError, match='schema is required when format is "json"'): + _BrowserbaseFetchOptions( + api_key="bb_key", + url="https://stagehand.dev", + format="json", + ) + + options = _BrowserbaseFetchOptions( + api_key="bb_key", + url="https://stagehand.dev", + format="json", + schema={"type": "object"}, + ) + assert options.json_schema == {"type": "object"} diff --git a/packages/sdk-ts/examples/fetch.ts b/packages/sdk-ts/examples/fetch.ts new file mode 100644 index 0000000000..0eafb5458d --- /dev/null +++ b/packages/sdk-ts/examples/fetch.ts @@ -0,0 +1,13 @@ +import "dotenv/config"; +import { browserbase } from "../src/index.js"; + +const { BROWSERBASE_API_KEY } = process.env; +if (!BROWSERBASE_API_KEY) throw new Error("BROWSERBASE_API_KEY is required"); + +const fetchResult = await browserbase.fetch({ + apiKey: BROWSERBASE_API_KEY, + url: "https://example.com", + format: "markdown", +}); + +console.log(fetchResult.content); diff --git a/packages/sdk-ts/examples/search.ts b/packages/sdk-ts/examples/search.ts new file mode 100644 index 0000000000..57459667d9 --- /dev/null +++ b/packages/sdk-ts/examples/search.ts @@ -0,0 +1,15 @@ +import "dotenv/config"; +import { browserbase } from "../src/index.js"; + +const { BROWSERBASE_API_KEY } = process.env; +if (!BROWSERBASE_API_KEY) throw new Error("BROWSERBASE_API_KEY is required"); + +const searchResult = await browserbase.search({ + apiKey: BROWSERBASE_API_KEY, + query: "browser agent frameworks", + numResults: 5, +}); + +for (const result of searchResult.results) { + console.log(`${result.title}: ${result.url}`); +} diff --git a/packages/sdk-ts/src/browser/browserbaseServices.ts b/packages/sdk-ts/src/browser/browserbaseServices.ts new file mode 100644 index 0000000000..26ded933a8 --- /dev/null +++ b/packages/sdk-ts/src/browser/browserbaseServices.ts @@ -0,0 +1,52 @@ +import Browserbase from "@browserbasehq/sdk"; +import { + BrowserbaseFetchResultSchema, + BrowserbaseSearchResultSchema, + type BrowserbaseFetchResult, + type BrowserbaseSearchResult, +} from "../clientSchemas.js"; + +export type BrowserbaseSearchParams = { + query: string; + numResults?: number; +}; + +export type BrowserbaseFetchParams = { + url: string; + allowInsecureSsl?: boolean; + allowRedirects?: boolean; + format?: "raw" | "json" | "markdown"; + proxies?: boolean; + schema?: Record; +}; + +export type BrowserbaseServicesClient = { + search(params: BrowserbaseSearchParams): Promise; + fetch(params: BrowserbaseFetchParams): Promise; +}; + +type BrowserbaseSdk = { + search: { + web(params: BrowserbaseSearchParams): Promise; + }; + fetchAPI: { + create(params: BrowserbaseFetchParams): Promise; + }; +}; +type BrowserbaseSdkFactory = (apiKey: string, baseUrl: string) => BrowserbaseSdk; + +export function createBrowserbaseServicesClient( + apiKey: string, + baseUrl: string, + createSdk: BrowserbaseSdkFactory = (key, baseURL) => new Browserbase({ apiKey: key, baseURL }), +): BrowserbaseServicesClient { + const sdk = createSdk(apiKey, baseUrl); + return { + async search(params) { + return BrowserbaseSearchResultSchema.parse(await sdk.search.web(params)); + }, + async fetch(params) { + return BrowserbaseFetchResultSchema.parse(await sdk.fetchAPI.create(params)); + }, + }; +} diff --git a/packages/sdk-ts/src/browser/factories.ts b/packages/sdk-ts/src/browser/factories.ts index 2e977b7d4e..362cbe6cd9 100644 --- a/packages/sdk-ts/src/browser/factories.ts +++ b/packages/sdk-ts/src/browser/factories.ts @@ -1,7 +1,9 @@ import type { StagehandInitParams } from "@browserbasehq/stagehand-protocol/types"; import { BrowserbaseConnectOptionsSchema, + BrowserbaseFetchOptionsSchema, BrowserbaseLaunchOptionsSchema, + BrowserbaseSearchOptionsSchema, LocalBrowserConnectOptionsSchema, LocalBrowserLaunchOptionsSchema, } from "../clientSchemas.js"; @@ -22,6 +24,10 @@ import { createBrowserbaseSessionClient, type BrowserbaseSessionClient, } from "./browserbaseSession.js"; +import { + createBrowserbaseServicesClient, + type BrowserbaseServicesClient, +} from "./browserbaseServices.js"; import { launchLocalBrowser, type LocalBrowserLauncher } from "./localBrowser.js"; import { STAGEHAND_EXTENSION_DIRECTORY_PATH } from "../extensionAssets.js"; import { abortable } from "../abort.js"; @@ -40,6 +46,7 @@ export type ClaimedStagehandBrowser = { type BrowserFactoryDependencies = { launchLocalBrowser?: LocalBrowserLauncher; createBrowserbaseSessionClient?: (apiKey: string, baseUrl: string) => BrowserbaseSessionClient; + createBrowserbaseServicesClient?: (apiKey: string, baseUrl: string) => BrowserbaseServicesClient; connectCdp?: (options: CDPClientOptions) => Promise; }; @@ -56,6 +63,8 @@ function createBrowserFactories(dependencies: BrowserFactoryDependencies = {}): const launchLocal = dependencies.launchLocalBrowser ?? launchLocalBrowser; const createBrowserbase = dependencies.createBrowserbaseSessionClient ?? createBrowserbaseSessionClient; + const createBrowserbaseServices = + dependencies.createBrowserbaseServicesClient ?? createBrowserbaseServicesClient; const connectCdp = dependencies.connectCdp ?? ((options) => CDPClient.connect(options)); return { @@ -206,6 +215,16 @@ function createBrowserFactories(dependencies: BrowserFactoryDependencies = {}): }); }); }, + + async search(input) { + const { apiKey, baseUrl, ...params } = BrowserbaseSearchOptionsSchema.parse(input); + return await createBrowserbaseServices(apiKey, baseUrl).search(params); + }, + + async fetch(input) { + const { apiKey, baseUrl, ...params } = BrowserbaseFetchOptionsSchema.parse(input); + return await createBrowserbaseServices(apiKey, baseUrl).fetch(params); + }, }, }; } diff --git a/packages/sdk-ts/src/browser/index.ts b/packages/sdk-ts/src/browser/index.ts index 0e2f389e8c..fff0b48900 100644 --- a/packages/sdk-ts/src/browser/index.ts +++ b/packages/sdk-ts/src/browser/index.ts @@ -1,6 +1,10 @@ import type { BrowserbaseConnectOptions, + BrowserbaseFetchOptions, + BrowserbaseFetchResult, BrowserbaseLaunchOptions, + BrowserbaseSearchOptions, + BrowserbaseSearchResult, LocalBrowserConnectOptions, LocalBrowserLaunchOptions, } from "../clientSchemas.js"; @@ -8,7 +12,11 @@ import type { BrowserContext } from "../browserContext.js"; export type { BrowserbaseConnectOptions, + BrowserbaseFetchOptions, + BrowserbaseFetchResult, BrowserbaseLaunchOptions, + BrowserbaseSearchOptions, + BrowserbaseSearchResult, LocalBrowserConnectOptions, LocalBrowserLaunchOptions, }; @@ -43,6 +51,8 @@ export interface LocalBrowser { export interface BrowserbaseBrowser { launch(options: BrowserbaseLaunchOptions): Promise; connect(options: BrowserbaseConnectOptions): Promise; + search(options: BrowserbaseSearchOptions): Promise; + fetch(options: BrowserbaseFetchOptions): Promise; } type BrowserHandleInternals = { diff --git a/packages/sdk-ts/src/clientSchemas.ts b/packages/sdk-ts/src/clientSchemas.ts index 9fa1ffde66..49c110dfaa 100644 --- a/packages/sdk-ts/src/clientSchemas.ts +++ b/packages/sdk-ts/src/clientSchemas.ts @@ -98,6 +98,68 @@ export const BrowserbaseConnectOptionsSchema = z }) .meta({ id: "BrowserbaseConnectOptions" }); +const BrowserbaseClientOptionsSchema = { + apiKey: z.string().min(1), + baseUrl: z.url().default(DEFAULT_BROWSERBASE_URL), +}; + +export const BrowserbaseSearchOptionsSchema = z + .strictObject({ + ...BrowserbaseClientOptionsSchema, + query: z.string().min(1).max(200), + numResults: z.int().min(1).max(25).optional(), + }) + .meta({ id: "BrowserbaseSearchOptions" }); + +export const BrowserbaseFetchOptionsSchema = z + .strictObject({ + ...BrowserbaseClientOptionsSchema, + url: z.url(), + allowInsecureSsl: z.boolean().optional(), + allowRedirects: z.boolean().optional(), + format: z.enum(["raw", "json", "markdown"]).optional(), + proxies: z.boolean().optional(), + schema: z.record(z.string(), z.unknown()).optional(), + }) + .refine(({ format, schema }) => schema === undefined || format === "json", { + message: 'schema is only valid when format is "json"', + path: ["schema"], + }) + .refine(({ format, schema }) => format !== "json" || schema !== undefined, { + message: 'schema is required when format is "json"', + path: ["schema"], + }) + .meta({ id: "BrowserbaseFetchOptions" }); + +export const BrowserbaseSearchResultSchema = z + .object({ + query: z.string(), + requestId: z.string(), + results: z.array( + z.object({ + id: z.string(), + title: z.string(), + url: z.string(), + author: z.string().nullish(), + favicon: z.string().nullish(), + image: z.string().nullish(), + publishedDate: z.string().nullish(), + }), + ), + }) + .meta({ id: "BrowserbaseSearchResult" }); + +export const BrowserbaseFetchResultSchema = z + .object({ + id: z.string(), + content: z.union([z.string(), z.record(z.string(), z.unknown())]), + contentType: z.string(), + encoding: z.string(), + headers: z.record(z.string(), z.string()), + statusCode: z.number().int(), + }) + .meta({ id: "BrowserbaseFetchResult" }); + /** Data returned by the Browserbase SDK after creating a session. */ export const BrowserbaseSessionCreateResultSchema = z .object({ @@ -221,6 +283,10 @@ export type LocalBrowserLaunchOptions = z.infer; export type BrowserbaseLaunchOptions = z.input; export type BrowserbaseConnectOptions = z.input; +export type BrowserbaseSearchOptions = z.input; +export type BrowserbaseFetchOptions = z.input; +export type BrowserbaseSearchResult = z.infer; +export type BrowserbaseFetchResult = z.infer; export type BrowserbaseSessionCreateResult = z.infer; export type BrowserbaseSessionRetrieveResult = z.infer< typeof BrowserbaseSessionRetrieveResultSchema diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index 148a84f4b6..5e3b283823 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -51,7 +51,11 @@ export { browserbase, localBrowser } from "./browser/factories.js"; export type { BrowserbaseBrowser, BrowserbaseConnectOptions, + BrowserbaseFetchOptions, + BrowserbaseFetchResult, BrowserbaseLaunchOptions, + BrowserbaseSearchOptions, + BrowserbaseSearchResult, LocalBrowser, LocalBrowserConnectOptions, LocalBrowserLaunchOptions, @@ -92,7 +96,11 @@ export type { } from "@browserbasehq/stagehand-protocol/types"; export { BrowserbaseConnectOptionsSchema, + BrowserbaseFetchOptionsSchema, + BrowserbaseFetchResultSchema, BrowserbaseLaunchOptionsSchema, + BrowserbaseSearchOptionsSchema, + BrowserbaseSearchResultSchema, ClientLLMSchema, LocalBrowserConnectOptionsSchema, LocalBrowserLaunchOptionsSchema, diff --git a/packages/sdk-ts/tests/browser/browserbaseServices.test.ts b/packages/sdk-ts/tests/browser/browserbaseServices.test.ts new file mode 100644 index 0000000000..4245d512f6 --- /dev/null +++ b/packages/sdk-ts/tests/browser/browserbaseServices.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { createBrowserbaseServicesClient } from "../../src/browser/browserbaseServices.js"; +import { + BrowserbaseFetchOptionsSchema, + BrowserbaseSearchOptionsSchema, +} from "../../src/clientSchemas.js"; + +describe("Browserbase services client", () => { + it("proxies search and fetch through the official Browserbase SDK", async () => { + const searchWeb = vi.fn(async () => ({ + query: "browser agents", + requestId: "request_123", + results: [ + { + id: "result_123", + title: "Stagehand", + url: "https://stagehand.dev", + author: null, + favicon: null, + image: null, + publishedDate: null, + }, + ], + })); + const fetchCreate = vi.fn(async () => ({ + id: "fetch_123", + content: "# Stagehand", + contentType: "text/markdown", + encoding: "utf-8", + headers: { "content-type": "text/html" }, + statusCode: 200, + })); + const createSdk = vi.fn(() => ({ + search: { web: searchWeb }, + fetchAPI: { create: fetchCreate }, + })); + const client = createBrowserbaseServicesClient( + "bb_key", + "https://api.dev.browserbase.com", + createSdk, + ); + + await expect(client.search({ query: "browser agents", numResults: 5 })).resolves.toMatchObject({ + requestId: "request_123", + results: [{ author: null, publishedDate: null }], + }); + await expect( + client.fetch({ url: "https://stagehand.dev", format: "markdown" }), + ).resolves.toMatchObject({ statusCode: 200 }); + + expect(createSdk).toHaveBeenCalledWith("bb_key", "https://api.dev.browserbase.com"); + expect(searchWeb).toHaveBeenCalledWith({ query: "browser agents", numResults: 5 }); + expect(fetchCreate).toHaveBeenCalledWith({ + url: "https://stagehand.dev", + format: "markdown", + }); + }); + + it("enforces Browserbase Search and Fetch request constraints", () => { + expect(() => + BrowserbaseSearchOptionsSchema.parse({ apiKey: "bb_key", query: "q".repeat(201) }), + ).toThrow(); + expect(() => + BrowserbaseFetchOptionsSchema.parse({ + apiKey: "bb_key", + url: "https://stagehand.dev", + format: "markdown", + schema: { type: "object" }, + }), + ).toThrow(/schema is only valid when format/u); + expect(() => + BrowserbaseFetchOptionsSchema.parse({ + apiKey: "bb_key", + url: "https://stagehand.dev", + format: "json", + }), + ).toThrow(/schema is required when format/u); + expect(() => + BrowserbaseFetchOptionsSchema.parse({ + apiKey: "bb_key", + url: "https://stagehand.dev", + format: "json", + schema: { type: "object" }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/sdk-ts/tests/browser/factories.test.ts b/packages/sdk-ts/tests/browser/factories.test.ts index f524fc79a6..8511007fc4 100644 --- a/packages/sdk-ts/tests/browser/factories.test.ts +++ b/packages/sdk-ts/tests/browser/factories.test.ts @@ -345,6 +345,53 @@ describe("Stagehand browser factories", () => { expect(closeSession).toHaveBeenCalledTimes(expectedSessionCloses); }, ); + it("delegates Browserbase search and fetch requests", async () => { + const search = vi.fn(async () => ({ + query: "browser agents", + requestId: "request_123", + results: [{ id: "result_123", title: "Stagehand", url: "https://stagehand.dev" }], + })); + const fetch = vi.fn(async () => ({ + id: "fetch_123", + content: "# Stagehand", + contentType: "text/markdown", + encoding: "utf-8", + headers: { "content-type": "text/html" }, + statusCode: 200, + })); + const createBrowserbaseServicesClient = vi.fn(() => ({ search, fetch })); + const { browserbase } = createBrowserFactoriesForTest({ + createBrowserbaseServicesClient, + }); + + await expect( + browserbase.search({ + apiKey: "bb_key", + baseUrl: "https://api.dev.browserbase.com", + query: "browser agents", + numResults: 5, + }), + ).resolves.toMatchObject({ requestId: "request_123" }); + await expect( + browserbase.fetch({ + apiKey: "bb_key", + baseUrl: "https://api.dev.browserbase.com", + url: "https://stagehand.dev", + format: "markdown", + }), + ).resolves.toMatchObject({ statusCode: 200 }); + + expect(createBrowserbaseServicesClient).toHaveBeenCalledTimes(2); + expect(createBrowserbaseServicesClient).toHaveBeenCalledWith( + "bb_key", + "https://api.dev.browserbase.com", + ); + expect(search).toHaveBeenCalledWith({ query: "browser agents", numResults: 5 }); + expect(fetch).toHaveBeenCalledWith({ + url: "https://stagehand.dev", + format: "markdown", + }); + }); it("discovers Stagehand when connecting without a Chrome extension ID", async () => { const connectSession = vi.fn(async () => ({ diff --git a/rules/ast-grep/example-parity.test.ts b/rules/ast-grep/example-parity.test.ts index 735d4af78f..7ad001c76d 100644 --- a/rules/ast-grep/example-parity.test.ts +++ b/rules/ast-grep/example-parity.test.ts @@ -92,11 +92,15 @@ func main() { expect(publicSdkOperations(root, "go")).toStrictEqual(["stagehand.extract"]); }); - it("uses the public Stagehand lifecycle in every example", async () => { + it("uses the public SDK lifecycle required by each example", async () => { for (const language of ["typescript", "python", "go"] as const) { for (const example of await examples(language)) { const root = parse(language, await readFile(example.url, "utf8")).root(); const stagehand = stagehandVariable(root, language); + const operations = publicSdkOperations(root, language); + const usesOnlyBrowserbaseServices = + operations.length > 0 && + operations.every((operation) => operation.startsWith("browserbase.")); const publicImport = language === "go" ? root @@ -117,12 +121,32 @@ func main() { publicImport, `${language} ${example.file} must import the public SDK`, ).toBeDefined(); - if (language !== "go") { + if (usesOnlyBrowserbaseServices) { + if (language !== "go") { + expect( + publicImport + ?.getMultipleMatches("IMPORTS") + .some((node) => node.text() === "browserbase"), + `${language} ${example.file} must import public browserbase services`, + ).toBe(true); + } + expect( + stagehand, + `${language} ${example.file} must not construct Stagehand for browserless services`, + ).toBeUndefined(); + } else if (language !== "go") { expect( publicImport?.getMultipleMatches("IMPORTS").some((node) => node.text() === "Stagehand"), `${language} ${example.file} must import public Stagehand`, ).toBe(true); } + if (usesOnlyBrowserbaseServices) { + expect( + root.text(), + `${language} ${example.file} must not reach into SDK internals`, + ).not.toMatch(/\b(?:CDPClient|RPCClient|Transport|_generated|rpc_client)\b/); + continue; + } expect(stagehand, `${language} ${example.file} must construct Stagehand`).toBeDefined(); if (language === "typescript") { expect( @@ -197,7 +221,7 @@ function stagehandVariable(root: SgNode, language: ExampleLanguage): string | un function publicSdkOperations(root: SgNode, language: ExampleLanguage): string[] { const stagehand = stagehandVariable(root, language); - if (!stagehand) return []; + if (!stagehand) return browserbaseServiceOperations(root, language); if (language === "go") { const sdkPackage = goSdkPackage(root); @@ -235,6 +259,32 @@ function publicSdkOperations(root: SgNode, language: ExampleLanguage): string[] .sort(); } +function browserbaseServiceOperations(root: SgNode, language: ExampleLanguage): string[] { + if (language === "go") { + const sdkPackage = goSdkPackage(root); + if (!sdkPackage) return []; + return goCalls(root) + .flatMap(({ object, method }) => { + if (object !== sdkPackage) return []; + if (method === "SearchBrowserbase") return ["browserbase.search"]; + if (method === "FetchBrowserbase") return ["browserbase.fetch"]; + return []; + }) + .sort(); + } + + return root + .findAll({ rule: { pattern: "$OBJECT.$METHOD($$$ARGS)" } }) + .flatMap((call) => { + const object = call.getMatch("OBJECT")?.text(); + const method = call.getMatch("METHOD")?.text(); + return object === "browserbase" && (method === "search" || method === "fetch") + ? [`browserbase.${method}`] + : []; + }) + .sort(); +} + function goPublicSdkOperations(root: SgNode, stagehand: string, sdkPackage: string): string[] { const assignedValues = goAssignedValues(root); const contextObjects = new Set( diff --git a/rules/ast-grep/sdk-client-schema-parity.test.ts b/rules/ast-grep/sdk-client-schema-parity.test.ts index da604a10db..2bcd299e57 100644 --- a/rules/ast-grep/sdk-client-schema-parity.test.ts +++ b/rules/ast-grep/sdk-client-schema-parity.test.ts @@ -46,6 +46,30 @@ const concepts: readonly Concept[] = [ python: () => pythonClassFields("client_types.py", "BrowserbaseConnectOptions"), go: () => goStructFields("browser_factories.go", "BrowserbaseConnectOptions"), }, + { + name: "BrowserbaseSearchOptions", + typescript: ClientSchemas.BrowserbaseSearchOptionsSchema, + python: () => pythonMethodParameters("browser.py", "BrowserbaseBrowser", "search"), + go: () => goStructFields("browserbase_services.go", "BrowserbaseSearchOptions"), + }, + { + name: "BrowserbaseFetchOptions", + typescript: ClientSchemas.BrowserbaseFetchOptionsSchema, + python: () => pythonMethodParameters("browser.py", "BrowserbaseBrowser", "fetch"), + go: () => goStructFields("browserbase_services.go", "BrowserbaseFetchOptions"), + }, + { + name: "BrowserbaseSearchResult", + typescript: ClientSchemas.BrowserbaseSearchResultSchema, + python: () => pythonClassFields("client_models.py", "BrowserbaseSearchResult"), + go: () => goStructFields("browserbase_services.go", "BrowserbaseSearchResult"), + }, + { + name: "BrowserbaseFetchResult", + typescript: ClientSchemas.BrowserbaseFetchResultSchema, + python: () => pythonClassFields("client_models.py", "BrowserbaseFetchResult"), + go: () => goStructFields("browserbase_services.go", "BrowserbaseFetchResult"), + }, { name: "StagehandClientLoggingConfig", typescript: ClientSchemas.StagehandClientLoggingConfigSchema, diff --git a/rules/ast-grep/sdk-field-pipeline.test.ts b/rules/ast-grep/sdk-field-pipeline.test.ts index 79e4f45fa6..f67d1df09d 100644 --- a/rules/ast-grep/sdk-field-pipeline.test.ts +++ b/rules/ast-grep/sdk-field-pipeline.test.ts @@ -115,7 +115,7 @@ describe("Every public SDK field participates in the protocol pipeline", () => { missing.sort(), "Every protocol request field must be visibly constructed, forwarded wholesale, or handled by a complete adapter", ).toEqual([]); - }); + }, 15_000); it("consumes every declared result field in every SDK", async () => { const [protocol, calls] = await Promise.all([protocolDocument(), publicRpcCalls()]); diff --git a/rules/ast-grep/sdk-parity.test.ts b/rules/ast-grep/sdk-parity.test.ts index 17aacc2f9e..42ea03d09b 100644 --- a/rules/ast-grep/sdk-parity.test.ts +++ b/rules/ast-grep/sdk-parity.test.ts @@ -208,7 +208,7 @@ describe("All language SDK operations remain in sync", () => { `${language} inbound request handlers must match TypeScript inbound request handlers`, ).toStrictEqual(typescriptInbound); } - }); + }, 15_000); it("keeps every registered notification in the generated protocol and every client", async () => { const [registry, protocol] = await Promise.all([