Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -69,6 +70,13 @@ interface BrowserLike {
snapshot(page: Page, options?: Record<string, any>): Promise<Snapshot>;
}

export interface AttachOptions {
apiKey?: string;
apiUrl?: string;
toolRegistry?: ToolRegistry;
browser?: BrowserLike;
}

const DEFAULT_CAPTCHA_OPTIONS: Required<Omit<CaptchaOptions, 'handler' | 'resetSession'>> = {
policy: 'abort',
minConfidence: 0.7,
Expand Down Expand Up @@ -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<string, any>) =>
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.
*
Expand Down
50 changes: 50 additions & 0 deletions src/debugger.ts
Original file line number Diff line number Diff line change
@@ -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<AgentRuntime['emitStepEnd']>[0] = {}): Promise<any> {
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> | void, stepIndex?: number): Promise<void> {
this.beginStep(goal, stepIndex);
try {
await fn();
} finally {
await this.endStep();
}
}

async snapshot(options?: Record<string, any>) {
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);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
69 changes: 69 additions & 0 deletions tests/agent-runtime-attach.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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();
});
});
69 changes: 69 additions & 0 deletions tests/debugger.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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');
});
});
Loading