diff --git a/.gitignore b/.gitignore index d98d51a..03f2361 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist-deno /*.tgz .idea/ +.env \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..dc0bb0f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v22.12.0 diff --git a/examples/demo.ts b/examples/demo.ts new file mode 100755 index 0000000..6b976bd --- /dev/null +++ b/examples/demo.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env -S npm run tsn -T + +import { BrowserUse } from 'browser-use-sdk'; +import { TaskView } from 'browser-use-sdk/resources'; +import { spinner } from './utils'; + +// gets API Key from environment variable BROWSER_USE_API_KEY +const browseruse = new BrowserUse(); + +async function main() { + let log = 'starting'; + const stop = spinner(() => log); + + // Create Task + const rsp = await browseruse.tasks.create({ + task: "What's the weather line in SF and what's the temperature?", + }); + + poll: do { + // Wait for Task to Finish + const status = (await browseruse.tasks.retrieve(rsp.id, { statusOnly: false })) as TaskView; + + switch (status.status) { + case 'started': + case 'paused': + case 'stopped': + log = `agent ${status.status} - live: ${status.sessionLiveUrl}`; + + await new Promise((resolve) => setTimeout(resolve, 2000)); + break; + + case 'finished': + stop(); + + console.log(status.doneOutput); + break poll; + } + } while (true); +} + +main().catch(console.error); diff --git a/examples/structured-output.ts b/examples/structured-output.ts new file mode 100755 index 0000000..f03e2ec --- /dev/null +++ b/examples/structured-output.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env -S npm run tsn -T + +import { BrowserUse } from 'browser-use-sdk'; +import { z } from 'zod'; +import { spinner } from './utils'; + +// gets API Key from environment variable BROWSER_USE_API_KEY +const browseruse = new BrowserUse(); + +// Define Structured Output Schema +const HackerNewsResponse = z.object({ + title: z.string(), + url: z.string(), + score: z.number(), +}); + +const TaskOutput = z.object({ + posts: z.array(HackerNewsResponse), +}); + +async function main() { + let log = 'starting'; + const stop = spinner(() => log); + + // Create Task + const rsp = await browseruse.tasks.createWithStructuredOutput({ + task: 'Extract top 10 Hacker News posts and return the title, url, and score', + structuredOutputJson: TaskOutput, + }); + + poll: do { + // Wait for Task to Finish + const status = await browseruse.tasks.retrieveWithStructuredOutput(rsp.id, { + structuredOutputJson: TaskOutput, + }); + + switch (status.status) { + case 'started': + case 'paused': + case 'stopped': { + const stepsCount = status.steps ? status.steps.length : 0; + const steps = `${stepsCount} steps`; + const lastGoalDescription = stepsCount > 0 ? status.steps![stepsCount - 1]!.nextGoal : undefined; + const lastGoal = lastGoalDescription ? `, last: ${lastGoalDescription}` : ''; + const liveUrl = status.sessionLiveUrl ? `, live: ${status.sessionLiveUrl}` : ''; + + log = `agent ${status.status} (${steps}${lastGoal}${liveUrl}) `; + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + break; + } + + case 'finished': + stop(); + + // Print Structured Output + console.log('TOP POSTS:'); + + for (const post of status.doneOutput!.posts) { + console.log(` - ${post.title} (${post.score}) ${post.url}`); + } + + break poll; + } + } while (true); +} + +main().catch(console.error); diff --git a/examples/utils.ts b/examples/utils.ts new file mode 100644 index 0000000..cc89cad --- /dev/null +++ b/examples/utils.ts @@ -0,0 +1,28 @@ +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/** + * Start a spinner that updates the text every 100ms. + * + * @param renderText - A function that returns the text to display. + * @returns A function to stop the spinner. + */ +export function spinner(renderText: () => string): () => void { + let frameIndex = 0; + const interval = setInterval(() => { + const frame = SPINNER_FRAMES[frameIndex++ % SPINNER_FRAMES.length]; + const text = `${frame} ${renderText()}`; + if (typeof process.stdout.clearLine === 'function') { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + } + process.stdout.write(text); + }, 100); + + return () => { + clearInterval(interval); + if (typeof process.stdout.clearLine === 'function') { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + } + }; +} diff --git a/package.json b/package.json index 5de3b6e..81f3614 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,11 @@ "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", "typescript": "5.8.3", - "typescript-eslint": "8.31.1" + "typescript-eslint": "8.31.1", + "zod": "^4.0.17" + }, + "peerDependencies": { + "zod": "^4.0.17" }, "exports": { ".": { diff --git a/src/lib/parse.ts b/src/lib/parse.ts new file mode 100644 index 0000000..c872e6e --- /dev/null +++ b/src/lib/parse.ts @@ -0,0 +1,58 @@ +import z, { type ZodType } from 'zod'; +import type { TaskCreateParams, TaskRetrieveParams, TaskView } from '../resources/tasks'; + +// RUN + +export type RunTaskCreateParamsWithStructuredOutput = Omit< + TaskCreateParams, + 'structuredOutputJson' +> & { + structuredOutputJson: T; +}; + +export function stringifyStructuredOutput( + req: RunTaskCreateParamsWithStructuredOutput, +): TaskCreateParams { + return { + ...req, + structuredOutputJson: JSON.stringify(z.toJSONSchema(req.structuredOutputJson)), + }; +} + +// RETRIEVE + +export type GetTaskStatusParamsWithStructuredOutput = Omit< + TaskRetrieveParams, + 'statusOnly' +> & { + statusOnly?: false; + structuredOutputJson: T; +}; + +export type TaskViewWithStructuredOutput = Omit & { + doneOutput: z.output | null; +}; + +export function parseStructuredTaskOutput( + res: TaskView, + body: GetTaskStatusParamsWithStructuredOutput, +): TaskViewWithStructuredOutput { + try { + const parsed = JSON.parse(res.doneOutput); + + const response = body.structuredOutputJson.safeParse(parsed); + if (!response.success) { + throw new Error(`Invalid structured output: ${response.error.message}`); + } + + return { ...res, doneOutput: response.data }; + } catch (e) { + if (e instanceof SyntaxError) { + return { + ...res, + doneOutput: null, + }; + } + throw e; + } +} diff --git a/src/resources/tasks.ts b/src/resources/tasks.ts index 4ced3ed..18ca698 100644 --- a/src/resources/tasks.ts +++ b/src/resources/tasks.ts @@ -1,10 +1,19 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +import type { ZodType } from 'zod'; + import { APIResource } from '../core/resource'; import * as TasksAPI from './tasks'; import { APIPromise } from '../core/api-promise'; import { RequestOptions } from '../internal/request-options'; import { path } from '../internal/utils/path'; +import { + parseStructuredTaskOutput, + stringifyStructuredOutput, + type TaskViewWithStructuredOutput, + type GetTaskStatusParamsWithStructuredOutput, + type RunTaskCreateParamsWithStructuredOutput, +} from '../lib/parse'; export class Tasks extends APIResource { /** @@ -14,6 +23,15 @@ export class Tasks extends APIResource { return this._client.post('/tasks', { body, ...options }); } + createWithStructuredOutput( + body: RunTaskCreateParamsWithStructuredOutput, + options?: RequestOptions, + ): APIPromise> { + return this.create(stringifyStructuredOutput(body), options)._thenUnwrap((rsp) => + parseStructuredTaskOutput(rsp as TaskView, body), + ); + } + /** * Get Task */ @@ -25,6 +43,20 @@ export class Tasks extends APIResource { return this._client.get(path`/tasks/${taskID}`, { query, ...options }); } + retrieveWithStructuredOutput( + taskID: string, + query: GetTaskStatusParamsWithStructuredOutput, + options?: RequestOptions, + ): APIPromise> { + // NOTE: We manually remove structuredOutputJson from the query object because + // it's not a valid Browser Use Cloud parameter. + const { structuredOutputJson, ...rest } = query; + + return this.retrieve(taskID, rest, options)._thenUnwrap((rsp) => + parseStructuredTaskOutput(rsp as TaskView, query), + ); + } + /** * Update Task */ diff --git a/yarn.lock b/yarn.lock index 58c08d5..43b21d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3498,3 +3498,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^4.0.17: + version "4.0.17" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.17.tgz#95931170715f73f7426c385c237b7477750d6c8d" + integrity sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==