diff --git a/sdk-typescript/packages/adk/package.json b/sdk-typescript/packages/adk/package.json index d4dcfca..2ead9e1 100644 --- a/sdk-typescript/packages/adk/package.json +++ b/sdk-typescript/packages/adk/package.json @@ -105,6 +105,7 @@ "node": ">=18.0.0" }, "dependencies": { + "@wave-av/kernel": "workspace:*", "zod": "^3.23.0" }, "devDependencies": { diff --git a/sdk-typescript/packages/adk/src/__tests__/kernel-adapter.test.ts b/sdk-typescript/packages/adk/src/__tests__/kernel-adapter.test.ts new file mode 100644 index 0000000..424f6b4 --- /dev/null +++ b/sdk-typescript/packages/adk/src/__tests__/kernel-adapter.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the shared Kernel client so the adapter's lifecycle (create → playwright +// execute → deleteByID) is observable without a live Kernel API key. +const create = vi.fn(); +const execute = vi.fn(); +const deleteByID = vi.fn().mockResolvedValue(undefined); + +vi.mock('@wave-av/kernel', async () => { + const actual = await vi.importActual('@wave-av/kernel'); + return { + ...actual, + WaveKernel: class { + browsers = { + create, + deleteByID, + playwright: { execute }, + }; + }, + }; +}); + +async function tools() { + const { createKernelTools } = await import('../adapters/kernel'); + return createKernelTools({ apiKey: 'test-key' }); +} + +function tool(name: string) { + return tools().then((ts) => ts.find((t) => t.name === name)!); +} + +/** The `code` string of the nth (0-indexed) `playwright.execute` call. */ +function executedCode(nth: number): string { + return (execute.mock.calls[nth][1] as { code: string }).code; +} + +beforeEach(() => { + create.mockReset().mockResolvedValue({ session_id: 'br_123', cdp_ws_url: 'ws://x' }); + execute.mockReset(); + deleteByID.mockReset().mockResolvedValue(undefined); +}); + +describe('Kernel adapter — lifecycle', () => { + it('browse_url creates a session, runs goto/title/content code, then tears it down', async () => { + execute.mockResolvedValue({ success: true, result: { title: 'T', content: '' } }); + const browse = await tool('browse_url'); + + const out = await browse.handler({ url: 'https://example.com' }); + + expect(create).toHaveBeenCalledOnce(); + expect(execute).toHaveBeenCalledWith('br_123', { code: expect.stringContaining('page.goto') }); + expect(executedCode(0)).toContain('page.title'); + expect(deleteByID).toHaveBeenCalledWith('br_123'); + expect(out).toEqual({ title: 'T', content: '' }); + }); + + it('waitForSelector is injected into the executed code when provided', async () => { + execute.mockResolvedValue({ success: true, result: {} }); + const browse = await tool('browse_url'); + + await browse.handler({ url: 'https://example.com', waitForSelector: '#player' }); + + expect(executedCode(0)).toContain('waitForSelector'); + expect(executedCode(0)).toContain('#player'); + }); + + it('take_screenshot returns base64 and uses fullPage when no selector', async () => { + execute.mockResolvedValue({ success: true, result: { screenshotBase64: 'AAAA', width: 800, height: 600 } }); + const shot = await tool('take_screenshot'); + + const out = await shot.handler({ url: 'https://example.com', width: 800, height: 600 }); + + expect(executedCode(0)).toContain('fullPage: true'); + expect(out).toEqual({ screenshotBase64: 'AAAA', width: 800, height: 600 }); + }); + + it('take_screenshot scopes to a locator (no fullPage) when a selector is given', async () => { + execute.mockResolvedValue({ success: true, result: {} }); + const shot = await tool('take_screenshot'); + + await shot.handler({ url: 'https://example.com', selector: '.video' }); + + const code = executedCode(0); + expect(code).toContain('page.locator'); + expect(code).not.toContain('fullPage'); + }); + + it('run_playwright navigates first when a url is given, then runs the user code', async () => { + execute.mockResolvedValue({ success: true, result: 'done' }); + const run = await tool('run_playwright'); + + const out = await run.handler({ code: 'return await page.title();', url: 'https://example.com' }); + + expect(executedCode(0)).toContain('page.goto'); + expect(execute).toHaveBeenNthCalledWith(2, 'br_123', { code: 'return await page.title();' }); + expect(out).toEqual({ output: 'done', logs: [] }); + }); + + it('run_playwright surfaces stdout/stderr as logs', async () => { + execute.mockResolvedValue({ success: true, result: 'ok', stdout: 'hello', stderr: 'warn' }); + const run = await tool('run_playwright'); + + const out = await run.handler({ code: 'return 1;' }); + + expect(out).toEqual({ output: 'ok', logs: ['hello', 'warn'] }); + }); + + it('a non-success execution surfaces a KernelApiError AND still tears the session down', async () => { + const { KernelApiError } = await import('@wave-av/kernel'); + execute.mockResolvedValue({ success: false, error: 'boom' }); + const browse = await tool('browse_url'); + + await expect(browse.handler({ url: 'https://example.com' })).rejects.toBeInstanceOf(KernelApiError); + expect(deleteByID).toHaveBeenCalledWith('br_123'); + }); +}); diff --git a/sdk-typescript/packages/adk/src/adapters/kernel.ts b/sdk-typescript/packages/adk/src/adapters/kernel.ts index 9eb2671..6e0ee0a 100644 --- a/sdk-typescript/packages/adk/src/adapters/kernel.ts +++ b/sdk-typescript/packages/adk/src/adapters/kernel.ts @@ -1,28 +1,42 @@ /** - * Kernel.sh Adapter for WAVE ADK + * Kernel Adapter for WAVE ADK * - * Provides cloud browser capabilities to WAVE agents via Kernel.sh: + * Provides cloud browser capabilities to WAVE agents via Kernel: * - Screenshot live stream players for visual QA * - Interact with third-party dashboards (Mux, Cloudflare) * - Run visual regression on embedded players * - Scrape competitor streaming pages for intelligence * - * Kernel provides managed cloud browsers (unikernel-based) - * with Playwright API, session persistence, and file I/O. + * Kernel provides managed cloud browsers (unikernel-based) with a Playwright + * execution surface, session persistence, and file I/O. + * + * All Kernel access goes through the shared {@link WaveKernel} client + * (`@wave-av/kernel`), never a hand-rolled fetch — one place for telemetry, + * resilience, auth, and the SDK's real resource surface + * (law: kernel-substrate-governed). * * @see https://docs.onkernel.com */ +import { WaveKernel, KernelApiError } from '@wave-av/kernel'; import { z } from 'zod'; import type { AgentTool } from '../tools/AgentToolkit.js'; export interface KernelConfig { readonly apiKey: string; + /** Override the Kernel API base URL. Defaults to the SDK default (`https://api.onkernel.com/`). */ readonly baseUrl?: string; } +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_WIDTH = 1920; +const DEFAULT_HEIGHT = 1080; + /** - * Create WAVE ADK tools backed by Kernel.sh cloud browsers. + * Create WAVE ADK tools backed by Kernel cloud browsers. + * + * Each tool spins up a fresh Kernel browser session, drives it through the + * SDK's Playwright execution surface, and always tears the session down. * * Usage: * ```typescript @@ -34,84 +48,152 @@ export interface KernelConfig { * ``` */ export function createKernelTools(config: KernelConfig): AgentTool[] { - const baseUrl = config.baseUrl ?? 'https://api.onkernel.com'; + const kernel = new WaveKernel({ + apiKey: config.apiKey, + ...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}), + }); - async function kernelFetch(path: string, body: Record): Promise { - const response = await fetch(`${baseUrl}${path}`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${config.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Kernel API error: ${response.status} ${response.statusText}`); + /** + * Create a fresh cloud browser, run `fn` against its session id, and always + * terminate the session afterwards (even if `fn` throws). Teardown failures + * are swallowed so they never mask the real result/error. + */ + async function withBrowser(fn: (browserId: string) => Promise): Promise { + const browser = await kernel.browsers.create({}); + try { + return await fn(browser.session_id); + } finally { + await kernel.browsers.deleteByID(browser.session_id).catch(() => {}); } + } - return response.json() as Promise; + /** + * Execute Playwright code in the browser VM. The code has access to `page`, + * `context`, and `browser`, and returns via `return`. A non-success result is + * surfaced through the shared Kernel error taxonomy. Returns the full + * execution response so callers can read `result`, `stdout`, and `stderr`. + */ + async function execute(browserId: string, code: string) { + const res = await kernel.browsers.playwright.execute(browserId, { code }); + if (!res.success) { + throw new KernelApiError(res.error ?? 'Playwright execution failed', undefined, { + browserId, + }); + } + return res; } - const browseSchema = z.object({ url: z.string().url(), waitForSelector: z.string().optional(), timeoutMs: z.number().optional() }); - const screenshotSchema = z.object({ url: z.string().url(), selector: z.string().optional(), width: z.number().optional(), height: z.number().optional() }); + const browseSchema = z.object({ + url: z.string().url(), + waitForSelector: z.string().optional(), + timeoutMs: z.number().optional(), + }); + const screenshotSchema = z.object({ + url: z.string().url(), + selector: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + }); const playwrightSchema = z.object({ code: z.string().min(1), url: z.string().url().optional() }); return [ { name: 'browse_url', - description: 'Navigate a cloud browser to a URL and return the page content/snapshot. Uses Kernel.sh managed browsers — no local browser needed.', + description: + 'Navigate a cloud browser to a URL and return the page title + HTML content. Uses Kernel managed browsers — no local browser needed.', parameters: { url: { type: 'string', description: 'URL to navigate to', required: true }, - waitForSelector: { type: 'string', description: 'CSS selector to wait for before capturing', required: false }, - timeoutMs: { type: 'number', description: 'Navigation timeout in milliseconds', required: false }, + waitForSelector: { + type: 'string', + description: 'CSS selector to wait for before capturing', + required: false, + }, + timeoutMs: { + type: 'number', + description: 'Navigation timeout in milliseconds', + required: false, + }, }, schema: browseSchema, handler: async (params: Record) => { - const result = await kernelFetch<{ browserId: string; content: string; title: string }>('/v1/browsers/navigate', { - url: params.url, - waitForSelector: params.waitForSelector, - timeout: params.timeoutMs ?? 30_000, + const p = browseSchema.parse(params); + const timeout = p.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return withBrowser(async (id) => { + const waitForSelector = p.waitForSelector + ? `await page.waitForSelector(${JSON.stringify(p.waitForSelector)}, { timeout: ${timeout} });` + : ''; + const code = [ + `await page.goto(${JSON.stringify(p.url)}, { waitUntil: 'load', timeout: ${timeout} });`, + waitForSelector, + `return { title: await page.title(), content: await page.content() };`, + ] + .filter(Boolean) + .join('\n'); + return (await execute(id, code)).result as { title: string; content: string }; }); - return result; }, }, { name: 'take_screenshot', - description: 'Take a screenshot of a URL using a cloud browser. Returns base64-encoded PNG. Useful for visual QA of live stream players.', + description: + 'Take a screenshot of a URL using a cloud browser. Returns a base64-encoded PNG. Useful for visual QA of live stream players.', parameters: { url: { type: 'string', description: 'URL to screenshot', required: true }, - selector: { type: 'string', description: 'CSS selector to screenshot (optional, defaults to full page)', required: false }, + selector: { + type: 'string', + description: 'CSS selector to screenshot (optional, defaults to full page)', + required: false, + }, width: { type: 'number', description: 'Viewport width in pixels', required: false }, height: { type: 'number', description: 'Viewport height in pixels', required: false }, }, schema: screenshotSchema, handler: async (params: Record) => { - const result = await kernelFetch<{ screenshotBase64: string; width: number; height: number }>('/v1/browsers/screenshot', { - url: params.url, - selector: params.selector, - viewport: { - width: params.width ?? 1920, - height: params.height ?? 1080, - }, + const p = screenshotSchema.parse(params); + const width = p.width ?? DEFAULT_WIDTH; + const height = p.height ?? DEFAULT_HEIGHT; + return withBrowser(async (id) => { + // Element screenshots can't use fullPage; full-page shots can. + const target = p.selector ? `page.locator(${JSON.stringify(p.selector)})` : 'page'; + const shotOpts = p.selector ? `{ type: 'png' }` : `{ type: 'png', fullPage: true }`; + const code = [ + `await page.setViewportSize({ width: ${width}, height: ${height} });`, + `await page.goto(${JSON.stringify(p.url)}, { waitUntil: 'load', timeout: ${DEFAULT_TIMEOUT_MS} });`, + `const buf = await ${target}.screenshot(${shotOpts});`, + `return { screenshotBase64: buf.toString('base64'), width: ${width}, height: ${height} };`, + ].join('\n'); + return (await execute(id, code)).result as { + screenshotBase64: string; + width: number; + height: number; + }; }); - return result; }, }, { name: 'run_playwright', - description: 'Execute Playwright code in a Kernel.sh cloud browser. For complex browser automation like testing embed players or monitoring dashboards.', + description: + 'Execute Playwright code in a Kernel cloud browser. For complex browser automation like testing embed players or monitoring dashboards.', parameters: { code: { type: 'string', description: 'Playwright JavaScript code to execute', required: true }, url: { type: 'string', description: 'Starting URL (optional)', required: false }, }, schema: playwrightSchema, handler: async (params: Record) => { - const result = await kernelFetch<{ output: string; logs: string[] }>('/v1/browsers/execute', { - code: params.code, - url: params.url, + const p = playwrightSchema.parse(params); + return withBrowser(async (id) => { + if (p.url) { + await execute( + id, + `await page.goto(${JSON.stringify(p.url)}, { waitUntil: 'load', timeout: ${DEFAULT_TIMEOUT_MS} });`, + ); + } + const res = await execute(id, p.code); + const logs = [res.stdout, res.stderr].filter( + (line): line is string => typeof line === 'string' && line.length > 0, + ); + return { output: res.result ?? '', logs }; }); - return result; }, }, ]; diff --git a/sdk-typescript/pnpm-lock.yaml b/sdk-typescript/pnpm-lock.yaml index 92faf40..5e35b6a 100644 --- a/sdk-typescript/pnpm-lock.yaml +++ b/sdk-typescript/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: packages/adk: dependencies: + '@wave-av/kernel': + specifier: workspace:* + version: link:../kernel zod: specifier: ^3.23.0 version: 3.25.76