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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ jobs:
- run: npm test
# Live end-to-end navigation only when a TYPESAFE_API_KEY secret is set.
- name: Install Chromium for e2e
id: install-chromium
if: env.TYPESAFE_API_KEY != ''
env:
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
run: npx playwright install --with-deps chromium
- name: Transport integration (optional)
if: steps.install-chromium.outcome == 'success'
run: node --test test/transports.test.mjs
- name: End-to-end (optional)
if: env.TYPESAFE_API_KEY != ''
env:
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

- Transport drivers: the four judgment transports (TypeSafe, OpenRouter, Cloudflare, Vercel) are now run-bound drivers behind one registry. Typing providers are unchanged.
- An unknown `JEV_PROVIDER` now errors instead of silently falling through to auto-detection; the no-provider diagnostic names all four credential sets.
- Every answer is validated at a shared boundary before tokens are credited or an action executes: missing or malformed answers error the run, and transport errors from built-in providers no longer include raw response bodies.
- Removed the select-option fallback to the first option; a malformed option judgment errors the run without selecting anything.
- Library callers can inject a transport with `NavigateOptions.transport`; results report its name and effective model. `est_cost_usd` stays a Jev-token estimate.


## 0.5.0

- Cloudflare challenges and hard blocks are now detected and named: the run stops with status `blocked` plus `bot_protection` evidence and guidance, instead of burning steps against a wall. Challenges get a short window to clear first.
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,30 @@ console.log(result.status, result.final_url);
console.log(result.page.content);
```

### Judgment transports in the library

The built-in Jev transports are TypeSafe, OpenRouter, Cloudflare, and Vercel, selected in that order from configured credentials. `JEV_PROVIDER` forces one of them; an unknown name or missing credential is an error. This is separate from the typing model. Library callers can provide a transport instead, bypassing judgment provider detection without changing typing configuration:

```ts
import { navigate, type JevTransport } from "@jkudish/jev-browser";

const transport: JevTransport = {
name: "my-gateway", // reported as jev_provider
async ask({ state, questions, model, signal }) {
const response = await myGateway.decide({ state, questions, model, signal });
return {
answers: response.answers,
usage: { input_tokens: response.inputTokens, output_tokens: response.outputTokens },
model: response.effectiveModel,
};
},
};

