aboutsummaryrefslogtreecommitdiff
path: root/src/domains/page.ts
blob: 65bd4a2d17ed93443151cc97474dbc31208f8855 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
import { Type } from "typebox";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { evalJs } from "./cdp-call";
import { safeJs } from "../util/js-template";

const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
  new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason ?? new Error("aborted"));
      return;
    }
    const onAbort = (): void => {
      clearTimeout(timer);
      reject(signal?.reason ?? new Error("aborted"));
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    signal?.addEventListener("abort", onAbort, { once: true });
  });

export const pageInfoTool = defineBrowserTool({
  name: "browser_page_info",
  label: "Browser Page Info",
  description:
    "Get current page state: URL, title, viewport size, scroll position, page dimensions. If a JS dialog is open, returns dialog info instead.",
  promptSnippet: "Get current page URL, title, viewport, and scroll position",
  promptGuidelines: [
    "If browser_page_info returns a dialog, call browser_handle_dialog before any other browser actions: dialogs freeze the page's JS thread.",
  ],
  parameters: Type.Object({}),
  concurrency: "parallel",
  async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
    const r = await client.pageInfo();
    if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
    if ("dialog" in r.data) {
      const d = r.data.dialog;
      return ok({
        text: `⚠️  DIALOG OPEN: ${d.type}\nMessage: ${d.message}${d.defaultPrompt ? `\nDefault: ${d.defaultPrompt}` : ""}\n\nUse browser_handle_dialog to accept or dismiss.`,
        details: { dialog: d },
      });
    }
    const i = r.data;
    return ok({
      text: `URL: ${i.url}\nTitle: ${i.title}\nViewport: ${i.width}x${i.height}\nScroll: (${i.scrollX}, ${i.scrollY})\nPage size: ${i.pageWidth}x${i.pageHeight}`,
      details: { ...i },
    });
  },
});

const WaitArgs = Type.Object({
  seconds: Type.Number({ minimum: 0, maximum: 60, description: "Seconds to wait" }),
});

export const waitTool = defineBrowserTool({
  name: "browser_wait",
  label: "Browser Wait",
  description: "Wait N seconds before continuing.",
  promptSnippet: "Wait N seconds",
  promptGuidelines: ["Use sparingly — prefer browser_wait_for_load when waiting for a page load."],
  parameters: WaitArgs,
  concurrency: "parallel",
  async handler(args, { signal }): Promise<Result<ToolOk, ToolErr>> {
    try {
      await sleep(Math.round(args.seconds * 1000), signal);
      return ok({ text: `Waited ${args.seconds}s` });
    } catch (e) {
      return err({ kind: "internal", message: e instanceof Error ? e.message : String(e) });
    }
  },
});

const WaitForLoadArgs = Type.Object({
  timeout: Type.Optional(Type.Number({ default: 15, minimum: 1, maximum: 120, description: "Max seconds to wait. Default: 15." })),
});

export const waitForLoadTool = defineBrowserTool({
  name: "browser_wait_for_load",
  label: "Browser Wait For Load",
  description:
    "Poll until the current page reports document.readyState === 'complete'. Returns a typed timeout error if the deadline elapses.",
  promptSnippet: "Wait for the page to finish loading",
  promptGuidelines: [
    "Call after browser_navigate / browser_open_urls before extracting data.",
  ],
  parameters: WaitForLoadArgs,
  concurrency: "serialized",
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
    const timeoutMs = (args.timeout ?? 15) * 1000;
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
      const r = await evalJs(client, safeJs`document.readyState`);
      if (r.success && r.data === "complete") {
        const ms = Date.now() - start;
        return ok({ text: `Page loaded in ${Math.round(ms / 100) / 10}s`, details: { ms } });
      }
      await new Promise((res) => setTimeout(res, 50));
    }
    return err({ kind: "timeout", message: `Page did not finish loading in ${args.timeout ?? 15}s` });
  },
});

const WaitForArgs = Type.Object({
  selector: Type.Optional(Type.String({ description: "CSS selector to wait for (or wait to disappear with gone:true)" })),
  text: Type.Optional(Type.String({ description: "Wait until this text appears anywhere in document.body" })),
  gone: Type.Optional(Type.Boolean({ default: false, description: "If true with a selector, wait until it is absent instead of present" })),
  timeout: Type.Optional(Type.Number({ default: 5, minimum: 1, maximum: 60, description: "Max seconds to wait. Default: 5." })),
});

export const waitForTool = defineBrowserTool({
  name: "browser_wait_for",
  label: "Browser Wait For",
  description:
    "Poll until a condition holds: a selector appears (or disappears with gone:true), or text appears in the page. Returns a typed timeout error if the deadline elapses. Use to wait for SPA content to render before interacting.",
  promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
  promptGuidelines: [
    "Use before browser_fill / browser_click on dynamic pages so you act after the element renders.",
  ],
  parameters: WaitForArgs,
  concurrency: "parallel",
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
    if (args.selector === undefined && args.text === undefined) {
      return err({ kind: "invalid_state", message: "Provide a selector or text to wait for." });
    }
    const gone = args.gone ?? false;
    const timeoutMs = (args.timeout ?? 5) * 1000;
    const expr =
      args.selector !== undefined
        ? safeJs`(() => { const found = !!document.querySelector(${args.selector}); return ${gone} ? !found : found; })()`
        : safeJs`(() => { const t = document.body ? document.body.innerText : ""; return t.includes(${args.text}); })()`;
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
      const r = await evalJs(client, expr);
      if (r.success && r.data === true) {
        const ms = Date.now() - start;
        const what = args.selector !== undefined ? `${args.selector}${gone ? " gone" : ""}` : `text "${args.text}"`;
        return ok({ text: `Condition met (${what}) in ${Math.round(ms / 100) / 10}s`, details: { ms } });
      }
      await sleep(50, signal);
    }
    const what = args.selector !== undefined ? `${args.selector}${gone ? " to disappear" : ""}` : `text "${args.text}"`;
    return err({ kind: "timeout", message: `Timed out after ${args.timeout ?? 5}s waiting for ${what}` });
  },
});