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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Use `AgentRuntime` to add Jest-style assertions to your agent loops. Verify brow
import {
SentienceBrowser,
AgentRuntime,
HumanHandoffSolver,
urlContains,
exists,
allOf,
Expand Down Expand Up @@ -75,6 +76,12 @@ const ok = await runtime
.eventually({ timeoutMs: 10_000, pollMs: 250, minConfidence: 0.7, maxSnapshotAttempts: 3 });
console.log('eventually() result:', ok);

// CAPTCHA handling (detection + handoff + verify)
runtime.setCaptchaOptions({
policy: 'callback',
handler: HumanHandoffSolver(),
});

// Check task completion
if (runtime.assertDone(exists("text~'Example'"), 'task_complete')) {
console.log('✅ Task completed!');
Expand All @@ -83,6 +90,30 @@ if (runtime.assertDone(exists("text~'Example'"), 'task_complete')) {
console.log(`Task done: ${runtime.isTaskDone}`);
```

#### CAPTCHA strategies (Batteries Included)

```typescript
import { ExternalSolver, HumanHandoffSolver, VisionSolver } from 'sentienceapi';

// Human-in-loop
runtime.setCaptchaOptions({ policy: 'callback', handler: HumanHandoffSolver() });

// Vision verification only
runtime.setCaptchaOptions({ policy: 'callback', handler: VisionSolver() });

// External system/webhook
runtime.setCaptchaOptions({
policy: 'callback',
handler: ExternalSolver(async ctx => {
await fetch(process.env.CAPTCHA_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ runId: ctx.runId, url: ctx.url }),
});
}),
});
```

### Failure Artifact Buffer (Phase 1)

Capture a short ring buffer of screenshots and persist them when a required assertion fails.
Expand Down
48 changes: 48 additions & 0 deletions examples/agent-runtime-captcha-strategies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
AgentRuntime,
CaptchaOptions,
ExternalSolver,
HumanHandoffSolver,
SentienceBrowser,
VisionSolver,
} from 'sentienceapi';
import { createTracer } from 'sentienceapi';

async function notifyWebhook(ctx: any): Promise<void> {
console.log(`[captcha] external resolver notified: url=${ctx.url} run_id=${ctx.runId}`);
}

async function main(): Promise<void> {
const browser = await SentienceBrowser.create({ apiKey: process.env.SENTIENCE_API_KEY });
const tracer = await createTracer({ runId: 'captcha-demo', uploadTrace: false });

const browserAdapter = {
snapshot: async (_page: any, options?: Record<string, any>) => {
return await browser.snapshot(options);
},
};
const runtime = new AgentRuntime(browserAdapter as any, browser.getPage() as any, tracer);

// Option 1: Human-in-loop
runtime.setCaptchaOptions({ policy: 'callback', handler: HumanHandoffSolver() });

// Option 2: Vision-only verification (no actions)
runtime.setCaptchaOptions({ policy: 'callback', handler: VisionSolver() });

// Option 3: External resolver orchestration
runtime.setCaptchaOptions({
policy: 'callback',
handler: ExternalSolver(async ctx => notifyWebhook(ctx)),
});

await browser.getPage().goto(process.env.CAPTCHA_TEST_URL ?? 'https://example.com');
runtime.beginStep('Captcha-aware snapshot');
await runtime.snapshot();

await browser.close();
}

main().catch(err => {
console.error(err);
process.exit(1);
});
181 changes: 180 additions & 1 deletion src/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,27 @@ import { AssertContext, Predicate } from './verification';
import { Tracer } from './tracing/tracer';
import { LLMProvider } from './llm-provider';
import { FailureArtifactBuffer, FailureArtifactsOptions } from './failure-artifacts';
import {
CaptchaContext,
CaptchaHandlingError,
CaptchaOptions,
CaptchaResolution,
CaptchaSource,
} from './captcha/types';

// Define a minimal browser interface to avoid circular dependencies
interface BrowserLike {
snapshot(page: Page, options?: Record<string, any>): Promise<Snapshot>;
}

const DEFAULT_CAPTCHA_OPTIONS: Required<Omit<CaptchaOptions, 'handler' | 'resetSession'>> = {
policy: 'abort',
minConfidence: 0.7,
timeoutMs: 120_000,
pollMs: 1_000,
maxRetriesNewSession: 1,
};

/**
* Assertion record for accumulation and step_end emission.
*/
Expand Down Expand Up @@ -333,6 +348,10 @@ export class AgentRuntime {
private taskDone: boolean = false;
private taskDoneLabel: string | null = null;

/** CAPTCHA handling (optional, disabled by default) */
private captchaOptions: CaptchaOptions | null = null;
private captchaRetryCount: number = 0;

private static similarity(a: string, b: string): number {
const s1 = a.toLowerCase();
const s2 = b.toLowerCase();
Expand Down Expand Up @@ -422,6 +441,17 @@ export class AgentRuntime {
this.tracer = tracer;
}

/**
* Configure CAPTCHA handling (disabled by default unless set).
*/
setCaptchaOptions(options: CaptchaOptions): void {
this.captchaOptions = {
...DEFAULT_CAPTCHA_OPTIONS,
...options,
};
this.captchaRetryCount = 0;
}

/**
* Build assertion context from current state.
*/
Expand Down Expand Up @@ -449,10 +479,159 @@ export class AgentRuntime {
* @returns Snapshot of current page state
*/
async snapshot(options?: Record<string, any>): Promise<Snapshot> {
this.lastSnapshot = await this.browser.snapshot(this.page, options);
const { _skipCaptchaHandling, ...snapshotOptions } = options || {};
this.lastSnapshot = await this.browser.snapshot(this.page, snapshotOptions);
if (!_skipCaptchaHandling) {
await this.handleCaptchaIfNeeded(this.lastSnapshot, 'gateway');
}
return this.lastSnapshot;
}

private isCaptchaDetected(snapshot: Snapshot): boolean {
const options = this.captchaOptions;
if (!options) {
return false;
}
const captcha = snapshot.diagnostics?.captcha;
if (!captcha || !captcha.detected) {
return false;
}
const confidence = captcha.confidence ?? 0;
const minConfidence = options.minConfidence ?? DEFAULT_CAPTCHA_OPTIONS.minConfidence;
return confidence >= minConfidence;
}

private buildCaptchaContext(snapshot: Snapshot, source: CaptchaSource): CaptchaContext {
return {
runId: this.tracer.getRunId(),
stepIndex: this.stepIndex,
url: snapshot.url,
source,
captcha: snapshot.diagnostics?.captcha ?? null,
};
}

private emitCaptchaEvent(reasonCode: string, details: Record<string, any> = {}): void {
this.tracer.emit(
'verification',
{
kind: 'captcha',
passed: false,
label: reasonCode,
details: { reason_code: reasonCode, ...details },
},
this.stepId || undefined
);
}

private async handleCaptchaIfNeeded(snapshot: Snapshot, source: CaptchaSource): Promise<void> {
if (!this.captchaOptions) {
return;
}
if (!this.isCaptchaDetected(snapshot)) {
return;
}

const options = this.captchaOptions;
const minConfidence = options.minConfidence ?? DEFAULT_CAPTCHA_OPTIONS.minConfidence;
const captcha = snapshot.diagnostics?.captcha ?? null;

this.emitCaptchaEvent('captcha_detected', { captcha, min_confidence: minConfidence });

let resolution: CaptchaResolution;
if (options.policy === 'callback') {
if (!options.handler) {
this.emitCaptchaEvent('captcha_handler_error');
throw new CaptchaHandlingError(
'captcha_handler_error',
'Captcha handler is required for policy="callback".'
);
}
try {
resolution = await options.handler(this.buildCaptchaContext(snapshot, source));
} catch (err: any) {
this.emitCaptchaEvent('captcha_handler_error', { error: String(err?.message || err) });
throw new CaptchaHandlingError('captcha_handler_error', 'Captcha handler failed.', {
error: String(err?.message || err),
});
}
if (!resolution || !resolution.action) {
this.emitCaptchaEvent('captcha_handler_error');
throw new CaptchaHandlingError(
'captcha_handler_error',
'Captcha handler returned an invalid resolution.'
);
}
} else {
resolution = { action: 'abort' };
}

await this.applyCaptchaResolution(resolution, snapshot, source);
}

private async applyCaptchaResolution(
resolution: CaptchaResolution,
snapshot: Snapshot,
source: CaptchaSource
): Promise<void> {
const options = this.captchaOptions || DEFAULT_CAPTCHA_OPTIONS;
if (resolution.action === 'abort') {
this.emitCaptchaEvent('captcha_policy_abort', { message: resolution.message });
throw new CaptchaHandlingError(
'captcha_policy_abort',
resolution.message || 'Captcha detected. Aborting per policy.'
);
}

if (resolution.action === 'retry_new_session') {
this.captchaRetryCount += 1;
this.emitCaptchaEvent('captcha_retry_new_session');
if (this.captchaRetryCount > (options.maxRetriesNewSession ?? 1)) {
this.emitCaptchaEvent('captcha_retry_exhausted');
throw new CaptchaHandlingError(
'captcha_retry_exhausted',
'Captcha retry_new_session exhausted.'
);
}
const resetSession = this.captchaOptions?.resetSession;
if (!resetSession) {
throw new CaptchaHandlingError(
'captcha_retry_new_session',
'resetSession callback is required for retry_new_session.'
);
}
await resetSession();
return;
}

if (resolution.action === 'wait_until_cleared') {
const timeoutMs =
resolution.timeoutMs ?? options.timeoutMs ?? DEFAULT_CAPTCHA_OPTIONS.timeoutMs;
const pollMs = resolution.pollMs ?? options.pollMs ?? DEFAULT_CAPTCHA_OPTIONS.pollMs;
await this.waitUntilCleared(timeoutMs, pollMs, snapshot, source);
this.emitCaptchaEvent('captcha_resumed');
}
}

private async waitUntilCleared(
timeoutMs: number,
pollMs: number,
snapshot: Snapshot,
source: CaptchaSource
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
await new Promise(res => setTimeout(res, pollMs));
const next = await this.snapshot({ _skipCaptchaHandling: true });
if (!this.isCaptchaDetected(next)) {
this.emitCaptchaEvent('captcha_cleared', { source });
return;
}
}
this.emitCaptchaEvent('captcha_wait_timeout', { timeout_ms: timeoutMs });
throw new CaptchaHandlingError('captcha_wait_timeout', 'Captcha wait_until_cleared timed out.');
}

/**
* Enable failure artifact buffer (Phase 1).
*/
Expand Down
51 changes: 51 additions & 0 deletions src/captcha/strategies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { CaptchaHandler, CaptchaResolution } from './types';

type StrategyOptions = {
message?: string;
handledBy?: 'human' | 'customer_system' | 'unknown';
timeoutMs?: number;
pollMs?: number;
};

export function HumanHandoffSolver(options: StrategyOptions = {}): CaptchaHandler {
return () => {
const resolution: CaptchaResolution = {
action: 'wait_until_cleared',
message: options.message ?? 'Solve CAPTCHA in the live session, then resume.',
handledBy: options.handledBy ?? 'human',
timeoutMs: options.timeoutMs,
pollMs: options.pollMs,
};
return Promise.resolve(resolution);
};
}

export function VisionSolver(options: StrategyOptions = {}): CaptchaHandler {
return () => {
const resolution: CaptchaResolution = {
action: 'wait_until_cleared',
message: options.message ?? 'Waiting for CAPTCHA to clear (vision verification).',
handledBy: options.handledBy ?? 'customer_system',
timeoutMs: options.timeoutMs,
pollMs: options.pollMs,
};
return Promise.resolve(resolution);
};
}

export function ExternalSolver(
resolver: (ctx: any) => Promise<void>,
options: StrategyOptions = {}
): CaptchaHandler {
return async ctx => {
await resolver(ctx);
const resolution: CaptchaResolution = {
action: 'wait_until_cleared',
message: options.message ?? 'External solver invoked; waiting for clearance.',
handledBy: options.handledBy ?? 'customer_system',
timeoutMs: options.timeoutMs,
pollMs: options.pollMs,
};
return resolution;
};
}
Loading
Loading