返回 DeepSeek-Reasonix
tools_write.go
根目录 / internal / browser / tools_write.go
1 package browser
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "maps"
8 "slices"
9 "strings"
10
11 "reasonix/internal/tool"
12 )
13
14 func openTool(exec Executor) tool.Tool {
15 return writeTool{base: base{exec: exec, name: "browser_open",
16 description: "Open a new tab at a URL for this task and return its tabId. Tabs share the task's login partition unless temporary is set. Follow with browser_snapshot before acting on the page.",
17 schema: objectSchema([]string{"operationId", "url"}, operationIDProp(), str("url", "Absolute URL to open."), boolean("temporary", "Open in an in-memory partition that shares no cookies or logins and is discarded when the tab closes.")),
18 snip: shortSnip,
19 }, run: runOpen}
20 }
21
22 func runOpen(ctx context.Context, exec Executor, args json.RawMessage) (string, error) {
23 var p struct {
24 OperationID string `json:"operationId"`
25 URL string `json:"url"`
26 Temporary bool `json:"temporary"`
27 }
28 if err := decode(args, &p); err != nil {
29 return "", err
30 }
31 if err := requireOperationID(p.OperationID); err != nil {
32 return "", err
33 }
34 if strings.TrimSpace(p.URL) == "" {
35 return "", fmt.Errorf("url is required")
36 }
37 tab, err := exec.Open(ctx, OpenRequest{OperationID: p.OperationID, URL: p.URL, Temporary: p.Temporary})
38 if err != nil {
39 return "", translate(err, "browser_open "+p.URL)
40 }
41 return "opened " + formatTab(tab) + "\nTake a browser_snapshot before acting on it.", nil
42 }
43
44 func navigateTool(exec Executor) tool.Tool {
45 return writeTool{base: base{exec: exec, name: "browser_navigate",
46 description: "Navigate a tab: go to a URL, or move back, forward, or reload. Navigation invalidates every earlier ref and documentToken for the tab, so take a new browser_snapshot afterwards.",
47 schema: objectSchema([]string{"action", "operationId", "tabId"}, operationIDProp(), tabIDProp(),
48 enum("action", "url goes to url; back, forward, and reload move through the tab's history.", NavigateURL, NavigateBack, NavigateForward, NavigateReload),
49 str("url", "Absolute URL; required when action is url and not accepted otherwise.")),
50 snip: shortSnip,
51 }, run: runNavigate}
52 }
53
54 func runNavigate(ctx context.Context, exec Executor, args json.RawMessage) (string, error) {
55 var p struct {
56 OperationID string `json:"operationId"`
57 TabID string `json:"tabId"`
58 Action string `json:"action"`
59 URL string `json:"url"`
60 }
61 if err := decode(args, &p); err != nil {
62 return "", err
63 }
64 if err := requireOperationID(p.OperationID); err != nil {
65 return "", err
66 }
67 if err := requireTab(p.TabID); err != nil {
68 return "", err
69 }
70 switch p.Action {
71 case NavigateURL:
72 if strings.TrimSpace(p.URL) == "" {
73 return "", fmt.Errorf("url is required when action is url")
74 }
75 case NavigateBack, NavigateForward, NavigateReload:
76 if p.URL != "" {
77 return "", fmt.Errorf("url is only accepted when action is url")
78 }
79 default:
80 return "", fmt.Errorf("action must be one of url, back, forward, reload")
81 }
82 tab, err := exec.Navigate(ctx, NavigateRequest{OperationID: p.OperationID, TabID: p.TabID, URL: p.URL, Action: p.Action})
83 if err != nil {
84 return "", translate(err, "browser_navigate "+p.Action+" on tab "+p.TabID)
85 }
86 return fmt.Sprintf("navigated (%s): %s\nEarlier refs and documentTokens for this tab are now invalid; take a new browser_snapshot.", p.Action, formatTab(tab)), nil
87 }
88
89 func closeTool(exec Executor) tool.Tool {
90 return writeTool{base: base{exec: exec, name: "browser_close",
91 description: "Close a tab. A temporary tab's partition is discarded with its last tab.",
92 schema: objectSchema([]string{"operationId", "tabId"}, operationIDProp(), tabIDProp()),
93 snip: shortSnip,
94 }, run: runClose}
95 }
96
97 func runClose(ctx context.Context, exec Executor, args json.RawMessage) (string, error) {
98 var p struct {
99 OperationID string `json:"operationId"`
100 TabID string `json:"tabId"`
101 }
102 if err := decode(args, &p); err != nil {
103 return "", err
104 }
105 if err := requireOperationID(p.OperationID); err != nil {
106 return "", err
107 }
108 if err := requireTab(p.TabID); err != nil {
109 return "", err
110 }
111 if err := exec.Close(ctx, CloseRequest{OperationID: p.OperationID, TabID: p.TabID}); err != nil {
112 return "", translate(err, "browser_close tab "+p.TabID)
113 }
114 return "closed tab " + p.TabID, nil
115 }
116
117 // actArgs is the union of every reference-bound write's arguments; decodeAct
118 // still rejects fields the particular tool's schema does not declare.
119 type actArgs struct {
120 OperationID string `json:"operationId"`
121 TabID string `json:"tabId"`
122 DocumentToken string `json:"documentToken"`
123 Ref string `json:"ref"`
124 Text string `json:"text"`
125 Keys string `json:"keys"`
126 Options []string `json:"options"`
127 Files []string `json:"files"`
128 Submit bool `json:"submit"`
129 DeltaX int `json:"deltaX"`
130 DeltaY int `json:"deltaY"`
131 }
132
133 // actSpec describes one reference-bound write beyond the operationId, tabId,
134 // and documentToken every such write carries.
135 type actSpec struct {
136 name string
137 action string
138 description string
139 required []string
140 props []property
141 validate func(actArgs) error
142 describe func(actArgs) string
143 }
144
145 func actTool(exec Executor, spec actSpec) tool.Tool {
146 required := append([]string{"operationId", "tabId", "documentToken"}, spec.required...)
147 props := append([]property{operationIDProp(), tabIDProp(), documentTokenProp()}, spec.props...)
148 allowed := make(map[string]bool, len(props))
149 for _, p := range props {
150 allowed[p.name] = true
151 }
152 run := func(ctx context.Context, exec Executor, args json.RawMessage) (string, error) {
153 a, err := decodeAct(args, allowed)
154 if err != nil {
155 return "", err
156 }
157 if err := spec.validate(a); err != nil {
158 return "", err
159 }
160 what := spec.describe(a) + " on tab " + a.TabID
161 res, err := exec.Act(ctx, ActRequest{
162 OperationID: a.OperationID, TabID: a.TabID, DocumentToken: a.DocumentToken, Action: spec.action,
163 Ref: a.Ref, Text: a.Text, Keys: a.Keys, Options: a.Options, Files: a.Files, Submit: a.Submit,
164 DeltaX: a.DeltaX, DeltaY: a.DeltaY,
165 })
166 if err != nil {
167 return "", translate(err, what)
168 }
169 return actOutcome(res, what)
170 }
171 return writeTool{base: base{exec: exec, name: spec.name, description: spec.description, schema: objectSchema(required, props...), snip: shortSnip}, run: run}
172 }
173
174 func decodeAct(args json.RawMessage, allowed map[string]bool) (actArgs, error) {
175 var raw map[string]json.RawMessage
176 if err := decode(args, &raw); err != nil {
177 return actArgs{}, err
178 }
179 for _, name := range slices.Sorted(maps.Keys(raw)) {
180 if !allowed[name] {
181 return actArgs{}, fmt.Errorf("invalid args: unknown field %q", name)
182 }
183 }
184 var a actArgs
185 if err := json.Unmarshal(args, &a); err != nil {
186 return actArgs{}, fmt.Errorf("invalid args: %w", err)
187 }
188 if err := requireOperationID(a.OperationID); err != nil {
189 return actArgs{}, err
190 }
191 if err := requireTab(a.TabID); err != nil {
192 return actArgs{}, err
193 }
194 if err := requireDocumentToken(a.DocumentToken); err != nil {
195 return actArgs{}, err
196 }
197 return a, nil
198 }
199
200 func actOutcome(res ActResult, what string) (string, error) {
201 switch {
202 case res.Outcome == OutcomeUnknown:
203 return "", unknownOutcome(what)
204 case res.Executed || res.Outcome == OutcomeExecuted:
205 s := "executed: " + what
206 if res.DocumentToken != "" {
207 s += "\ndocumentToken: " + res.DocumentToken
208 }
209 return s, nil
210 }
211 return "", notExecuted(what, res.Reason)
212 }
213
214 func clickTool(exec Executor) tool.Tool {
215 return actTool(exec, actSpec{name: "browser_click", action: ActionClick,
216 description: "Click an element by its snapshot ref with trusted mouse events at its centre. Requires the documentToken of the snapshot the ref came from and a fresh operationId; if the page changed since, the call is blocked as stale and you must snapshot again.",
217 required: []string{"ref"},
218 props: []property{refProp("Element ref from browser_snapshot, for example e12.")},
219 validate: func(a actArgs) error { return requireRef(a.Ref) },
220 describe: func(a actArgs) string { return "click " + a.Ref },
221 })
222 }
223
224 func typeTool(exec Executor) tool.Tool {
225 return actTool(exec, actSpec{name: "browser_type", action: ActionType,
226 description: "Type text into an element by its snapshot ref using trusted key events, so controlled inputs and custom widgets behave as they would for a user. Set submit to press Enter afterwards. Never type credentials: login pages are handed over to the user.",
227 required: []string{"ref", "text"},
228 props: []property{refProp("Element ref from browser_snapshot that accepts text."), str("text", "Text to type."), boolean("submit", "Press Enter after typing.")},
229 validate: func(a actArgs) error {
230 if err := requireRef(a.Ref); err != nil {
231 return err
232 }
233 if a.Text == "" && !a.Submit {
234 return fmt.Errorf("text is required unless submit is set")
235 }
236 return nil
237 },
238 describe: func(a actArgs) string { return fmt.Sprintf("type %d character(s) into %s", len(a.Text), a.Ref) },
239 })
240 }
241
242 func pressTool(exec Executor) tool.Tool {
243 return actTool(exec, actSpec{name: "browser_press", action: ActionPress,
244 description: "Press a key or chord (for example Enter, Escape, Tab, Control+a) as trusted key events. Give ref to focus an element first; omit it to press on the active element.",
245 required: []string{"keys"},
246 props: []property{str("keys", "Key name or '+'-joined chord, such as Enter or Control+a."), refProp("Optional element ref from browser_snapshot to focus first.")},
247 validate: func(a actArgs) error {
248 if strings.TrimSpace(a.Keys) == "" {
249 return fmt.Errorf("keys is required")
250 }
251 return nil
252 },
253 describe: func(a actArgs) string { return "press " + a.Keys },
254 })
255 }
256
257 func scrollTool(exec Executor) tool.Tool {
258 return actTool(exec, actSpec{name: "browser_scroll", action: ActionScroll,
259 description: "Scroll the viewport, or the element named by ref, by deltaX and deltaY pixels; positive values scroll right and down. Take a new browser_snapshot afterwards to see newly revealed elements.",
260 props: []property{refProp("Optional scrollable element ref from browser_snapshot; omit to scroll the viewport."), integer("deltaX", "Horizontal pixels; positive scrolls right."), integer("deltaY", "Vertical pixels; positive scrolls down.")},
261 validate: func(a actArgs) error {
262 if a.DeltaX == 0 && a.DeltaY == 0 {
263 return fmt.Errorf("deltaX or deltaY must be non-zero")
264 }
265 return nil
266 },
267 describe: func(a actArgs) string { return fmt.Sprintf("scroll by (%d, %d)", a.DeltaX, a.DeltaY) },
268 })
269 }
270
271 func selectTool(exec Executor) tool.Tool {
272 return actTool(exec, actSpec{name: "browser_select", action: ActionSelect,
273 description: "Choose options in a select element by its snapshot ref. Give option values or visible labels; more than one only for a multi-select.",
274 required: []string{"ref", "options"},
275 props: []property{refProp("Select element ref from browser_snapshot."), strList("options", "Option values or labels to select.")},
276 validate: func(a actArgs) error {
277 if err := requireRef(a.Ref); err != nil {
278 return err
279 }
280 return requireStrings("options", a.Options)
281 },
282 describe: func(a actArgs) string { return fmt.Sprintf("select %s in %s", strings.Join(a.Options, ", "), a.Ref) },
283 })
284 }
285
286 func uploadTool(exec Executor) tool.Tool {
287 return actTool(exec, actSpec{name: "browser_upload", action: ActionUpload,
288 description: "Attach files to a file input by its snapshot ref. Only files this task owns can be attached; a remote task stages them through the task's temporary directory first.",
289 required: []string{"ref", "files"},
290 props: []property{refProp("File input ref from browser_snapshot."), strList("files", "Paths of task-owned files to attach.")},
291 validate: func(a actArgs) error {
292 if err := requireRef(a.Ref); err != nil {
293 return err
294 }
295 return requireStrings("files", a.Files)
296 },
297 describe: func(a actArgs) string { return fmt.Sprintf("upload %d file(s) to %s", len(a.Files), a.Ref) },
298 })
299 }
300
300 lines GO