| 1 | package browser |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/internal/tool" |
| 14 | ) |
| 15 | |
| 16 | const screenshotMaxBytes = 8 << 20 |
| 17 | |
| 18 | func tabsTool(exec Executor) tool.Tool { |
| 19 | return readTool{base: base{exec: exec, name: "browser_tabs", |
| 20 | description: "List this task's browser tabs with ID, URL, title, and loading state. Start here to find a tabId, or call browser_open when the task has no tab yet.", |
| 21 | schema: objectSchema(nil), |
| 22 | snip: listSnip, |
| 23 | }, run: runTabs} |
| 24 | } |
| 25 | |
| 26 | func runTabs(ctx context.Context, exec Executor, args json.RawMessage) (string, error) { |
| 27 | var p struct{} |
| 28 | if err := decode(args, &p); err != nil { |
| 29 | return "", err |
| 30 | } |
| 31 | tabs, err := exec.Tabs(ctx) |
| 32 | if err != nil { |
| 33 | return "", translate(err, "browser_tabs") |
| 34 | } |
| 35 | if len(tabs) == 0 { |
| 36 | return "no tabs are open for this task; call browser_open to create one", nil |
| 37 | } |
| 38 | lines := make([]string, 0, len(tabs)+1) |
| 39 | lines = append(lines, fmt.Sprintf("%d tab(s)", len(tabs))) |
| 40 | for _, t := range tabs { |
| 41 | lines = append(lines, formatTab(t)) |
| 42 | } |
| 43 | return strings.Join(lines, "\n"), nil |
| 44 | } |
| 45 | |
| 46 | func formatTab(t Tab) string { |
| 47 | s := fmt.Sprintf("tab %s: %s", t.ID, t.URL) |
| 48 | if t.Title != "" { |
| 49 | s += fmt.Sprintf(" %q", t.Title) |
| 50 | } |
| 51 | var flags []string |
| 52 | if t.Loading { |
| 53 | flags = append(flags, "loading") |
| 54 | } |
| 55 | if t.Temporary { |
| 56 | flags = append(flags, "temporary") |
| 57 | } |
| 58 | if len(flags) > 0 { |
| 59 | s += " [" + strings.Join(flags, ", ") + "]" |
| 60 | } |
| 61 | return s |
| 62 | } |
| 63 | |
| 64 | func snapshotTool(exec Executor) tool.Tool { |
| 65 | return readTool{base: base{exec: exec, name: "browser_snapshot", |
| 66 | description: "Capture the structural snapshot of a tab: an accessibility-style tree where each interactive element carries a ref such as ref=e12, plus the documentToken that binds those refs to the current document. Take a snapshot before browser_click, browser_type, browser_press, browser_scroll, browser_select, or browser_upload, and again after anything changes the page: refs and the token expire on navigation, page replacement, or user take-over. Pass selector to scope the tree to one subtree.", |
| 67 | schema: objectSchema([]string{"tabId"}, tabIDProp(), str("selector", "Optional CSS selector; only the matching subtree is captured.")), |
| 68 | snip: treeSnip, |
| 69 | }, run: runSnapshot} |
| 70 | } |
| 71 | |
| 72 | func runSnapshot(ctx context.Context, exec Executor, args json.RawMessage) (string, error) { |
| 73 | var p struct { |
| 74 | TabID string `json:"tabId"` |
| 75 | Selector string `json:"selector"` |
| 76 | } |
| 77 | if err := decode(args, &p); err != nil { |
| 78 | return "", err |
| 79 | } |
| 80 | if err := requireTab(p.TabID); err != nil { |
| 81 | return "", err |
| 82 | } |
| 83 | snap, err := exec.Snapshot(ctx, SnapshotRequest{TabID: p.TabID, Selector: p.Selector}) |
| 84 | if err != nil { |
| 85 | return "", translate(err, "browser_snapshot") |
| 86 | } |
| 87 | return fmt.Sprintf("documentToken: %s\nurl: %s\ntitle: %s\nrefs: %d\n\n%s", snap.DocumentToken, snap.URL, snap.Title, snap.Refs, snap.Tree), nil |
| 88 | } |
| 89 | |
| 90 | func downloadTool(exec Executor) tool.Tool { |
| 91 | return readTool{base: base{exec: exec, name: "browser_download", |
| 92 | description: "List the downloads of a tab, or wait up to waitSeconds for an in-progress one to finish. Each entry reports ID, URL, saved path, state, and size; the path is a task-owned file the file tools can read.", |
| 93 | schema: objectSchema([]string{"tabId"}, tabIDProp(), bounded(integer("waitSeconds", "Seconds to wait for an in-progress download to complete; 0 lists immediately."), 0, 300)), |
| 94 | snip: listSnip, |
| 95 | }, run: runDownloads} |
| 96 | } |
| 97 | |
| 98 | func runDownloads(ctx context.Context, exec Executor, args json.RawMessage) (string, error) { |
| 99 | var p struct { |
| 100 | TabID string `json:"tabId"` |
| 101 | WaitSeconds int `json:"waitSeconds"` |
| 102 | } |
| 103 | if err := decode(args, &p); err != nil { |
| 104 | return "", err |
| 105 | } |
| 106 | if err := requireTab(p.TabID); err != nil { |
| 107 | return "", err |
| 108 | } |
| 109 | if p.WaitSeconds < 0 || p.WaitSeconds > 300 { |
| 110 | return "", fmt.Errorf("waitSeconds must be between 0 and 300") |
| 111 | } |
| 112 | downloads, err := exec.Downloads(ctx, DownloadsRequest{TabID: p.TabID, WaitFor: time.Duration(p.WaitSeconds) * time.Second}) |
| 113 | if err != nil { |
| 114 | return "", translate(err, "browser_download") |
| 115 | } |
| 116 | if len(downloads) == 0 { |
| 117 | return "no downloads for tab " + p.TabID, nil |
| 118 | } |
| 119 | lines := make([]string, 0, len(downloads)+1) |
| 120 | lines = append(lines, fmt.Sprintf("%d download(s) for tab %s", len(downloads), p.TabID)) |
| 121 | for _, d := range downloads { |
| 122 | lines = append(lines, fmt.Sprintf("download %s: %s %s -> %s (%d bytes)", d.ID, d.State, d.URL, d.Path, d.Bytes)) |
| 123 | } |
| 124 | return strings.Join(lines, "\n"), nil |
| 125 | } |
| 126 | |
| 127 | type screenshot struct{ base } |
| 128 | |
| 129 | func screenshotTool(exec Executor) tool.Tool { |
| 130 | return screenshot{base{exec: exec, name: "browser_screenshot", |
| 131 | description: "Capture a PNG of a tab's viewport, of one element by ref, or of the full page, and return it as an image. Use browser_snapshot for structure and refs; use this to see layout, images, or rendering. Images over 8 MiB are not returned; capture an element or the viewport instead.", |
| 132 | schema: objectSchema([]string{"tabId"}, tabIDProp(), refProp("Optional element ref from browser_snapshot; captures only that element."), boolean("fullPage", "Capture the whole scrollable page instead of the viewport.")), |
| 133 | snip: shortSnip, |
| 134 | }} |
| 135 | } |
| 136 | |
| 137 | func (screenshot) ReadOnly() bool { return true } |
| 138 | func (screenshot) PlanModeSafe() bool { return true } |
| 139 | |
| 140 | func (t screenshot) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
| 141 | text, images, err := t.ExecuteWithImages(ctx, args) |
| 142 | if err == nil && len(images) > 0 { |
| 143 | text += "\nVisual content requires a structured image channel and an image-capable model." |
| 144 | } |
| 145 | return text, err |
| 146 | } |
| 147 | |
| 148 | func (t screenshot) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) { |
| 149 | if err := t.ready(ctx); err != nil { |
| 150 | return "", nil, err |
| 151 | } |
| 152 | var p struct { |
| 153 | TabID string `json:"tabId"` |
| 154 | Ref string `json:"ref"` |
| 155 | FullPage bool `json:"fullPage"` |
| 156 | } |
| 157 | if err := decode(args, &p); err != nil { |
| 158 | return "", nil, err |
| 159 | } |
| 160 | if err := requireTab(p.TabID); err != nil { |
| 161 | return "", nil, err |
| 162 | } |
| 163 | shot, err := t.exec.Screenshot(ctx, ScreenshotRequest{TabID: p.TabID, Ref: p.Ref, FullPage: p.FullPage}) |
| 164 | if err != nil { |
| 165 | return "", nil, translate(err, "browser_screenshot") |
| 166 | } |
| 167 | return encodeScreenshot(p.TabID, shot) |
| 168 | } |
| 169 | |
| 170 | func encodeScreenshot(tabID string, shot Screenshot) (string, []string, error) { |
| 171 | info, err := os.Stat(shot.Path) |
| 172 | if err != nil { |
| 173 | return "", nil, fmt.Errorf("read screenshot %s: %w", shot.Path, err) |
| 174 | } |
| 175 | if !info.Mode().IsRegular() { |
| 176 | return "", nil, fmt.Errorf("screenshot %s is not a regular file", shot.Path) |
| 177 | } |
| 178 | if info.Size() > screenshotMaxBytes { |
| 179 | return oversizeText(shot.Path, info.Size()), nil, nil |
| 180 | } |
| 181 | f, err := os.Open(shot.Path) |
| 182 | if err != nil { |
| 183 | return "", nil, fmt.Errorf("read screenshot %s: %w", shot.Path, err) |
| 184 | } |
| 185 | defer f.Close() |
| 186 | data, err := io.ReadAll(io.LimitReader(f, screenshotMaxBytes+1)) |
| 187 | if err != nil { |
| 188 | return "", nil, fmt.Errorf("read screenshot %s: %w", shot.Path, err) |
| 189 | } |
| 190 | if len(data) > screenshotMaxBytes { |
| 191 | return oversizeText(shot.Path, int64(len(data))), nil, nil |
| 192 | } |
| 193 | mime := shot.MIME |
| 194 | if mime == "" { |
| 195 | mime = "image/png" |
| 196 | } |
| 197 | text := fmt.Sprintf("[image: %s, %dx%d] screenshot of tab %s saved at %s", mime, shot.Width, shot.Height, tabID, shot.Path) |
| 198 | return text, []string{"data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)}, nil |
| 199 | } |
| 200 | |
| 201 | func oversizeText(path string, size int64) string { |
| 202 | return fmt.Sprintf("screenshot %s is %d bytes, over the %d MiB limit for an inline image, so it was not returned. Capture one element with ref, or the viewport without fullPage, to get a smaller image.", path, size, screenshotMaxBytes>>20) |
| 203 | } |
| 204 |