From 3c95f9db4b7df943311a40d5f75345c8ba10c4c8 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Thu, 29 Jan 2026 17:49:10 -0800 Subject: [PATCH] Sentience Debugger for any agent --- src/agent-runtime.ts | 32 ++++++++++++++ src/debugger.ts | 50 ++++++++++++++++++++++ src/index.ts | 1 + tests/agent-runtime-attach.test.ts | 69 ++++++++++++++++++++++++++++++ tests/debugger.test.ts | 69 ++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 src/debugger.ts create mode 100644 tests/agent-runtime-attach.test.ts create mode 100644 tests/debugger.test.ts diff --git a/src/agent-runtime.ts b/src/agent-runtime.ts index c624d4d..68652d6 100644 --- a/src/agent-runtime.ts +++ b/src/agent-runtime.ts @@ -55,6 +55,7 @@ import { Tracer } from './tracing/tracer'; import { TraceEventBuilder } from './utils/trace-event-builder'; import { LLMProvider } from './llm-provider'; import { FailureArtifactBuffer, FailureArtifactsOptions } from './failure-artifacts'; +import { SentienceBrowser } from './browser'; import type { ToolRegistry } from './tools/registry'; import { CaptchaContext, @@ -69,6 +70,13 @@ interface BrowserLike { snapshot(page: Page, options?: Record): Promise; } +export interface AttachOptions { + apiKey?: string; + apiUrl?: string; + toolRegistry?: ToolRegistry; + browser?: BrowserLike; +} + const DEFAULT_CAPTCHA_OPTIONS: Required> = { policy: 'abort', minConfidence: 0.7, @@ -464,6 +472,30 @@ export class AgentRuntime { return new AssertionHandle(this, predicate, label, required); } + /** + * Create AgentRuntime from a raw Playwright Page (sidecar mode). + */ + static fromPlaywrightPage(page: Page, tracer: Tracer, options?: AttachOptions): AgentRuntime { + const browser = + options?.browser ?? + ((): BrowserLike => { + const sentienceBrowser = SentienceBrowser.fromPage(page, options?.apiKey, options?.apiUrl); + return { + snapshot: async (_page: Page, snapshotOptions?: Record) => + sentienceBrowser.snapshot(snapshotOptions), + }; + })(); + + return new AgentRuntime(browser, page, tracer, options?.toolRegistry); + } + + /** + * Sidecar alias for fromPlaywrightPage(). + */ + static attach(page: Page, tracer: Tracer, options?: AttachOptions): AgentRuntime { + return AgentRuntime.fromPlaywrightPage(page, tracer, options); + } + /** * Create a new AgentRuntime. * diff --git a/src/debugger.ts b/src/debugger.ts new file mode 100644 index 0000000..80380eb --- /dev/null +++ b/src/debugger.ts @@ -0,0 +1,50 @@ +import { Page } from 'playwright'; + +import { AgentRuntime, AttachOptions } from './agent-runtime'; +import { Predicate } from './verification'; +import { Tracer } from './tracing/tracer'; + +export class SentienceDebugger { + readonly runtime: AgentRuntime; + private stepOpen: boolean = false; + + constructor(runtime: AgentRuntime) { + this.runtime = runtime; + } + + static attach(page: Page, tracer: Tracer, options?: AttachOptions): SentienceDebugger { + const runtime = AgentRuntime.fromPlaywrightPage(page, tracer, options); + return new SentienceDebugger(runtime); + } + + beginStep(goal: string, stepIndex?: number): string { + this.stepOpen = true; + return this.runtime.beginStep(goal, stepIndex); + } + + async endStep(opts: Parameters[0] = {}): Promise { + this.stepOpen = false; + // emitStepEnd is synchronous; wrap to satisfy async/await lint rules. + return await Promise.resolve(this.runtime.emitStepEnd(opts)); + } + + async step(goal: string, fn: () => Promise | void, stepIndex?: number): Promise { + this.beginStep(goal, stepIndex); + try { + await fn(); + } finally { + await this.endStep(); + } + } + + async snapshot(options?: Record) { + return this.runtime.snapshot(options); + } + + check(predicate: Predicate, label: string, required: boolean = false) { + if (!this.stepOpen) { + this.beginStep(`verify:${label}`); + } + return this.runtime.check(predicate, label, required); + } +} diff --git a/src/index.ts b/src/index.ts index c24f865..a42f011 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,7 @@ export { isCollapsed, } from './verification'; export { AgentRuntime, AssertionHandle, AssertionRecord, EventuallyOptions } from './agent-runtime'; +export { SentienceDebugger } from './debugger'; export { RuntimeAgent } from './runtime-agent'; export type { RuntimeStep, StepVerification } from './runtime-agent'; export { parseVisionExecutorAction, executeVisionExecutorAction } from './vision-executor'; diff --git a/tests/agent-runtime-attach.test.ts b/tests/agent-runtime-attach.test.ts new file mode 100644 index 0000000..0b50ffa --- /dev/null +++ b/tests/agent-runtime-attach.test.ts @@ -0,0 +1,69 @@ +import { AgentRuntime } from '../src/agent-runtime'; +import { SentienceBrowser } from '../src/browser'; +import { TraceSink } from '../src/tracing/sink'; +import { Tracer } from '../src/tracing/tracer'; +import { MockPage } from './mocks/browser-mock'; + +class MockSink extends TraceSink { + emit(): void { + // no-op + } + async close(): Promise { + // no-op + } + getSinkType(): string { + return 'MockSink'; + } +} + +describe('AgentRuntime.fromPlaywrightPage()', () => { + it('creates runtime using SentienceBrowser.fromPage()', () => { + const sink = new MockSink(); + const tracer = new Tracer('test-run', sink); + const page = new MockPage('https://example.com') as any; + const browserLike = { + snapshot: async () => ({ + status: 'success', + url: 'https://example.com', + elements: [], + timestamp: 't1', + }), + }; + + const spy = jest.spyOn(SentienceBrowser, 'fromPage').mockReturnValue(browserLike as any); + + const runtime = AgentRuntime.fromPlaywrightPage(page, tracer); + + expect(spy).toHaveBeenCalledWith(page, undefined, undefined); + expect(typeof runtime.browser.snapshot).toBe('function'); + expect(runtime.page).toBe(page); + + spy.mockRestore(); + }); + + it('passes apiKey and apiUrl to SentienceBrowser.fromPage()', () => { + const sink = new MockSink(); + const tracer = new Tracer('test-run', sink); + const page = new MockPage('https://example.com') as any; + const browserLike = { + snapshot: async () => ({ + status: 'success', + url: 'https://example.com', + elements: [], + timestamp: 't1', + }), + }; + + const spy = jest.spyOn(SentienceBrowser, 'fromPage').mockReturnValue(browserLike as any); + + const runtime = AgentRuntime.fromPlaywrightPage(page, tracer, { + apiKey: 'sk_test', + apiUrl: 'https://api.example.com', + }); + + expect(spy).toHaveBeenCalledWith(page, 'sk_test', 'https://api.example.com'); + expect(typeof runtime.browser.snapshot).toBe('function'); + + spy.mockRestore(); + }); +}); diff --git a/tests/debugger.test.ts b/tests/debugger.test.ts new file mode 100644 index 0000000..e3a1267 --- /dev/null +++ b/tests/debugger.test.ts @@ -0,0 +1,69 @@ +import { AgentRuntime } from '../src/agent-runtime'; +import { SentienceDebugger } from '../src/debugger'; +import { TraceSink } from '../src/tracing/sink'; +import { Tracer } from '../src/tracing/tracer'; +import { MockPage } from './mocks/browser-mock'; + +class MockSink extends TraceSink { + emit(): void { + // no-op + } + async close(): Promise { + // no-op + } + getSinkType(): string { + return 'MockSink'; + } +} + +describe('SentienceDebugger', () => { + it('attaches via AgentRuntime.fromPlaywrightPage()', () => { + const sink = new MockSink(); + const tracer = new Tracer('test-run', sink); + const page = new MockPage('https://example.com') as any; + const runtime = {} as AgentRuntime; + + const spy = jest.spyOn(AgentRuntime, 'fromPlaywrightPage').mockReturnValue(runtime); + + const dbg = SentienceDebugger.attach(page, tracer, { apiKey: 'sk', apiUrl: 'https://api' }); + + expect(spy).toHaveBeenCalledWith(page, tracer, { apiKey: 'sk', apiUrl: 'https://api' }); + expect(dbg.runtime).toBe(runtime); + + spy.mockRestore(); + }); + + it('step() calls beginStep and emitStepEnd', async () => { + const runtime = { + beginStep: jest.fn().mockReturnValue('step-1'), + emitStepEnd: jest.fn().mockResolvedValue({}), + } as unknown as AgentRuntime; + + const dbg = new SentienceDebugger(runtime); + + await dbg.step('verify-cart', async () => { + // no-op + }); + + expect(runtime.beginStep).toHaveBeenCalledWith('verify-cart', undefined); + expect(runtime.emitStepEnd).toHaveBeenCalled(); + }); + + it('check() auto-opens a step if missing', () => { + const runtime = { + beginStep: jest.fn().mockReturnValue('step-1'), + check: jest.fn().mockReturnValue('handle'), + } as unknown as AgentRuntime; + + const dbg = new SentienceDebugger(runtime); + + const handle = dbg.check( + (_ctx: any) => ({ passed: true, reason: '', details: {} }), + 'has_cart' + ); + + expect(runtime.beginStep).toHaveBeenCalledWith('verify:has_cart', undefined); + expect(runtime.check).toHaveBeenCalled(); + expect(handle).toBe('handle'); + }); +});