const result = await navigate({ task: "Find the price", startUrl: "https://example.com", transport });
```

`ask` receives the page state, named questions, requested model, and run abort signal. Return an answer for every requested ID in the matching Jev shape, plus nonnegative integer token counts and the effective model. Choice distributions must cover exactly the offered criteria, sum to about 1, and select a maximum; Noul values must be in [0,1]. Invalid answers stop the run before the action executes. `est_cost_usd` stays a Jev-token estimate, not verified billing for injected carriers.

## Password fill (logins)

The agent can fill native password fields without the password ever reaching a model. The value arrives through one of three channels, lives in memory for a single run, and is scrubbed from every state, trace, error, URL, and payload the run produces. Video recording is refused on credential runs and the final screenshot is suppressed once a fill is attempted (on injected pages, which may already show the value, from the start of the run). A fill never submits: no Enter, no click.
Expand Down Expand Up @@ -409,6 +433,7 @@ With no provider at all, or when the typing model fails or returns empty text, t
| `TYPESAFE_API_KEY` | none | TypeSafe direct. Default provider when set. |
| `OPENROUTER_API_KEY` | none | Powers both the Jev judgments (when `TYPESAFE_API_KEY` is absent) and, optionally, the typing model. One key runs everything. |
| `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` | none | Cloudflare Workers AI for the Jev judgments; used when no other provider key is present. |
| `AI_GATEWAY_API_KEY` | none | Vercel AI Gateway for Jev judgments after the other providers. |
| `JEV_PROVIDER` | `auto` | Force `typesafe`, `openrouter`, `cloudflare`, or `vercel` for the Jev calls instead of auto-detection. Judgment transport only; typing is configured separately with `JEV_BROWSER_TYPE_*`. |
| `JEV_BROWSER_MODEL` | `jev-latest` | Pin a Jev version, or `typesafe/jev-1.13` on OpenRouter. |
| `JEV_BROWSER_TYPE_*` | see above | Typing provider, model, and endpoint. |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"build": "tsc",
"prepare": "npm run build",
"typecheck": "tsc --noEmit",
"test": "node --test test/unit.test.mjs",
"test": "node --test test/unit.test.mjs test/transports.test.mjs",
"test:e2e": "node --test test/e2e.test.mjs",
"postinstall": "node scripts/ensure-chromium.mjs"
},
Expand Down
1 change: 1 addition & 0 deletions src/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { navigate } from "./navigate.js";
export type { NavigateOptions, StepRecord, ConsoleEvent, JevUsage } from "./navigate.js";
export type { TypingGenerator, TypingTextResult } from "./navigate.js";
export type { TypingWarning, TypingWarningCode, TypingSelection } from "./lib.js";
export type { JevTransport, JevTransportInput, JevTransportReply, AskResult } from "./provider.js";
33 changes: 21 additions & 12 deletions src/navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "./lib.js";
import { selectOptionQuestion, stepQuestions } from "./questions.js";
import { assertNoPlaywrightDebug, makeRedactor, parseTrustedOrigin, validateSecretBuffer, type Redactor } from "./password.js";
import { askJev as askProvider, InvalidJevAnswer, resolveTransport, type JevTransport, type JevAnswer } from "./provider.js";

const MAX_CONSOLE_EVENTS = 200;
const STATE_EXCERPT_CHARS = 1_500;
Expand All @@ -46,6 +47,8 @@ export interface NavigateOptions {
startUrl?: string;
/** Reuse an existing Playwright page instead of launching a new browser. */
page?: Page;
/** Override judgment transport for this run, independent of JEV_PROVIDER and credentials. */
transport?: JevTransport;
maxSteps?: number;
maxSeconds?: number;
allowTyping?: boolean;
Expand Down Expand Up @@ -108,22 +111,21 @@ const DEFAULT_CAPS: Record<string, number> = {
const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
turndown.use(gfm.gfm);

import { askJev as askProvider, type JevProvider } from "./provider.js";

interface RunBudget {
usage: JevUsage;
transport: JevTransport;
signal: AbortSignal;
deadlineAt: number; // performance.now() milliseconds
// Per-run model/provider state: resolved inside navigate() and mutated only
// by this run's askJev calls, so concurrent runs cannot report each other's
// provider and a failed run cannot inherit values from a previous one.
requestedModel: string;
model: string; // model reported by the most recent Jev call
provider: JevProvider | null;
provider: string | null;
}

async function askJev(budget: RunBudget, state: unknown, questions: Record<string, unknown>) {
const result = await askProvider(state, questions, budget.requestedModel, budget.signal);
const result = await askProvider(budget.transport, { state, questions, model: budget.requestedModel, signal: budget.signal });
budget.provider = result.provider;
budget.model = result.model;
budget.usage.jev_calls += 1;
Expand Down Expand Up @@ -496,6 +498,7 @@ export async function navigate(options: NavigateOptions, externalSignal?: AbortS
// another provider. Runs with typing disabled ignore typing config at all,
// so a broken config can always be worked around with allowTyping: false.
const typingGenerator = allowTyping ? createTypingGenerator() : null;
const transport = options.transport ?? resolveTransport();

// One abort source per run: the wall-clock deadline, optionally composed
// with caller cancellation (the MCP layer forwards its signal).
Expand All @@ -510,6 +513,7 @@ export async function navigate(options: NavigateOptions, externalSignal?: AbortS
const requestedModel = process.env.JEV_BROWSER_MODEL ?? "jev-latest";
const budget: RunBudget = {
usage: { jev_calls: 0, input_tokens: 0, output_tokens: 0, est_cost_usd: 0 },
transport,
signal: controller.signal,
deadlineAt,
requestedModel,
Expand Down Expand Up @@ -759,7 +763,7 @@ export async function navigate(options: NavigateOptions, externalSignal?: AbortS
history,
};
const answers = await askJev(budget, state, stepQuestions(buildCriteria(elements)));
const actionAnswer = answers.action;
const actionAnswer = answers.action as Extract<JevAnswer, { type: "choice" }>;
const proposed: string = actionAnswer.choice;
const probabilities: Record<string, number> = actionAnswer.probabilities ?? {};
const base = {
Expand All @@ -768,8 +772,8 @@ export async function navigate(options: NavigateOptions, externalSignal?: AbortS
proposed_action: proposed,
confidence: actionAnswer.confidence ?? null,
top_probability: probabilities[proposed] ?? null,
goal_done: answers.goal_done.noul,
stuck: answers.stuck.noul,
goal_done: (answers.goal_done as Extract<JevAnswer, { type: "noul" }>).noul,
stuck: (answers.stuck as Extract<JevAnswer, { type: "noul" }>).noul,
};

// Stop gates run BEFORE execution: a watcher that fires on the current
Expand All @@ -779,12 +783,12 @@ export async function navigate(options: NavigateOptions, externalSignal?: AbortS
status = "done";
break;
}
if (answers.goal_done.noul > 0.85) {
if ((answers.goal_done as Extract<JevAnswer, { type: "noul" }>).noul > 0.85) {
steps.push({ ...base, executed_action: null, detail: "goal watcher fired; proposed action not executed", outcome: "goal watcher fired before acting" });
status = "goal_achieved";
break;
}
if (answers.stuck.noul > 0.85 && step > 2) {
if ((answers.stuck as Extract<JevAnswer, { type: "noul" }>).noul > 0.85 && step > 2) {
steps.push({ ...base, executed_action: null, detail: "stuck watcher fired; proposed action not executed", outcome: "stuck watcher fired before acting" });
status = "stuck";
break;
Expand Down Expand Up @@ -904,9 +908,13 @@ export async function navigate(options: NavigateOptions, externalSignal?: AbortS
budget,
{ task: safeTask, page: { url: R(observables.url), title: R(observables.title) }, dropdown: element.description, options: opts.map((o) => o.label) },
{ option: selectOptionQuestion(element.description, opts.map((o) => o.label)) },
);
const pickedIndex = Number((optionAnswer.option.choice as string).slice(1));
const opt = opts[pickedIndex] ?? opts[0];
).catch((error) => {
if (budget.signal.aborted) throw budget.signal.reason;
if (error instanceof InvalidJevAnswer) throw error;
throw new InvalidJevAnswer(`Jev provider ${budget.transport.name} question option: request failed`);
});
const pickedIndex = Number((optionAnswer.option as Extract<JevAnswer, { type: "choice" }>).choice.slice(1));
const opt = opts[pickedIndex];
await page.selectOption(selectorFor(element), { index: opt.i }, { timeout: bounded(4_000) });
detail = `selected "${opt.label}"`;
}
Expand Down Expand Up @@ -958,6 +966,7 @@ export async function navigate(options: NavigateOptions, externalSignal?: AbortS
}
} catch (error) {
if (controller.signal.aborted) throw error; // deadline/cancellation propagates
if (error instanceof InvalidJevAnswer) throw error; // malformed second-stage answer is a run error, never an action fallback
actionError = R((error as Error).message).slice(0, 160);
}

Expand Down
Loading
Loading