返回 DeepSeek-Reasonix
act.go
根目录 / internal / browser / cdp / act.go
1 package cdp
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "maps"
11 "strings"
12
13 "reasonix/internal/browser"
14 )
15
16 // agentInputWindow is how long after a marker trusted input still counts as
17 // the executor's own. It covers one dispatch, not a train of them.
18 const agentInputWindowMillis = 1500
19
20 // maxTypeRunes bounds one browser_type so a runaway argument cannot hold the
21 // socket for minutes: each rune costs two key events.
22 const maxTypeRunes = 10000
23
24 // errDispatched marks a failure that happened after the page already felt the
25 // action, which is the only honest reason to report an unknown outcome.
26 var errDispatched = errors.New("input reached the page")
27
28 // Snapshot re-reads the document, mints the token its refs are bound to, and
29 // clears a take-over: re-reading the page is exactly how the agent resumes.
30 func (e *Executor) Snapshot(ctx context.Context, req browser.SnapshotRequest) (browser.Snapshot, error) {
31 p, err := e.lookup(ctx, req.TabID)
32 if err != nil {
33 return browser.Snapshot{}, err
34 }
35 selector, err := json.Marshal(nullableString(req.Selector))
36 if err != nil {
37 return browser.Snapshot{}, err
38 }
39 var out struct {
40 Error string `json:"error"`
41 URL string `json:"url"`
42 Title string `json:"title"`
43 Tree string `json:"tree"`
44 Refs int `json:"refs"`
45 UserSeq int64 `json:"userSeq"`
46 Truncated bool `json:"truncated"`
47 }
48 expr := fmt.Sprintf("__rx.snapshot(%s, %d)", selector, snapshotBudget)
49 if err := e.eval(ctx, p, expr, &out); err != nil {
50 return browser.Snapshot{}, err
51 }
52 if out.Error != "" {
53 return browser.Snapshot{}, fmt.Errorf("browser_snapshot: %s", out.Error)
54 }
55 token := mintToken()
56 p.mu.Lock()
57 p.doc.token, p.doc.lastSeq, p.doc.takenOver = token, out.UserSeq, false
58 p.url, p.title = out.URL, out.Title
59 p.mu.Unlock()
60
61 tree := out.Tree
62 if out.Truncated {
63 tree += fmt.Sprintf("\n… snapshot stopped at %d nodes; pass selector to scope it to one subtree.", snapshotBudget)
64 }
65 return browser.Snapshot{DocumentToken: token, URL: out.URL, Title: out.Title, Tree: tree, Refs: out.Refs}, nil
66 }
67
68 // Act performs one reserved write after proving the model is acting on the
69 // document it last read.
70 func (e *Executor) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) {
71 p, err := e.lookup(ctx, req.TabID)
72 if err != nil {
73 return browser.ActResult{}, err
74 }
75 if err := e.guard(ctx, p, req.DocumentToken); err != nil {
76 return browser.ActResult{}, err
77 }
78 if err := e.reserve(req.OperationID, req.Action+" on "+req.TabID); err != nil {
79 return browser.ActResult{}, err
80 }
81 executed, reason, err := e.perform(ctx, p, req)
82 if err != nil {
83 return e.failure(err)
84 }
85 p.mu.Lock()
86 token := p.doc.token
87 p.mu.Unlock()
88 if !executed {
89 return browser.ActResult{Reason: reason, Outcome: browser.OutcomeNotExecuted, DocumentToken: token}, nil
90 }
91 return browser.ActResult{Executed: true, Outcome: browser.OutcomeExecuted, DocumentToken: token}, nil
92 }
93
94 // failure decides what a failed write leaves behind. A refusal the executor
95 // recognises keeps its sentinel; a failure after input reached the page is the
96 // one case where the outcome is genuinely unknown; anything else never left
97 // this process, so the model may plan again with a fresh operationId.
98 func (e *Executor) failure(err error) (browser.ActResult, error) {
99 switch {
100 case errors.Is(err, browser.ErrStaleReference), errors.Is(err, browser.ErrTakenOver), errors.Is(err, browser.ErrNoGrant):
101 return browser.ActResult{Outcome: browser.OutcomeNotExecuted}, err
102 case errors.Is(err, errDispatched):
103 return browser.ActResult{Outcome: browser.OutcomeUnknown}, fmt.Errorf("%w: %w", browser.ErrUnknownOutcome, err)
104 }
105 return browser.ActResult{Reason: err.Error(), Outcome: browser.OutcomeNotExecuted}, nil
106 }
107
108 // guard refuses a write that was planned against a document the tab has left
109 // or that a human has touched since the snapshot.
110 func (e *Executor) guard(ctx context.Context, p *page, token string) error {
111 p.mu.Lock()
112 current, takenOver := p.doc.token, p.doc.takenOver
113 p.mu.Unlock()
114 switch {
115 case takenOver:
116 return browser.ErrTakenOver
117 case current == "" || current != token:
118 return browser.ErrStaleReference
119 }
120 if _, err := e.observe(ctx, p); err != nil {
121 return err
122 }
123 p.mu.Lock()
124 takenOver, current = p.doc.takenOver, p.doc.token
125 p.mu.Unlock()
126 switch {
127 case takenOver:
128 return browser.ErrTakenOver
129 case current != token:
130 return browser.ErrStaleReference
131 }
132 return nil
133 }
134
135 // perform dispatches one action. A nil error with executed false is a refusal
136 // the model can act on; a non-nil error means the outcome is not known.
137 func (e *Executor) perform(ctx context.Context, p *page, req browser.ActRequest) (bool, string, error) {
138 switch req.Action {
139 case browser.ActionClick:
140 return e.click(ctx, p, req)
141 case browser.ActionType:
142 return e.typeText(ctx, p, req)
143 case browser.ActionPress:
144 return e.press(ctx, p, req)
145 case browser.ActionScroll:
146 return e.scroll(ctx, p, req)
147 case browser.ActionSelect:
148 return e.selectOptions(ctx, p, req)
149 case browser.ActionUpload:
150 return e.upload(ctx, p, req)
151 }
152 return false, "unsupported action " + req.Action, nil
153 }
154
155 // elementRect is the isolated world's answer for one ref: its viewport centre
156 // after scrolling it into view, or a hidden marker.
157 type elementRect struct {
158 Hidden bool `json:"hidden"`
159 X float64 `json:"x"`
160 Y float64 `json:"y"`
161 Width float64 `json:"width"`
162 Height float64 `json:"height"`
163 Tag string `json:"tag"`
164 Type string `json:"type"`
165 }
166
167 // locate resolves a ref. A ref the document no longer holds is stale, which is
168 // a refusal the tools translate into "snapshot again".
169 func (e *Executor) locate(ctx context.Context, p *page, ref string) (*elementRect, error) {
170 var rect *elementRect
171 if err := e.eval(ctx, p, fmt.Sprintf("__rx.rect(%s)", quote(ref)), &rect); err != nil {
172 return nil, err
173 }
174 if rect == nil {
175 return nil, browser.ErrStaleReference
176 }
177 return rect, nil
178 }
179
180 func (e *Executor) click(ctx context.Context, p *page, req browser.ActRequest) (bool, string, error) {
181 rect, err := e.locate(ctx, p, req.Ref)
182 if err != nil {
183 return false, "", err
184 }
185 if rect.Hidden {
186 return false, "the element has no visible box on the page", nil
187 }
188 if err := e.markAgentInput(ctx, p, agentInputWindowMillis); err != nil {
189 return false, "", err
190 }
191 base := map[string]any{"x": rect.X, "y": rect.Y, "button": "left", "clickCount": 1, "buttons": 1}
192 steps := []map[string]any{
193 merge(base, map[string]any{"type": "mouseMoved", "buttons": 0}),
194 merge(base, map[string]any{"type": "mousePressed"}),
195 merge(base, map[string]any{"type": "mouseReleased", "buttons": 0}),
196 }
197 return e.dispatchAll(ctx, p, "Input.dispatchMouseEvent", steps)
198 }
199
200 func (e *Executor) typeText(ctx context.Context, p *page, req browser.ActRequest) (bool, string, error) {
201 runes := []rune(req.Text)
202 if len(runes) > maxTypeRunes {
203 return false, fmt.Sprintf("text is %d characters, over the %d-character limit for one call", len(runes), maxTypeRunes), nil
204 }
205 ok, reason, err := e.focusRef(ctx, p, req.Ref)
206 if !ok || err != nil {
207 return false, reason, err
208 }
209 if err := e.markAgentInput(ctx, p, agentInputWindowMillis+len(runes)*10); err != nil {
210 return false, "", err
211 }
212 var events []map[string]any
213 for _, r := range runes {
214 events = append(events, charEvents(r)...)
215 }
216 if req.Submit {
217 down, up, err := chordEvents("Enter")
218 if err != nil {
219 return false, "", err
220 }
221 events = append(events, down, up)
222 }
223 return e.dispatchAll(ctx, p, "Input.dispatchKeyEvent", events)
224 }
225
226 func (e *Executor) press(ctx context.Context, p *page, req browser.ActRequest) (bool, string, error) {
227 if strings.TrimSpace(req.Ref) != "" {
228 ok, reason, err := e.focusRef(ctx, p, req.Ref)
229 if !ok || err != nil {
230 return false, reason, err
231 }
232 }
233 down, up, err := chordEvents(req.Keys)
234 if err != nil {
235 return false, err.Error(), nil
236 }
237 if err := e.markAgentInput(ctx, p, agentInputWindowMillis); err != nil {
238 return false, "", err
239 }
240 return e.dispatchAll(ctx, p, "Input.dispatchKeyEvent", []map[string]any{down, up})
241 }
242
243 func (e *Executor) scroll(ctx context.Context, p *page, req browser.ActRequest) (bool, string, error) {
244 x, y := 0.0, 0.0
245 if strings.TrimSpace(req.Ref) != "" {
246 rect, err := e.locate(ctx, p, req.Ref)
247 if err != nil {
248 return false, "", err
249 }
250 if rect.Hidden {
251 return false, "the element has no visible box on the page", nil
252 }
253 x, y = rect.X, rect.Y
254 } else {
255 metrics, err := e.layout(ctx, p)
256 if err != nil {
257 return false, "", err
258 }
259 x, y = metrics.viewWidth/2, metrics.viewHeight/2
260 }
261 if err := e.markAgentInput(ctx, p, agentInputWindowMillis); err != nil {
262 return false, "", err
263 }
264 event := map[string]any{"type": "mouseWheel", "x": x, "y": y, "deltaX": req.DeltaX, "deltaY": req.DeltaY}
265 return e.dispatchAll(ctx, p, "Input.dispatchMouseEvent", []map[string]any{event})
266 }
267
268 // selectOptions drives a select element through the DOM: a native dropdown is
269 // rendered by the platform and cannot be steered with synthetic mouse events.
270 func (e *Executor) selectOptions(ctx context.Context, p *page, req browser.ActRequest) (bool, string, error) {
271 options, err := json.Marshal(req.Options)
272 if err != nil {
273 return false, "", err
274 }
275 var out struct {
276 OK bool `json:"ok"`
277 Reason string `json:"reason"`
278 }
279 expr := fmt.Sprintf("__rx.select(%s, %s)", quote(req.Ref), options)
280 if err := e.eval(ctx, p, expr, &out); err != nil {
281 return false, "", fmt.Errorf("%w: %w", errDispatched, err)
282 }
283 return out.OK, out.Reason, nil
284 }
285
286 func (e *Executor) upload(ctx context.Context, p *page, req browser.ActRequest) (bool, string, error) {
287 files := make([]string, 0, len(req.Files))
288 for _, candidate := range req.Files {
289 resolved, refusal := e.uploads.resolve(candidate)
290 if refusal != "" {
291 return false, refusal, nil
292 }
293 files = append(files, resolved)
294 }
295 handle, err := e.evalHandle(ctx, p, fmt.Sprintf("__rx.element(%s)", quote(req.Ref)))
296 if err != nil {
297 return false, "", err
298 }
299 defer e.releaseHandle(ctx, p, handle)
300 if err := e.conn.call(ctx, p.session, "DOM.enable", nil, nil); err != nil {
301 return false, "", err
302 }
303 if err := e.conn.call(ctx, p.session, "DOM.setFileInputFiles", map[string]any{"objectId": handle, "files": files}, nil); err != nil {
304 var pe *protocolError
305 if errors.As(err, &pe) {
306 return false, pe.Message, nil
307 }
308 return false, "", fmt.Errorf("%w: %w", errDispatched, err)
309 }
310 return true, "", nil
311 }
312
313 // focusRef puts the keyboard on one ref before typing into it.
314 func (e *Executor) focusRef(ctx context.Context, p *page, ref string) (bool, string, error) {
315 if _, err := e.locate(ctx, p, ref); err != nil {
316 return false, "", err
317 }
318 var out struct {
319 OK bool `json:"ok"`
320 Reason string `json:"reason"`
321 }
322 if err := e.eval(ctx, p, fmt.Sprintf("__rx.focus(%s)", quote(ref)), &out); err != nil {
323 return false, "", err
324 }
325 return out.OK, out.Reason, nil
326 }
327
328 // dispatchAll sends a train of input events. Once the first one lands, a later
329 // failure leaves the page half-acted-on, which is an unknown outcome and never
330 // a refusal the model may retry.
331 func (e *Executor) dispatchAll(ctx context.Context, p *page, method string, events []map[string]any) (bool, string, error) {
332 for i, event := range events {
333 if err := e.conn.call(ctx, p.session, method, event, nil); err != nil {
334 if i > 0 {
335 return false, "", fmt.Errorf("%w: %w", errDispatched, err)
336 }
337 var pe *protocolError
338 if errors.As(err, &pe) {
339 return false, pe.Message, nil
340 }
341 return false, "", err
342 }
343 }
344 return true, "", nil
345 }
346
347 func merge(base, over map[string]any) map[string]any {
348 out := make(map[string]any, len(base)+len(over))
349 maps.Copy(out, base)
350 maps.Copy(out, over)
351 return out
352 }
353
354 func quote(s string) string {
355 b, err := json.Marshal(s)
356 if err != nil {
357 return `""`
358 }
359 return string(b)
360 }
361
362 func nullableString(s string) any {
363 if strings.TrimSpace(s) == "" {
364 return nil
365 }
366 return s
367 }
368
369 // mintToken returns an opaque document token. Opacity matters: a model must
370 // not be able to guess the token of a document it has not read.
371 func mintToken() string {
372 var raw [16]byte
373 if _, err := rand.Read(raw[:]); err != nil {
374 return "d-" + hex.EncodeToString(raw[:4])
375 }
376 return "d-" + hex.EncodeToString(raw[:])
377 }
378
378 lines GO