Skip to content

Commit 07370f2

Browse files
committed
Add semantic browser waits
1 parent 0c29b34 commit 07370f2

11 files changed

Lines changed: 846 additions & 85 deletions

File tree

‎packages/agent/src/index.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ export { InternalComputerTranslator } from "./translator/translator";
66
export { CdpConnection } from "./translator/cdp";
77
export { BrowserExecutor } from "./translator/browser";
88
export type { BrowserFindCandidate, BrowserRefState } from "./translator/browser";
9-
export type { BatchExecutionResult, BatchReadResult } from "./translator/types";
9+
export type {
10+
BatchExecutionResult,
11+
BatchReadResult,
12+
BrowserExpectationEvidence,
13+
BrowserWaitForResult,
14+
} from "./translator/types";
1015
export { createCuaComputerTools } from "./tools";
1116
export type {
1217
BatchDetails,

‎packages/agent/src/tools.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "@onkernel/cua-ai";
1717
import { InternalComputerTranslator, type KernelBrowser } from "./translator/translator";
1818
import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
19+
import type { BrowserWaitForResult } from "./translator/types";
1920

2021
export interface ComputerToolOptions {
2122
browser: KernelBrowser;
@@ -37,6 +38,7 @@ export interface BatchDetails {
3738
| { type: "screenshot"; bytes: number }
3839
| { type: "cursor_position"; x: number; y: number }
3940
| { type: "browser_text"; label: string; bytes: number }
41+
| { type: "browser_wait_for"; result: BrowserWaitForResult }
4042
>;
4143
}
4244

@@ -170,6 +172,9 @@ async function executeBatchTool(
170172
} else if (read.type === "browser_text") {
171173
readResults.push({ type: "browser_text", label: read.label, bytes: read.text.length });
172174
content.push({ type: "text", text: read.text });
175+
} else if (read.type === "browser_wait_for") {
176+
readResults.push(read);
177+
content.push({ type: "text", text: formatBrowserWaitResult(read.result) });
173178
} else {
174179
readResults.push({ type: "screenshot", bytes: read.data.length });
175180
content.push({ type: "image", data: read.data.toString("base64"), mimeType: read.mimeType });
@@ -185,7 +190,14 @@ async function executeBatchTool(
185190
} catch (err) {
186191
throw new Error(`Actions failed: ${errorMessage(err)}`, { cause: err });
187192
}
188-
return { content, details: { statusText: "Actions executed successfully.", readResults } };
193+
const waits = readResults.flatMap((read) => read.type === "browser_wait_for" ? [read.result] : []);
194+
const failedWait = ["interrupted", "timed_out", "unverifiable"].find((status) => waits.some((wait) => wait.status === status));
195+
const statusText = waits.length === 0
196+
? "Actions executed successfully."
197+
: failedWait
198+
? `Browser condition ${failedWait}.`
199+
: "Browser condition satisfied.";
200+
return { content, details: { statusText, readResults } };
189201
}
190202

191203
async function executeNavigationTool(
@@ -257,6 +269,11 @@ async function executePlaywrightTool(translator: InternalComputerTranslator, par
257269
}
258270
}
259271

272+
function formatBrowserWaitResult(result: BrowserWaitForResult): string {
273+
const reason = result.reason ? ` (${result.reason})` : "";
274+
return [`wait_for: ${result.status}/${result.evidence}${reason} after ${result.elapsed_ms}ms`, ...result.details].join("\n");
275+
}
276+
260277
function formatPlaywrightResult(result: unknown): string {
261278
return typeof result === "string" ? result : JSON.stringify(result);
262279
}

‎packages/agent/src/translator/browser-observation.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface NthIndex {
2121
export interface RenderContext {
2222
targetId: string;
2323
frameKey: string;
24+
/** Target whose CDP session owns this frame (page target or OOPIF target). */
25+
sessionTargetId: string;
2426
sessionId: string;
2527
generation: number;
2628
interactiveOnly: boolean;
@@ -51,12 +53,14 @@ export interface ObservedNode {
5153
/** Stable structured browser state collected before presentation filtering. */
5254
export interface BrowserObservation {
5355
targetId: string;
56+
navigationEpoch: number;
5457
tree: FrameStitch;
55-
stitches: Map<number, FrameStitch>;
58+
stitches: Map<string, FrameStitch>;
5659
nodes: ObservedNode[];
5760
url: string;
5861
title: string;
5962
generations: Map<string, number>;
63+
complete: boolean;
6064
}
6165

6266
/** Render-ready projection of one structured browser observation. */
@@ -67,6 +71,11 @@ export interface BrowserPresentation {
6771
shape: string;
6872
}
6973

74+
/** Build the lookup key for an iframe node's stitched child tree. */
75+
export function frameStitchKey(parentFrameKey: string, backendNodeId: number): string {
76+
return `${parentFrameKey}\u0000${backendNodeId}`;
77+
}
78+
7079
/** Signals that browser state changed while an observation was collected. */
7180
export class ObservationChangedError extends Error {
7281
constructor(message = "Browser observation changed during collection") {
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { CuaBrowserExpectation } from "@onkernel/cua-ai";
2+
import { staticTextRun, type AXNode, type BrowserObservation } from "./browser-observation";
3+
import type { BrowserExpectationEvidence, BrowserWaitForResult, BrowserWaitReason } from "./types";
4+
5+
/** Internal expectation truth and optional lifecycle reason. */
6+
export interface BrowserExpectationEvaluation extends BrowserExpectationEvidence {
7+
reason?: BrowserWaitReason;
8+
}
9+
10+
type RefExpectation = Extract<CuaBrowserExpectation, { type: "ref" }>;
11+
/** Resolve a ref expectation against one structured observation. */
12+
export type BrowserRefResolver = (expectation: RefExpectation, observation: BrowserObservation) => BrowserExpectationEvaluation;
13+
14+
/** Browser capabilities required by the semantic wait engine. */
15+
export interface BrowserWaitRuntime {
16+
selectTarget(tabId?: string): Promise<string>;
17+
observeTarget(targetId: string): Promise<BrowserObservation>;
18+
dialogCount(): number;
19+
targetExists(targetId: string): Promise<boolean>;
20+
liveGeneration(frameId: string): number;
21+
resolveRef: BrowserRefResolver;
22+
now?(): number;
23+
delay?(ms: number): Promise<void>;
24+
}
25+
26+
/** Timeout, polling, target, and condition options for a semantic wait. */
27+
export interface BrowserWaitOptions {
28+
expect: CuaBrowserExpectation;
29+
timeoutMs?: number;
30+
pollMs?: number;
31+
tabId?: string;
32+
}
33+
34+
function expectationNodes(observation: BrowserObservation): AXNode[] {
35+
const nodes = observation.nodes.map(({ node }) => node);
36+
for (const tree of [observation.tree, ...observation.stitches.values()]) {
37+
for (const node of tree.byId.values()) {
38+
const children = node.childIds ?? [];
39+
for (let index = 0; index < children.length; index += 1) {
40+
const run = staticTextRun(tree.byId, children, index);
41+
if (run) { nodes.push(run.node); index = run.end; }
42+
}
43+
}
44+
}
45+
return nodes;
46+
}
47+
48+
/** Evaluate a semantic condition without minting refs. Unknown means the observation cannot prove the claim. */
49+
export function evaluateBrowserExpectation(
50+
expectation: CuaBrowserExpectation,
51+
observation: BrowserObservation,
52+
baseline: BrowserObservation,
53+
resolveRef: BrowserRefResolver,
54+
): BrowserExpectationEvaluation {
55+
if ("all" in expectation || "any" in expectation) {
56+
const all = "all" in expectation;
57+
const children = (all ? expectation.all : expectation.any).map((child) => evaluateBrowserExpectation(child, observation, baseline, resolveRef));
58+
const truth = all
59+
? children.some((child) => child.truth === false) ? false : children.some((child) => child.truth === undefined) ? undefined : true
60+
: children.some((child) => child.truth === true) ? true : children.some((child) => child.truth === undefined) ? undefined : false;
61+
return {
62+
truth,
63+
details: children.flatMap((child) => child.details),
64+
...(truth === undefined ? { reason: children.find((child) => child.truth === undefined)?.reason } : {}),
65+
};
66+
}
67+
if (expectation.type === "text" || expectation.type === "role_name") {
68+
const found = expectationNodes(observation).some((node) => !node.ignored && (expectation.type === "text"
69+
? (node.name?.value ?? "").replace(/\s+/g, " ").toLowerCase().includes(expectation.text.replace(/\s+/g, " ").toLowerCase())
70+
: (expectation.role === undefined || node.role?.value === expectation.role) && (expectation.name === undefined || node.name?.value === expectation.name)));
71+
const expected = expectation.exists ?? true;
72+
const truth = !found && !observation.complete ? undefined : found === expected;
73+
return { truth, details: [`${expectation.type} ${found ? "present" : "absent"}${observation.complete ? "" : "; observation incomplete"}`], ...(!observation.complete && !found ? { reason: "incomplete_observation" as const } : {}) };
74+
}
75+
if (expectation.type === "url" || expectation.type === "title") {
76+
const value = observation[expectation.type];
77+
const initial = baseline[expectation.type];
78+
const truth = (expectation.equals === undefined || value === expectation.equals) &&
79+
(expectation.contains === undefined || value.includes(expectation.contains)) &&
80+
(expectation.changed === undefined || (value !== initial) === expectation.changed);
81+
return { truth, details: [`${expectation.type}=${JSON.stringify(value)}`] };
82+
}
83+
if (expectation.type === "ref") return resolveRef(expectation, observation);
84+
return { truth: undefined, details: ["unsupported expectation"] };
85+
}
86+
87+
/** Poll a semantic browser condition; in-flight browser reads settle before timeout is reported. */
88+
export async function waitForBrowserExpectation(runtime: BrowserWaitRuntime, options: BrowserWaitOptions): Promise<BrowserWaitForResult> {
89+
const now = runtime.now ?? Date.now;
90+
const sleep = runtime.delay ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
91+
const started = now();
92+
const timeout = options.timeoutMs ?? 2_000;
93+
const poll = options.pollMs ?? 50;
94+
const remaining = () => Math.max(0, timeout - (now() - started));
95+
const expired = () => now() - started >= timeout;
96+
let targetId: string;
97+
let baseline: BrowserObservation;
98+
try {
99+
targetId = await beforeDeadline(() => runtime.selectTarget(options.tabId), remaining(), now);
100+
if (expired()) return timedOut(started, now());
101+
baseline = await runtime.observeTarget(targetId);
102+
if (expired()) return timedOut(started, now());
103+
} catch (error) {
104+
if (error instanceof WaitDeadlineError) return timedOut(started, now());
105+
return failedObservation(started, now(), error);
106+
}
107+
const initial = evaluateBrowserExpectation(options.expect, baseline, baseline, runtime.resolveRef);
108+
if (expired()) return timedOut(started, now());
109+
if (initial.reason === "stale_ref") return terminal("interrupted", "unverifiable", initial, initial, started, now(), initial.reason);
110+
if (initial.truth === true) return terminal("satisfied", "preexisting", initial, initial, started, now());
111+
const dialogs = runtime.dialogCount();
112+
let final = initial;
113+
while (!expired()) {
114+
await sleep(Math.min(poll, remaining()));
115+
if (expired()) break;
116+
let exists: boolean;
117+
try { exists = await beforeDeadline(() => runtime.targetExists(targetId), remaining(), now); }
118+
catch (error) { if (error instanceof WaitDeadlineError) break; return failedObservation(started, now(), error, initial, final); }
119+
if (expired()) break;
120+
if (!exists) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_detached");
121+
let observation: BrowserObservation;
122+
try { observation = await runtime.observeTarget(targetId); }
123+
catch (error) { return failedObservation(started, now(), error, initial, final); }
124+
if (observation.targetId !== targetId) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_changed");
125+
if (runtime.dialogCount() > dialogs) return terminal("interrupted", "unverifiable", initial, final, started, now(), "dialog");
126+
const navigated = observation.navigationEpoch !== baseline.navigationEpoch || [...observation.generations].some(([key, generation]) => generation !== runtime.liveGeneration(key) || (baseline.generations.has(key) && baseline.generations.get(key) !== generation));
127+
if (navigated) {
128+
if (isLocationExpectation(options.expect)) {
129+
final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
130+
if (final.truth === true && !expired()) return terminal("satisfied", "newly_verified", initial, final, started, now());
131+
}
132+
return terminal("interrupted", "unverifiable", initial, final, started, now(), "navigation");
133+
}
134+
if (expired()) break;
135+
final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
136+
if (expired()) break;
137+
if (final.reason === "stale_ref") return terminal("interrupted", "unverifiable", initial, final, started, now(), final.reason);
138+
if (final.truth === true) return terminal("satisfied", "newly_verified", initial, final, started, now());
139+
}
140+
if (final.truth === undefined) return terminal("unverifiable", "unverifiable", initial, final, started, now(), final.reason ?? "incomplete_observation");
141+
return terminal("timed_out", "failed", initial, final, started, now());
142+
}
143+
144+
class WaitDeadlineError extends Error {}
145+
146+
async function beforeDeadline<T>(operation: () => Promise<T>, remaining: number, now: () => number): Promise<T> {
147+
if (remaining <= 0) throw new WaitDeadlineError();
148+
const started = now();
149+
try {
150+
const result = await operation();
151+
if (Number.isFinite(remaining) && now() - started >= remaining) throw new WaitDeadlineError();
152+
return result;
153+
} catch (error) {
154+
if (error instanceof WaitDeadlineError || Number.isFinite(remaining) && now() - started >= remaining) throw new WaitDeadlineError();
155+
throw error;
156+
}
157+
}
158+
159+
function isLocationExpectation(expectation: CuaBrowserExpectation): boolean {
160+
if ("all" in expectation) return expectation.all.every(isLocationExpectation);
161+
if ("any" in expectation) return expectation.any.every(isLocationExpectation);
162+
return expectation.type === "url" || expectation.type === "title";
163+
}
164+
165+
function timedOut(started: number, ended: number): BrowserWaitForResult {
166+
const evidence = { truth: undefined, details: [] };
167+
return terminal("timed_out", "failed", evidence, evidence, started, ended);
168+
}
169+
170+
function terminal(status: BrowserWaitForResult["status"], evidence: BrowserWaitForResult["evidence"], initial: BrowserExpectationEvidence, final: BrowserExpectationEvidence, started: number, ended: number, reason?: BrowserWaitReason): BrowserWaitForResult {
171+
return { status, evidence, initial, final, elapsed_ms: Math.max(0, ended - started), ...(reason ? { reason } : {}), details: [...initial.details.map((detail) => `initial: ${detail}`), ...final.details.map((detail) => `final: ${detail}`)] };
172+
}
173+
174+
function failedObservation(started: number, ended: number, error: unknown, initial: BrowserExpectationEvidence = { truth: undefined, details: [] }, final = initial): BrowserWaitForResult {
175+
const message = error instanceof Error ? error.message : String(error);
176+
return { ...terminal("unverifiable", "unverifiable", initial, final, started, ended, "observation_failed"), details: [...initial.details, message] };
177+
}

0 commit comments

Comments
 (0)