Skip to content

Commit ed61ba3

Browse files
committed
Add Jev browser agent loop example
1 parent 34c2a00 commit ed61ba3

16 files changed

Lines changed: 2104 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ jobs:
2121
cache: npm
2222
- run: npm run check:lockfile
2323
- run: npm ci
24+
- name: Install Jev example dependencies
25+
working-directory: packages/browser-loop/examples/jev-system-one
26+
run: npm ci
27+
- name: Jev example tests
28+
working-directory: packages/browser-loop/examples/jev-system-one
29+
run: npm run typecheck && npm test
2430
# The pi print/RPC test loads the extension the way pi does, through the
2531
# package's own entry points, so dist has to exist before the unit run.
2632
- run: npm run build --workspace @onkernel/browser-loop
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Jev browser agent loop
2+
3+
This example runs a custom browser-agent loop with TypeSafe AI's Jev. It does not register Jev as a chat-model provider or expose Browser Loop tools to Jev. Code observes the browser, enumerates a bounded candidate space, asks Jev to choose an operation and target, lowers that candidate to a canonical Browser Loop action, and executes it through `BrowserExecutor`.
4+
5+
The loop uses:
6+
7+
- page-specific `CLICK`, `TYPE_TEXT`, `SELECT`, `SCROLL`, and `WAIT` candidates;
8+
- speculative operation and target questions in one System One request;
9+
- a small text-model escape hatch only after Jev selects a field or navigation operation;
10+
- code-owned freshness checks, step limits, and repeated-no-change detection;
11+
- `DONE` and `BLOCKED` as explicit Jev choices.
12+
13+
Navigation is part of the loop. A new browser starts on `about:blank` or an internal `chrome://` new-tab page; those startup pages expose only navigation and terminal candidates. Jev sees the goal plus the current URL, title, text, elements, values, and recent actions, then chooses `NAVIGATE`. Literal URLs in the task become bounded candidates. Otherwise the text resolver produces the destination URL.
14+
15+
## Data flow
16+
17+
```mermaid
18+
flowchart LR
19+
O[Browser observation] --> C[Build candidate space]
20+
C --> J[Jev operation and target]
21+
J --> R{Needs text?}
22+
R -->|no| L[Lower candidate]
23+
R -->|yes| T[Text resolver]
24+
T --> L
25+
L --> E[BrowserExecutor.execute]
26+
E --> O
27+
```
28+
29+
The two action layers have different responsibilities:
30+
31+
| Layer | Responsibility |
32+
| --- | --- |
33+
| `JevCandidateSpace` | Dynamic semantic choices that make sense on the current page |
34+
| `BrowserAction` / `BrowserActStep` | Fixed Browser Loop execution protocol |
35+
36+
Examples:
37+
38+
| Jev candidate | Browser Loop execution |
39+
| --- | --- |
40+
| Click Search | `browser_act` with `{ type: "click", ref }` |
41+
| Type in From | text resolver, then `browser_act` with `{ type: "fill", ref, value }` |
42+
| Select Business | `browser_act` with `{ type: "fill", ref, value: "Business" }` |
43+
| Navigate | `browser_navigate` |
44+
| Done / blocked | no browser action |
45+
46+
## Run
47+
48+
Requirements:
49+
50+
- Node.js 22+
51+
- `KERNEL_API_KEY`
52+
- `TYPESAFE_API_KEY`
53+
- `TEXT_MODEL_API_KEY` for tasks that require navigation inference or text entry
54+
55+
The text helper uses an OpenAI-compatible `/chat/completions` endpoint:
56+
57+
```bash
58+
export TEXT_MODEL_API_KEY="$OPENAI_API_KEY"
59+
export TEXT_MODEL_BASE_URL="https://api.openai.com/v1"
60+
export TEXT_MODEL="gpt-5.4-nano"
61+
```
62+
63+
Install the repository dependencies, then the example's isolated Jev dependency:
64+
65+
```bash
66+
# Repository root
67+
npm ci
68+
69+
cd packages/browser-loop/examples/jev-system-one
70+
npm ci
71+
npm run typecheck
72+
npm test
73+
74+
npm run run -- \
75+
--task "Open https://news.ycombinator.com, then open the newest submissions page using the new link"
76+
```
77+
78+
There is intentionally no `--url` argument. Initial navigation is selected and executed by the agent loop.
79+
80+
## Jev request
81+
82+
Jev receives more than the candidate labels. Every question is conditioned on structured state:
83+
84+
```json
85+
{
86+
"goal": "Open Google Flights and search from SFO to JFK",
87+
"page": {
88+
"url": "about:blank",
89+
"title": "",
90+
"text": ""
91+
},
92+
"elements": [],
93+
"recent_actions": []
94+
}
95+
```
96+
97+
The operation question contains only currently available operations. Target questions are added for operations with multiple candidates. Jev answers those questions speculatively in the same request; the loop consumes only the target for the selected operation.
98+
99+
## Files
100+
101+
- `agent.ts`: observe/choose/lower/execute loop and safety bounds
102+
- `actions.ts`: page-specific candidate construction
103+
- `browser.ts`: `BrowserExecutor` adapter and accessibility snapshot parsing
104+
- `models.ts`: Jev System One operation and target policy
105+
- `text.ts`: optional OpenAI-compatible string resolver
106+
- `run.ts`: Kernel browser setup and CLI
107+
108+
## Current boundaries
109+
110+
This is deliberately a custom example rather than a generalized policy API. The candidate builder consumes Browser Loop's rendered accessibility snapshot and keeps its own role-to-operation rules. If that representation proves too lossy for real tasks, the next change should be a code-level structured observation API—not another model-facing tool.
111+
112+
The example does not generate prose answers, handle CAPTCHA, upload files, or enter passwords. The candidate list is bounded to 250 grounded actions. Page text is treated as untrusted data, and the text resolver returns `null` when required information is absent.
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import type { ActionSpaceElement, HistoryEntry, JevCandidate, JevCandidateSpace, Observation, Operation } from "./types";
2+
3+
const MAX_GROUNDED_CANDIDATES = 250;
4+
const EXCLUDED_CONTROL = /\b(?:password|passphrase|choose file|upload file)\b/i;
5+
const CLICKABLE_ROLES = new Set([
6+
"button",
7+
"link",
8+
"checkbox",
9+
"radio",
10+
"switch",
11+
"tab",
12+
"menuitem",
13+
"menuitemcheckbox",
14+
"menuitemradio",
15+
"treeitem",
16+
]);
17+
const EDITABLE_ROLES = new Set(["textbox", "searchbox", "spinbutton"]);
18+
19+
export function buildCandidateSpace(observation: Observation, goal: string, history: readonly HistoryEntry[] = []): JevCandidateSpace {
20+
const candidates: JevCandidate[] = [];
21+
const navigationOnly = observation.url === "about:blank" || observation.url.startsWith("chrome://");
22+
const pageElements = navigationOnly ? [] : observation.elements;
23+
const operationsByRef = new Map<string, Set<Operation>>();
24+
const optionsByRef = new Map<string, ActionSpaceElement["options"]>();
25+
const nativeOptions = new Set<string>();
26+
let grounded = 0;
27+
28+
const addGrounded = (candidate: JevCandidate): boolean => {
29+
if (grounded >= MAX_GROUNDED_CANDIDATES) return false;
30+
candidates.push(candidate);
31+
grounded += 1;
32+
if (candidate.ref) {
33+
const operations = operationsByRef.get(candidate.ref) ?? new Set<Operation>();
34+
operations.add(candidate.operation);
35+
operationsByRef.set(candidate.ref, operations);
36+
}
37+
return true;
38+
};
39+
40+
for (let index = 0; index < pageElements.length && grounded < MAX_GROUNDED_CANDIDATES; index++) {
41+
const element = pageElements[index]!;
42+
if (element.disabled || EXCLUDED_CONTROL.test(element.name)) continue;
43+
44+
if (element.role === "combobox") {
45+
const options = descendantOptions(pageElements, index);
46+
if (options.length > 0) {
47+
optionsByRef.set(element.ref, options.map((option) => ({
48+
label: option.name,
49+
value: option.name,
50+
selected: option.selected === true,
51+
})));
52+
if (element.expanded !== true) {
53+
for (const option of options) {
54+
nativeOptions.add(option.ref);
55+
if (option.selected) continue;
56+
if (!addGrounded({
57+
id: `select:${element.ref}:${option.ref}`,
58+
kind: "browser-step",
59+
operation: "SELECT",
60+
label: `Select ${JSON.stringify(option.name)} in ${JSON.stringify(element.name)}`,
61+
ref: element.ref,
62+
value: option.name,
63+
step: { type: "fill", ref: element.ref, value: option.name },
64+
})) break;
65+
}
66+
continue;
67+
}
68+
}
69+
addGrounded({
70+
id: `type:${element.ref}`,
71+
kind: "browser-step",
72+
operation: "TYPE_TEXT",
73+
label: `Enter text in ${JSON.stringify(element.name)}; current value=${JSON.stringify(element.value ?? "")}`,
74+
ref: element.ref,
75+
value: element.value ?? "",
76+
textPurpose: "field",
77+
});
78+
addGrounded({
79+
id: `click:${element.ref}`,
80+
kind: "browser-step",
81+
operation: "CLICK",
82+
label: `Open ${JSON.stringify(element.name)}`,
83+
ref: element.ref,
84+
step: { type: "click", ref: element.ref },
85+
});
86+
continue;
87+
}
88+
89+
if (EDITABLE_ROLES.has(element.role)) {
90+
addGrounded({
91+
id: `type:${element.ref}`,
92+
kind: "browser-step",
93+
operation: "TYPE_TEXT",
94+
label: `Enter text in ${JSON.stringify(element.name)}; current value=${JSON.stringify(element.value ?? "")}`,
95+
ref: element.ref,
96+
value: element.value ?? "",
97+
textPurpose: "field",
98+
});
99+
addGrounded({
100+
id: `click:${element.ref}`,
101+
kind: "browser-step",
102+
operation: "CLICK",
103+
label: `Open ${JSON.stringify(element.name)}`,
104+
ref: element.ref,
105+
step: { type: "click", ref: element.ref },
106+
});
107+
continue;
108+
}
109+
110+
if (CLICKABLE_ROLES.has(element.role) || (element.role === "option" && !nativeOptions.has(element.ref))) {
111+
addGrounded({
112+
id: `click:${element.ref}`,
113+
kind: "browser-step",
114+
operation: "CLICK",
115+
label: `Click ${element.role} ${JSON.stringify(element.name)}${stateDescription(element)}`,
116+
ref: element.ref,
117+
step: { type: "click", ref: element.ref },
118+
});
119+
}
120+
}
121+
122+
const scrollPoint = {
123+
x: Math.max(0, Math.floor(observation.scroll.width / 2)),
124+
y: Math.max(0, Math.floor(observation.scroll.viewport / 2)),
125+
};
126+
const scrollAmount = Math.max(1, Math.ceil(observation.scroll.viewport / 120));
127+
if (!navigationOnly && observation.scroll.y + observation.scroll.viewport < observation.scroll.height - 2) {
128+
candidates.push({
129+
id: "scroll:down",
130+
kind: "browser-action",
131+
operation: "SCROLL",
132+
label: "Scroll down to reveal more page content",
133+
action: { type: "browser_scroll", ...scrollPoint, direction: "down", amount: scrollAmount },
134+
});
135+
}
136+
if (!navigationOnly && observation.scroll.y > 0) {
137+
candidates.push({
138+
id: "scroll:up",
139+
kind: "browser-action",
140+
operation: "SCROLL",
141+
label: "Scroll up to reveal earlier page content",
142+
action: { type: "browser_scroll", ...scrollPoint, direction: "up", amount: scrollAmount },
143+
});
144+
}
145+
candidates.push({ id: "wait", kind: "browser-step", operation: "WAIT", label: "Wait briefly for the page to update", step: { type: "wait", ms: 100 } });
146+
147+
const literalUrls = extractLiteralUrls(goal);
148+
if (literalUrls.length > 0) {
149+
for (const [index, url] of literalUrls.entries()) {
150+
candidates.push({ id: `navigate:${index}`, kind: "navigate", operation: "NAVIGATE", label: `Navigate to ${url}`, value: url });
151+
}
152+
} else {
153+
candidates.push({
154+
id: "navigate:resolve",
155+
kind: "navigate",
156+
operation: "NAVIGATE",
157+
label: "Navigate to the website needed to advance the goal",
158+
textPurpose: "navigation",
159+
});
160+
}
161+
if (!navigationOnly) {
162+
candidates.push({ id: "history:back", kind: "history", operation: "BACK", label: "Go back one page" });
163+
if (history.at(-1)?.operation === "BACK") {
164+
candidates.push({ id: "history:forward", kind: "history", operation: "FORWARD", label: "Go forward one page" });
165+
}
166+
candidates.push({ id: "history:reload", kind: "history", operation: "RELOAD", label: "Reload the current page" });
167+
}
168+
candidates.push({ id: "done", kind: "terminal", operation: "DONE", label: "Every requirement is visibly satisfied" });
169+
candidates.push({ id: "blocked", kind: "terminal", operation: "BLOCKED", label: "No supported operation can make progress safely" });
170+
171+
const byOperation = new Map<Operation, JevCandidate[]>();
172+
for (const candidate of candidates) {
173+
const group = byOperation.get(candidate.operation) ?? [];
174+
group.push(candidate);
175+
byOperation.set(candidate.operation, group);
176+
}
177+
const elements: ActionSpaceElement[] = pageElements
178+
.filter((element) => !EXCLUDED_CONTROL.test(element.name))
179+
.map((element) => ({
180+
...element,
181+
operations: [...(operationsByRef.get(element.ref) ?? [])],
182+
...(optionsByRef.has(element.ref) ? { options: optionsByRef.get(element.ref) } : {}),
183+
}));
184+
return { candidates, byId: new Map(candidates.map((candidate) => [candidate.id, candidate])), byOperation, elements };
185+
}
186+
187+
export function extractLiteralUrls(goal: string): string[] {
188+
const urls = new Set<string>();
189+
for (const match of goal.matchAll(/https?:\/\/[^\s<>"']+/gi)) {
190+
const value = match[0].replace(/[),.;!?]+$/, "");
191+
try {
192+
const url = new URL(value);
193+
if (url.protocol === "http:" || url.protocol === "https:") urls.add(url.href);
194+
} catch {
195+
// Ignore malformed literals and let the text resolver handle navigation.
196+
}
197+
}
198+
return [...urls].slice(0, MAX_GROUNDED_CANDIDATES);
199+
}
200+
201+
function descendantOptions(elements: readonly Observation["elements"][number][], parentIndex: number): Observation["elements"] {
202+
const parent = elements[parentIndex]!;
203+
const options: Observation["elements"] = [];
204+
for (let index = parentIndex + 1; index < elements.length; index++) {
205+
const candidate = elements[index]!;
206+
if (candidate.depth <= parent.depth) break;
207+
if (candidate.role === "option" && !candidate.disabled) options.push(candidate);
208+
}
209+
return options;
210+
}
211+
212+
function stateDescription(element: Observation["elements"][number]): string {
213+
const states = [
214+
element.value === undefined ? undefined : `value=${JSON.stringify(element.value)}`,
215+
element.checked === undefined ? undefined : `checked=${element.checked}`,
216+
element.selected === undefined ? undefined : `selected=${element.selected}`,
217+
element.expanded === undefined ? undefined : `expanded=${element.expanded}`,
218+
].filter((state): state is string => state !== undefined);
219+
return states.length ? `; ${states.join(", ")}` : "";
220+
}

0 commit comments

Comments
 (0)