Skip to content
Draft
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
22 changes: 19 additions & 3 deletions packages/extension/services/actService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,17 @@ export async function act({
},
}
: undefined;
await waitForDomNetworkQuiet(page.mainFrame(), logger, domSettleTimeoutMs);
// With Jev on, the intent request (which needs no page) runs while the DOM
// settles; everything that reads or touches the page still waits for it.
// performance.now(): tests script Date.now() for inference timing.
const actStartedAt = performance.now();
const settled = waitForDomNetworkQuiet(page.mainFrame(), logger, domSettleTimeoutMs);
// The cache lookup keys on the page's tree and URL, so when a cache is in
// play the page must have settled before it; only cache-less acts overlap.
const cacheLookup = cache !== undefined && options?.cache !== false;
const overlapSettle = jevAct !== undefined && jevAct.enabled !== false && !cacheLookup;
if (overlapSettle) settled.catch(() => {});
else await settled;
ensureTimeRemaining();
let actPath: "llm" | "jev" | "jev+arg-llm" | "jev+llm" | "jev-tool" | "jev-tool+arg-llm" = "llm";
let usedArgumentLlm = false;
Expand All @@ -175,9 +185,11 @@ export async function act({
bypass: cacheService.shouldBypassCacheForLocatorScope(options),
context: cache,
logger,
onHit: (value) => replayCachedActions(value, instruction, variables, context),
onHit: async (value) => {
await settled;
return await replayCachedActions(value, instruction, variables, context);
},
execute: async () => {
// performance.now(): tests script Date.now() for inference timing.
const startedAt = performance.now();
const result = await runActPipeline();
// Whatever Jev already did changed the page, whether or not the act
Expand All @@ -195,6 +207,8 @@ export async function act({
path: actPath,
success: result.data.success,
durationMs: Math.round(performance.now() - startedAt),
// From the start of act(), DOM settle included: what the caller waits for.
totalMs: Math.round(performance.now() - actStartedAt),
llmInputTokens: result.metadata.usage.inputTokens,
llmOutputTokens: result.metadata.usage.outputTokens,
llmMs: result.metadata.usage.inferenceTimeMs,
Expand Down Expand Up @@ -229,6 +243,7 @@ export async function act({
snapshotOptions,
ensureTimeRemaining,
openPageCount,
settled,
...(webmcp ? { webmcp } : {}),
extractText: async (text) => {
const response = await inference.actTextArgument({
Expand Down Expand Up @@ -285,6 +300,7 @@ export async function act({
}
}

await settled;
const { combinedTree, combinedXpathMap } = await page.captureSnapshot(snapshotOptions);

const actPrompt = buildActPrompt(
Expand Down
6 changes: 6 additions & 0 deletions packages/extension/services/jevAct/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ deliberately not a field of the public create config. Evals build that variable
| Checks | code | Fill read-back, native `<select>` `[selected]` flag, no-effect retry on a credible runner-up. `verify: "full"` adds a logged-only Jev yes/no. |
| No target | Jev, 1 request | Page-state signals; access-denied / captcha fail fast. Otherwise the LLM gets Jev's shortlist (with each item's card/row) before the whole tree. |

Steps that do not depend on each other overlap: the intent request needs no page, so it is asked
while `act()` waits for the DOM to settle (a fixed 500 ms quiet window at minimum), and nothing
reads or touches the page before that wait is over; `observe()` asks its intent while the snapshot
is captured. The per-act log reports `durationMs` (pipeline) and `totalMs` (from the start of
`act()`, settle included).

Also reused outside `act` inference: `cacheCheck.ts` asks one yes/no before a cached action is
replayed, so a selector that now resolves to a different control is re-inferred instead of clicked.

Expand Down
10 changes: 8 additions & 2 deletions packages/extension/services/jevAct/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,13 @@ export async function runJevObserve(
return outcome;
};

const snap = await snapshot(deps);
// The intent request only needs the instruction: it is asked while the
// snapshot is being captured.
const snapshotting = snapshot(deps);

// No instruction: every interactive element, no model needed.
if (!deps.instruction) {
const snap = await snapshotting;
const everything = uniqueById([
...buildView(snap.nodes, "pointer"),
...buildView(snap.nodes, "input"),
Expand All @@ -109,7 +112,8 @@ export async function runJevObserve(
redact: redactor(deps.variables),
};

const intent = await ask(
snapshotting.catch(() => {});
const asking = ask(
ctx,
"intent",
{ instruction: deps.instruction },
Expand Down Expand Up @@ -137,6 +141,8 @@ export async function runJevObserve(
},
},
);
asking.catch(() => {});
const [intent, snap] = await Promise.all([asking, snapshotting]);
const family = resolveFamily(choiceAnswer(intent, "family"), ctx.threshold);
const cardinality = choiceAnswer(intent, "cardinality");
annotate(trace, {
Expand Down
10 changes: 10 additions & 0 deletions packages/extension/services/jevAct/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ export type JevActDeps = {
*/
extractText?: (instruction: string) => Promise<string | null>;
takeAction: (action: Action) => Promise<ActResultData>;
/**
* Resolves when the DOM has settled. The intent request needs no page, so
* it runs while this is pending; nothing reads or touches the page before it.
*/
settled?: Promise<void>;
/** Present when `tools` is on and the act is not scoped to a locator. */
webmcp?: JevToolDeps;
};
Expand Down Expand Up @@ -396,6 +401,8 @@ async function decideAndAct(
});
}
if (input) {
// A tool runs page code: not before the document has settled.
await deps.settled;
deps.ensureTimeRemaining();
const result = await invokeTool(
deps.webmcp,
Expand Down Expand Up @@ -1075,6 +1082,8 @@ async function act(
action: Action,
options: { before?: Snapshot; expectedValue?: string } = {},
): Promise<Done | Fallback> {
// Press and whole-page scroll get here without ever taking a snapshot.
await ctx.deps.settled;
const urlBefore = ctx.deps.page.url();
const pagesBefore = ctx.deps.openPageCount?.();
ctx.deps.ensureTimeRemaining();
Expand Down Expand Up @@ -1363,6 +1372,7 @@ async function readInputValue(deps: JevActDeps, selector: string): Promise<strin
}

async function snapshot(deps: JevActDeps): Promise<Snapshot> {
await deps.settled;
deps.ensureTimeRemaining();
const { combinedTree, combinedXpathMap, combinedEditableIds } = await deps.page.captureSnapshot(
deps.snapshotOptions,
Expand Down
60 changes: 60 additions & 0 deletions packages/extension/tests/jevActPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,3 +918,63 @@ describe("resolveFamily merges", () => {
expect(merged.confidence).toBeCloseTo(0.9);
});
});

describe("jev act pipeline and DOM settle", () => {
afterEach(() => vi.unstubAllGlobals());

it("asks intent while the DOM settles, and touches the page only afterwards", async () => {
const order: string[] = [];
let settle!: () => void;
const settled = new Promise<void>((resolve) => {
settle = resolve;
});
vi.stubGlobal(
"fetch",
vi.fn(async () => {
order.push("intent");
// The page settles only after Jev has answered.
setTimeout(() => {
order.push("settled");
settle();
}, 5);
const answer = (choice: string) => ({
type: "choice",
choice,
confidence: 0.99,
probabilities: { [choice]: 0.99 },
});
return new Response(
JSON.stringify({
answers: {
family: answer("press"),
key: answer("Enter"),
mouse_button: answer("left"),
toggle_state: answer("unspecified"),
after_typing: answer("nothing"),
scroll_scope: answer("not_scroll"),
},
usage: { input_tokens: 10 },
}),
{ status: 200 },
);
}),
);
const outcome = await runJevActPipeline(
{ apiKey: "test" },
{
page: { url: () => "https://example.com" } as never,
logger: new StagehandLogger({ tracer: trace.getTracer("jev-settle-test") }, () => {}),
instruction: "press Enter",
snapshotOptions: {},
ensureTimeRemaining: () => {},
settled,
takeAction: async (action) => {
order.push("act");
return { success: true, message: "ok", actionDescription: "", actions: [action] };
},
},
);
expect(outcome.kind).toBe("done");
expect(order).toEqual(["intent", "settled", "act"]);
});
});
15 changes: 15 additions & 0 deletions packages/extension/tests/jevTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,21 @@ describe("Jev WebMCP tool act", () => {
expect(outcome.kind === "done" && outcome.result.message).not.toContain("SFO");
});

it("invokes the tool only once the DOM-settle wait is over", async () => {
stubJev({ tool: "clear_cart" });
let settle!: () => void;
const settled = new Promise<void>((resolve) => {
settle = resolve;
});
const h = harness("empty my cart");
const running = runJevActPipeline(config, { ...h.deps, settled });
await new Promise((resolve) => setTimeout(resolve, 150));
expect(h.invoked).toEqual([]);
settle();
expect((await running).kind).toBe("done");
expect(h.invoked).toHaveLength(1);
});

it("reports a tool error as a failed act instead of falling through to the UI", async () => {
stubJev({ tool: "clear_cart" });
const h = harness("empty my cart");
Expand Down
Binary file modified packages/sdk-go/internal/extensionassets/stagehand-extension.zip
Binary file not shown.
Loading