@mercuryo-ai/agentbrowse 0.2.61 → 0.2.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +102 -9
  3. package/dist/browser-session-state.d.ts +2 -11
  4. package/dist/browser-session-state.d.ts.map +1 -1
  5. package/dist/browser-session-state.js +0 -4
  6. package/dist/commands/act.d.ts.map +1 -1
  7. package/dist/commands/act.js +14 -5
  8. package/dist/commands/attach.d.ts +1 -3
  9. package/dist/commands/attach.d.ts.map +1 -1
  10. package/dist/commands/attach.js +0 -2
  11. package/dist/commands/browser-status.d.ts +0 -2
  12. package/dist/commands/browser-status.d.ts.map +1 -1
  13. package/dist/commands/browser-status.js +1 -7
  14. package/dist/commands/interaction-kernel.d.ts +1 -1
  15. package/dist/commands/interaction-kernel.d.ts.map +1 -1
  16. package/dist/commands/interaction-kernel.js +1 -1
  17. package/dist/commands/launch.d.ts +0 -1
  18. package/dist/commands/launch.d.ts.map +1 -1
  19. package/dist/commands/launch.js +0 -4
  20. package/dist/commands/observe-accessibility.d.ts.map +1 -1
  21. package/dist/commands/observe-accessibility.js +36 -2
  22. package/dist/commands/observe-inventory.d.ts +49 -7
  23. package/dist/commands/observe-inventory.d.ts.map +1 -1
  24. package/dist/commands/observe-inventory.js +807 -96
  25. package/dist/commands/observe-persistence.d.ts.map +1 -1
  26. package/dist/commands/observe-persistence.js +49 -6
  27. package/dist/commands/observe-projection.d.ts +6 -2
  28. package/dist/commands/observe-projection.d.ts.map +1 -1
  29. package/dist/commands/observe-projection.js +251 -27
  30. package/dist/commands/observe-semantics.d.ts +1 -0
  31. package/dist/commands/observe-semantics.d.ts.map +1 -1
  32. package/dist/commands/observe-semantics.js +541 -135
  33. package/dist/commands/observe-signals.d.ts +4 -4
  34. package/dist/commands/observe-signals.d.ts.map +1 -1
  35. package/dist/commands/observe-signals.js +2 -2
  36. package/dist/commands/observe-surfaces.d.ts +2 -1
  37. package/dist/commands/observe-surfaces.d.ts.map +1 -1
  38. package/dist/commands/observe-surfaces.js +143 -45
  39. package/dist/commands/observe.d.ts +5 -1
  40. package/dist/commands/observe.d.ts.map +1 -1
  41. package/dist/commands/observe.js +15 -11
  42. package/dist/commands/semantic-observe.d.ts.map +1 -1
  43. package/dist/commands/semantic-observe.js +43 -0
  44. package/dist/library.d.ts +2 -1
  45. package/dist/library.d.ts.map +1 -1
  46. package/dist/library.js +2 -1
  47. package/dist/match-resolve-fill.d.ts +196 -0
  48. package/dist/match-resolve-fill.d.ts.map +1 -0
  49. package/dist/match-resolve-fill.js +700 -0
  50. package/dist/match-resolve-fill.test-support.d.ts +34 -0
  51. package/dist/match-resolve-fill.test-support.d.ts.map +1 -0
  52. package/dist/match-resolve-fill.test-support.js +81 -0
  53. package/dist/runtime-protected-state.d.ts.map +1 -1
  54. package/dist/runtime-protected-state.js +12 -0
  55. package/dist/runtime-state.d.ts +6 -0
  56. package/dist/runtime-state.d.ts.map +1 -1
  57. package/dist/runtime-state.js +6 -0
  58. package/dist/secrets/form-matcher.d.ts.map +1 -1
  59. package/dist/secrets/form-matcher.js +76 -27
  60. package/dist/secrets/protected-exact-value-redaction.d.ts.map +1 -1
  61. package/dist/secrets/protected-exact-value-redaction.js +6 -0
  62. package/dist/secrets/protected-fill.js +3 -3
  63. package/dist/session.d.ts +3 -3
  64. package/dist/session.d.ts.map +1 -1
  65. package/dist/session.js +2 -2
  66. package/dist/solver/browser-launcher.d.ts.map +1 -1
  67. package/dist/solver/browser-launcher.js +2 -1
  68. package/dist/testing.d.ts +1 -0
  69. package/dist/testing.d.ts.map +1 -1
  70. package/dist/testing.js +1 -0
  71. package/docs/README.md +28 -11
  72. package/docs/api-reference.md +311 -19
  73. package/docs/assistive-runtime.md +41 -16
  74. package/docs/getting-started.md +45 -1
  75. package/docs/integration-checklist.md +32 -3
  76. package/docs/match-resolve-fill.md +699 -0
  77. package/docs/protected-fill.md +373 -91
  78. package/docs/testing.md +147 -15
  79. package/docs/troubleshooting.md +5 -0
  80. package/examples/README.md +7 -0
  81. package/examples/match-resolve-fill.ts +107 -0
  82. package/package.json +4 -2
@@ -0,0 +1,699 @@
1
+ # Match / Resolve / Fill Guide
2
+
3
+ `match`, `resolve`, and `fill` are the data-plane primitives on top of the
4
+ AgentBrowse browser runtime. They exist to answer one deceptively simple
5
+ question: **you have some key–value pairs in your runtime, and you want
6
+ them to end up in the right fields of a form on the page — how do you do
7
+ that cleanly?**
8
+
9
+ This guide builds the model from first principles. It assumes you have
10
+ already seen [`observe(...)`](./api-reference.md#observesession-goal) and
11
+ [`act(...)`](./api-reference.md#actsession-targetref-action-value); if
12
+ not, read [Getting Started](./getting-started.md) first.
13
+
14
+ ## The Problem
15
+
16
+ You run `observe(session)` and you get back a page inventory: some
17
+ observed targets (inputs, buttons), some fillable forms. In parallel,
18
+ your runtime holds candidate values — from a profile, a vault, the
19
+ current task context. Somehow you have to decide which value goes in
20
+ which field, and then write it into the browser.
21
+
22
+ The low-level primitive for writing is
23
+ `act(session, targetRef, 'fill', value)`. It takes exactly one target
24
+ ref and exactly one value string, and it fills the input. That's it. No
25
+ decision logic, no lookup logic.
26
+
27
+ If all you ever need is "I already know this target takes this value",
28
+ `act(...)` is fine. Real integrations hit three concerns it does not
29
+ help with:
30
+
31
+ 1. **Which candidate wins?** Your runtime has several values that could
32
+ plausibly belong in an observed field. Picking one by hand is fine
33
+ when there is a single correspondence (`email → email input`) but
34
+ becomes brittle when there are multiple candidates, host-specific
35
+ applicability rules, or protected targets that must be excluded from
36
+ open-data filling.
37
+ 2. **What if the value is not in hand yet?** Passwords, payment cards,
38
+ and anything behind user approval do not live in your process memory.
39
+ Something has to go fetch them — and ideally your fetch logic should
40
+ not be duplicated across every field that needs it.
41
+ 3. **What about values that must not touch the LLM prompt?** In agent
42
+ stacks, the orchestrating model sees everything in the prompt and in
43
+ the result objects it receives. Passing raw passwords through those
44
+ objects defeats the whole «kept out of the model» property that makes
45
+ vaults like MagicPay useful.
46
+
47
+ The three primitives in this guide address those three concerns in
48
+ order: **`match`** decides, **`resolve`** fetches, **`fill`** applies.
49
+
50
+ ## The Three Primitives
51
+
52
+ Read them top-to-bottom — that is also the order you call them.
53
+
54
+ ```
55
+ observe() ──▶ target / fillableForm
56
+
57
+
58
+ match(subject, { from })
59
+
60
+ ├── { kind: 'ready', valueRef } ────────┐
61
+ ├── { kind: 'needs_resolution', plan } ─┤
62
+ └── ambiguous / no_match / group-variants
63
+ │ │
64
+ ▼ │
65
+ resolve(plan, { with }) │
66
+ │ │
67
+ └── { kind: 'ready', valueRef }┤
68
+
69
+
70
+ fill(session, subject, plan)
71
+
72
+
73
+ act(...) on the browser
74
+ ```
75
+
76
+ - **`match(subject, { from })`** — takes an observed target or fillable
77
+ form plus a source of candidate values, returns a structured outcome:
78
+ a ready reference (when the value is already in hand), a plan (when
79
+ external resolution is needed), or an explicit ambiguity / no-match
80
+ signal. Pure, local, no network.
81
+ - **`resolve(plan, { with })`** — takes a plan (or an array of plans)
82
+ and an adapter, and walks each plan through your
83
+ `AgentbrowseMatchResolver` to produce a ready reference. This is the
84
+ one step where *your* code talks to a vault, approval UI, secret
85
+ manager, or any other external source of truth.
86
+ - **`fill(session, subject, plan, { resolver? })`** — applies a ready
87
+ plan to the browser. It's the only step that dereferences the value
88
+ (more on that below) and the only step that drives a DOM action.
89
+
90
+ If you want one sentence to remember: **match is pure; resolve owns the
91
+ adapter; fill owns the browser.**
92
+
93
+ ## Walk-through 1 — value in hand
94
+
95
+ The simplest case: you already have the value in memory, and you want to
96
+ fill one observed input with it.
97
+
98
+ ```ts
99
+ import { match, resolve, fill } from '@mercuryo-ai/agentbrowse';
100
+
101
+ const observed = await observe(session);
102
+ if (!observed.success) throw new Error(observed.reason ?? observed.message);
103
+
104
+ const emailTarget = observed.targets.find((t) => t.inputType === 'email');
105
+ if (!emailTarget) return;
106
+
107
+ const matched = await match(emailTarget, {
108
+ from: { email: 'traveler@example.com' },
109
+ });
110
+ // matched is { kind: 'ready', targetRef, fieldKey: 'email', valueRef, confidence }.
111
+
112
+ await fill(session, emailTarget, matched);
113
+ ```
114
+
115
+ What happened, step by step:
116
+
117
+ - `match` saw one observed target (the email input) and one candidate
118
+ coming from `{ email: '…' }`. AgentBrowse scored the candidate against
119
+ the target (label, `inputType`, `autocomplete`, etc.). High confidence
120
+ and no ambiguity, so it returned `kind: 'ready'`.
121
+ - The ready result carries a `valueRef` — a stable opaque string, not
122
+ the email address itself. `JSON.stringify(matched)` does not contain
123
+ `traveler@example.com`. The value is held inside a non-enumerable
124
+ accessor on the result object.
125
+ - `fill` used that accessor internally to dereference the value and
126
+ called `act(session, emailTarget.ref, 'fill', 'traveler@example.com')`.
127
+ - The final result is the regular `ActResult` shape (same contract as a
128
+ direct `act(...)` call), so you can branch on `success` / `error` the
129
+ usual way.
130
+
131
+ If `match` had returned something other than `ready` (for example,
132
+ `ambiguous` because two candidates competed), `fill` would have
133
+ short-circuited with a typed contract failure instead of guessing. See
134
+ [Failure Contract](#failure-contract) below.
135
+
136
+ ### Candidate sources
137
+
138
+ The `from` option is the same shape in every walk-through. It accepts
139
+ four forms; pick whichever fits how your runtime already holds values:
140
+
141
+ | Source | Shape | When to use |
142
+ | --- | --- | --- |
143
+ | Plain record | `{ [fieldKey]: value }` | You have a handful of open values in memory — fastest path. |
144
+ | Candidate array | `ReadonlyArray<AgentbrowseMatchCandidate>` | You want full control: `applicability` rules, `semanticTags`, or a `resolve` plan per entry. |
145
+ | `AgentbrowseMatchStore` | `{ entries(), read(candidateRef) }` | You do not want the value to sit inside the candidate list — the store lists metadata and only dereferences values through `read(candidateRef)`. |
146
+ | Group variants | array or store of `AgentbrowseGroupMatchCandidate` | The subject is a fillable form instead of a single target. See [Walk-through 5](#walk-through-5--grouped-protected-forms). |
147
+
148
+ The first three all produce single-field matches. Mix them as you like
149
+ across calls; the result shape is always the same tagged union.
150
+
151
+ ### How `match` decides
152
+
153
+ You just saw a successful single-field match. It is useful to understand
154
+ at a conceptual level how the matcher picked the candidate, because the
155
+ inputs you feed it — candidate metadata, applicability rules, target
156
+ metadata — directly shape the result. You do not compute scores by
157
+ hand; what you control is how much signal you hand the matcher.
158
+
159
+ The matcher runs in two phases: a **filter** phase that drops
160
+ candidates that cannot possibly apply, and a **score** phase that picks
161
+ a winner from what remains.
162
+
163
+ #### Filter phase
164
+
165
+ Every one of these filters is hard — if a candidate fails the check,
166
+ it's out. The matcher returns `no_match` with the corresponding reason
167
+ when all candidates drop.
168
+
169
+ - **Protected-target guard.** If the caller passed
170
+ `protectedTargetRefs` and the target's ref is in that set, the matcher
171
+ refuses everything. This is how open-data flows avoid writing values
172
+ into a password or card input. Reason: `protected_target`.
173
+ - **At least one candidate.** Empty source → reason: `no_candidate`.
174
+ - **Applicability.** A candidate declares
175
+ `applicability: { target: 'global' }` (applies anywhere) or
176
+ `{ target: 'host', value: 'example.com' }` (applies only when
177
+ `options.host` matches). Host-scoped candidates that do not match the
178
+ current host drop. Reason when zero remain: `scope_ineligible`.
179
+ - **Shape compatibility.** The candidate's value type must be
180
+ compatible with the target's inferred type. An email input only
181
+ accepts candidates whose value looks like an email (or whose declared
182
+ `type` is `email` / `text`). Dates need ISO `YYYY-MM-DD`, numbers
183
+ need numeric values, URLs need `http(s)://` prefixes. **Secret-typed
184
+ candidates (`type: 'secret'`) are always rejected** — secrets must
185
+ flow through the `resolve` plan path in Walk-through 2, never through
186
+ open-data matching. Reason when zero remain: `incompatible_shape`.
187
+
188
+ #### Score phase
189
+
190
+ From the candidates that survived filtering, the matcher ranks by an
191
+ evidence score combining signal strength on both sides:
192
+
193
+ - **On the target side:** `label`, `displayLabel`, `placeholder`,
194
+ `inputName`, `inputType`, `autocomplete`, and other accessibility
195
+ signals collected by `observe(...)`. The matcher looks at direct
196
+ signals first (high weight) and broader context signals with a
197
+ small penalty.
198
+ - **On the candidate side:** `fieldKey` (expanded into an evidence
199
+ vocabulary — for example, `fieldKey: 'email'` also scores against
200
+ terms like `"e-mail"`, `"email address"`, etc.), `label`, and any
201
+ `semanticTags` you provide.
202
+
203
+ Richer candidates win ambiguous cases. The most impactful thing you
204
+ control here is the candidate's metadata — providing `label` and
205
+ `semanticTags` alongside `fieldKey` gives the matcher more handles.
206
+
207
+ On top of the evidence score, two priority boosts break ties:
208
+
209
+ - **Host-scoped candidates** (`applicability: { target: 'host', value: <host> }`)
210
+ get a small boost over global candidates at the same evidence score.
211
+ Use this when the same field has different values on different sites.
212
+ - **Session-local values** (candidates with `source: 'session_open_value'`
213
+ — only relevant when you construct `ObservedFieldCandidate` objects
214
+ via the lower-level API) beat profile-fact ties. Use this to prefer
215
+ values a user just entered over generic profile defaults.
216
+
217
+ #### Confidence, ambiguity, and low-confidence — three distinct outcomes
218
+
219
+ These three adjacent concepts are easy to confuse. They each mean a
220
+ different thing and need a different fix.
221
+
222
+ - **`confidence: 'high'` / `'medium'` on a `ready` result.** The matcher
223
+ picked a clear winner; the field only reports whether it was a strong
224
+ match or a marginal one. Both are safe to fill. Use the field only
225
+ if your policy wants an extra guard (for example, auto-fill on
226
+ `'high'` and ask the user on `'medium'`).
227
+ - **`kind: 'no_match'` with `reason: 'low_confidence'`.** No candidate
228
+ scored high enough to be plausible. Fix: add metadata to your
229
+ candidates (richer `label`, explicit `type`, `semanticTags`) or
230
+ narrow the source.
231
+ - **`kind: 'ambiguous'`.** Several candidates scored within a small
232
+ threshold of the top. The matcher refuses to guess. Fix: disambiguate
233
+ by passing a narrower source for this target, or pick one of
234
+ `result.candidates` explicitly in your wrapper.
235
+
236
+ #### Grouped matching is simpler
237
+
238
+ When the subject is a fillable form (not a single target), the matcher
239
+ does not run signal-based evidence scoring. It ranks group candidates
240
+ by two things only:
241
+
242
+ 1. **Overlap.** How many of the form's `fieldKeys` the candidate
243
+ claims to cover.
244
+ 2. **Declared confidence** on the candidate itself (`'high'` >
245
+ `'medium'` > undefined).
246
+
247
+ The winner gets `confidence: 'high'` when it covers every field the
248
+ form declared, otherwise `'medium'`. Ties on `overlap + confidence`
249
+ surface as `ambiguous_group` rather than being resolved by list
250
+ position — that refusal is a deterministic-contract property of the
251
+ grouped matcher.
252
+
253
+ ## Walk-through 2 — value behind a lookup
254
+
255
+ Some values cannot live in memory: a password behind approval, a payment
256
+ card stored in a vault, a secret issued per request. For these you
257
+ describe the candidate as *needing resolution* and let AgentBrowse hand
258
+ the plan to a caller-supplied adapter.
259
+
260
+ The key word is **resolve**, and it has a specific meaning here. It is
261
+ not «resolve a Promise» and not «resolve a conflict». It means:
262
+
263
+ > Turn a match result that says *«this candidate requires external
264
+ > data»* into a match result that says *«the value is ready — here is
265
+ > the ref»*, by running a caller-supplied adapter.
266
+
267
+ The adapter is yours. AgentBrowse has no idea what a vault is.
268
+
269
+ ```ts
270
+ const passwordTarget = observed.targets.find((t) => t.inputType === 'password');
271
+
272
+ const pending = await match(passwordTarget!, {
273
+ from: [
274
+ {
275
+ fieldKey: 'password',
276
+ type: 'secret',
277
+ resolve: { kind: 'vault_lookup', key: 'login.password' },
278
+ },
279
+ ],
280
+ });
281
+ // pending is { kind: 'needs_resolution', targetRef, fieldKey, candidateRef, confidence, plan }.
282
+
283
+ const vaultResolver: AgentbrowseMatchResolver = {
284
+ async resolve(plan) {
285
+ if ('fillRef' in plan) {
286
+ throw new Error('This adapter only handles single-field plans.');
287
+ }
288
+ // Your real logic goes here — MagicPay request, KMS call, env var, etc.
289
+ const value = await readFromMyVault(plan.resolve.key!);
290
+ return { kind: 'value', value };
291
+ },
292
+ };
293
+
294
+ const ready = await resolve(pending, { with: vaultResolver });
295
+ // ready is { kind: 'ready', ...same shape as in Walk-through 1 }.
296
+
297
+ await fill(session, passwordTarget!, ready);
298
+ ```
299
+
300
+ Anatomy of the resolve step:
301
+
302
+ - `pending.plan` is a plain serialisable object:
303
+ `{ targetRef, candidateRef, fieldKey, type, resolve: { kind, key? , params? } }`.
304
+ It fully describes the work without carrying any value. You can log it,
305
+ pass it across a process boundary, store it in a job queue — nothing
306
+ sensitive is in there.
307
+ - `vaultResolver.resolve(plan)` returns a typed resource:
308
+ `{ kind: 'value', value }` for single fields, or
309
+ `{ kind: 'artifact', artifact, ...metadata }` for grouped plans (see
310
+ Walk-through 5). AgentBrowse wraps the result into a fresh ready match
311
+ with a stable `valueRef`.
312
+ - The whole adapter contract is two methods (see
313
+ [Resolver Interface](#resolver-interface)); you do not subclass
314
+ anything or register with a runtime. A handwritten object literal is a
315
+ perfectly valid resolver.
316
+
317
+ Why bother separating `resolve` from `fill`? Three reasons:
318
+
319
+ - Your adapter may run slower than the browser step (approval UIs,
320
+ polling, retries). Keeping them separate lets you log a checkpoint
321
+ between resolution and application.
322
+ - `resolve` can operate on batches (next walk-through), so several
323
+ fields can share one lookup round-trip.
324
+ - `resolve` is where all domain-specific behaviour lives. `fill` stays a
325
+ deterministic one-shot apply. That separation is deliberate — see
326
+ [Design Rules](#design-rules).
327
+
328
+ ## Walk-through 3 — collapsing to one call
329
+
330
+ Often you do not need the intermediate ready result. `fill` accepts an
331
+ optional `resolver`; if the plan still needs resolution, `fill` runs it
332
+ inline and applies the result in the same call.
333
+
334
+ ```ts
335
+ await fill(session, passwordTarget!, pending, { resolver: vaultResolver });
336
+ ```
337
+
338
+ This is equivalent to `fill(session, passwordTarget!, await resolve(pending, { with: vaultResolver }))`.
339
+ Use the two-call form when you want to inspect the ready result,
340
+ correlate traces, or batch multiple resolves together. Use the inline
341
+ form for one-shot integrations.
342
+
343
+ ## Walk-through 4 — resolving a batch
344
+
345
+ `resolve` also accepts an array of match results. Entries that are
346
+ already `ready` pass through untouched; only `needs_resolution` entries
347
+ hit the adapter. When more than one entry needs resolution and your
348
+ adapter implements `resolveBatch`, AgentBrowse calls it once with all of
349
+ the plans.
350
+
351
+ ```ts
352
+ const [emailReady, passwordReady, cardReady] = await resolve(
353
+ [emailMatch, passwordMatch, cardMatch],
354
+ { with: vaultResolver },
355
+ );
356
+ ```
357
+
358
+ Two rules matter:
359
+
360
+ - **Order is preserved.** The output array has the same length and same
361
+ positions as the input. Your code can rebuild the `target → ready`
362
+ mapping by index without extra bookkeeping.
363
+ - **Batch is an optimisation, not a requirement.** If your adapter only
364
+ implements `resolve` (singular), AgentBrowse falls back to
365
+ `Promise.all` over the single-plan method. Add `resolveBatch` only when
366
+ one network round-trip can fulfil many plans (typical for vault
367
+ lookups on one login form).
368
+
369
+ ## Walk-through 5 — grouped protected forms
370
+
371
+ Up to this point every walk-through filled one field at a time. Some
372
+ forms cannot be filled that way: a protected login form, a payment-card
373
+ form, an identity-document form. Either you fill the whole form atomically
374
+ (so the page's own validation and submit logic sees a consistent state),
375
+ or the caller's protected-fill guardrails apply to the whole form rather
376
+ than to individual inputs.
377
+
378
+ For these, `match` takes a `fillableForm` (one of the entries in
379
+ `observeResult.fillableForms`) as the subject, and the source is a list
380
+ of group candidates describing whole-form plans:
381
+
382
+ ```ts
383
+ const fillableForm = observed.fillableForms?.find((f) => f.purpose === 'login');
384
+ if (!fillableForm) return;
385
+
386
+ const matched = await match(fillableForm, {
387
+ from: [
388
+ {
389
+ candidateRef: 'vault_login_1',
390
+ itemRef: 'vault_login_1',
391
+ fieldKeys: ['username', 'password'],
392
+ confidence: 'high',
393
+ resolve: { kind: 'magicpay_observed_form', key: 'vault_login_1' },
394
+ },
395
+ ],
396
+ });
397
+ // matched is { kind: 'needs_resolution_group', fillRef, purpose, candidateRef, itemRef, fieldKeys, confidence, plan }.
398
+ ```
399
+
400
+ Two new things show up here:
401
+
402
+ - The resolver's `resolve(plan)` is expected to return an **artifact**,
403
+ not a single value. In the MagicPay case that artifact has shape
404
+ `{ kind: 'values', values: { username, password } }` plus metadata
405
+ (`requestId`, `resolutionPath`, `claimedAt`).
406
+ - A fresh capability, `resolver.fill`, takes over the apply step. It
407
+ receives the session, the form subject, and the ready artifact, and is
408
+ responsible for actually writing the values into the browser — usually
409
+ by delegating to `fillProtectedForm(...)` so all the stale-binding,
410
+ validation, and field-policy guards still apply.
411
+
412
+ ```ts
413
+ const magicpayResolver: AgentbrowseMatchResolver = {
414
+ async resolve(plan) {
415
+ if (!('fillRef' in plan)) throw new Error('Expected a grouped plan.');
416
+ const result = await magicpay.data.resolve(sessionId, buildResolveInput(plan));
417
+ const final = await magicpay.data.waitForResult(sessionId, result);
418
+ if (!final.ok || final.artifact.kind !== 'values') throw new Error('…');
419
+ return {
420
+ kind: 'artifact',
421
+ artifact: final.artifact,
422
+ requestId: final.requestId,
423
+ resolutionPath: final.resolutionPath,
424
+ claimedAt: new Date().toISOString(),
425
+ };
426
+ },
427
+ async fill(session, form, ready) {
428
+ const artifact = ready.artifact as { kind: 'values'; values: Record<string, string> };
429
+ return fillProtectedForm({
430
+ session,
431
+ fillableForm: form,
432
+ protectedValues: artifact.values,
433
+ });
434
+ },
435
+ };
436
+
437
+ const result = await fill(session, fillableForm, matched, {
438
+ resolver: magicpayResolver,
439
+ });
440
+ ```
441
+
442
+ The whole flow is still `match → resolve → fill`. The only change is
443
+ that the subject is a form, the candidates describe whole-form plans,
444
+ and the resolver carries a `fill` capability alongside `resolve`.
445
+
446
+ ## Resolver Interface
447
+
448
+ Two exported interfaces cover what you hand to `resolve(...)` and
449
+ `fill(...)`:
450
+
451
+ ```ts
452
+ // Main adapter — used by resolve() and by fill() when a plan needs
453
+ // external data or when grouped fill is part of the same adapter.
454
+ interface AgentbrowseMatchResolver {
455
+ resolve(plan): Promise<AgentbrowseResolvedResource>;
456
+ resolveBatch?(plans): Promise<ReadonlyArray<AgentbrowseResolvedResource>>;
457
+ fill?(session, form, ready): Promise<Record<string, unknown> & { success: boolean }>;
458
+ }
459
+
460
+ // Narrow handler — used by fill() when the caller only applies ready
461
+ // grouped artifacts and never fetches external data.
462
+ interface AgentbrowseGroupFillHandler {
463
+ fill(session, form, ready): Promise<Record<string, unknown> & { success: boolean }>;
464
+ }
465
+
466
+ type AgentbrowseResolvedResource =
467
+ | { kind: 'value'; value: string | number }
468
+ | {
469
+ kind: 'artifact';
470
+ artifact: unknown;
471
+ itemRef?: string;
472
+ requestId?: string;
473
+ resolutionPath?: string;
474
+ claimedAt?: string;
475
+ };
476
+ ```
477
+
478
+ One parameter slot, two accepted shapes. `fill(session, subject, plan,
479
+ { resolver })` accepts either interface via a union, and picks which
480
+ capability to call based on the plan's `kind` (`needs_resolution` needs
481
+ `.resolve`, `ready_group` needs `.fill`). `resolve(plan, { with })`
482
+ always requires the main `AgentbrowseMatchResolver` — the `.resolve`
483
+ method has to be there at compile time.
484
+
485
+ ### `AgentbrowseMatchResolver` capabilities
486
+
487
+ | Capability | Required? | Purpose |
488
+ | --- | --- | --- |
489
+ | `resolve` | Yes, on this interface. | Turn one plan into one ready resource. |
490
+ | `resolveBatch` | Optional — add when several plans share one external call. | Turn many plans into many resources in one call. Must preserve input order. |
491
+ | `fill` | Optional — add when the same adapter also applies grouped protected artifacts. | Apply a ready grouped artifact to the fillable form — usually a thin wrapper over `fillProtectedForm(...)`. |
492
+
493
+ Implement just `resolve` when your integration only fetches external
494
+ values (single-field flows). Add `fill` alongside when the same adapter
495
+ both fetches the artifact and applies it (the usual shape for a
496
+ MagicPay bridge around a single workflow).
497
+
498
+ ### `AgentbrowseGroupFillHandler` — narrow handler
499
+
500
+ Use this shape when the caller already has the grouped artifact in
501
+ hand (for example, after a separate `magicpay data.waitForResult(...)`
502
+ outside the AgentBrowse call) and only needs to apply it:
503
+
504
+ ```ts
505
+ import { fill, type AgentbrowseGroupFillHandler } from '@mercuryo-ai/agentbrowse';
506
+
507
+ const applier: AgentbrowseGroupFillHandler = {
508
+ async fill(session, form, ready) {
509
+ return fillProtectedForm({
510
+ session,
511
+ fillableForm: form,
512
+ protectedValues: (ready.artifact as { values: Record<string, string> }).values,
513
+ });
514
+ },
515
+ };
516
+
517
+ await fill(session, fillableForm, readyGroupMatch, { resolver: applier });
518
+ ```
519
+
520
+ Because `AgentbrowseGroupFillHandler` has no `resolve` method,
521
+ `resolve(plan, { with: applier })` would not type-check — that is
522
+ intentional. The narrow shape is only meaningful at the `fill(...)`
523
+ boundary, where the plan is already `ready_group` and no fetching is
524
+ needed.
525
+
526
+ ### Common rules
527
+
528
+ Both shapes follow the same behavioural contract:
529
+
530
+ - The adapter does not mutate its input plan — it returns a fresh
531
+ resource, and AgentBrowse wraps the result.
532
+ - Adapters must throw — not return silently — when a resource cannot be
533
+ produced for a plan the caller believed was valid. Thrown errors
534
+ propagate through `resolve(...)` / `fill(...)` to your call site.
535
+ - When you pass a handler that does not carry a capability the plan
536
+ needs (a `ready_group` plan with a handler lacking `.fill`, or a
537
+ `needs_resolution_group` plan with a handler lacking `.resolve`),
538
+ `fill(...)` returns a typed `match_resolver_required` failure instead
539
+ of throwing. See the [failure contract](#failure-contract) above.
540
+
541
+ ## Design Rules
542
+
543
+ These rules are public contract, not implementation detail. Rely on
544
+ them.
545
+
546
+ ### No raw values in public match results
547
+
548
+ Ready match results carry `valueRef` / `artifactRef` strings. The actual
549
+ value or artifact lives in a non-enumerable symbol accessor attached to
550
+ the same object. Only `fill(...)` looks through it.
551
+
552
+ This has visible consequences:
553
+
554
+ - `JSON.stringify(ready)` does not contain the value. It is safe to log,
555
+ snapshot-test, or forward to trace pipelines.
556
+ - `structuredClone(ready)` drops the accessor; the clone is a
557
+ diagnostic-only snapshot and `fill(...)` will reject it with
558
+ `match_value_unavailable`.
559
+ - Passing a ready result across a process boundary (a queue, a worker, a
560
+ subprocess, IPC) drops the accessor for the same reason.
561
+
562
+ The serialisable shape of the pipeline is the `needs_resolution` plan.
563
+ If your runtime needs to hand work to another process, ship the plan
564
+ there and let the downstream side run `resolve → fill` in one shot.
565
+
566
+ ### Stable resolved refs
567
+
568
+ After `resolve(plan)` succeeds, the ready result carries a stable ref of
569
+ the form `value:${candidateRef}:resolved` or
570
+ `artifact:${candidateRef}:resolved`. Repeated resolves of the same plan
571
+ produce the same ref.
572
+
573
+ Use this property when:
574
+
575
+ - writing snapshot tests that compare match results across runs;
576
+ - correlating traces between a resolve and a later fill;
577
+ - deduplicating resolves by ref before calling fill.
578
+
579
+ The refs before `resolve` (fresh from a `match` with an inline source)
580
+ also keep the property that they are stable for a given input array —
581
+ they are seeded by `candidateRef` and the input index, not by wall time.
582
+
583
+ ### AgentBrowse makes no network calls
584
+
585
+ `match` and `fill` are local-only. Only your adapter's `.resolve` /
586
+ `.resolveBatch` / `.fill` methods make network or I/O calls. This is
587
+ the «adapter boundary» — AgentBrowse core has zero knowledge of
588
+ MagicPay, vaults, approval UIs, or any specific transport.
589
+
590
+ ## Result Kinds
591
+
592
+ `match` returns a tagged union. Always branch on `kind`; never
593
+ infer from the presence of individual fields.
594
+
595
+ | `kind` | Meaning | Next step |
596
+ | --- | --- | --- |
597
+ | `ready` | Unique candidate; value is in memory. | `fill(...)`. |
598
+ | `needs_resolution` | Unique candidate; value needs external lookup. | `resolve(...)` or `fill(..., { resolver })`. |
599
+ | `ambiguous` | Several candidates tied for this target. | Ask the caller to disambiguate; do not fill speculatively. |
600
+ | `no_match` | No candidate applied. `reason` explains why: `protected_target`, `no_candidate`, `scope_ineligible`, `incompatible_shape`, `low_confidence`. | Skip the target. |
601
+ | `ready_group` | Grouped plan is ready for a fillable form. | `fill(session, form, matched, { resolver })` with `resolver.fill` implemented. |
602
+ | `needs_resolution_group` | Grouped plan still needs an artifact. | `resolve(...)` or `fill(..., { resolver })`. |
603
+ | `ambiguous_group` | Several group candidates tied on overlap + confidence. | Pick one candidate explicitly (e.g. via a wrapper option); do not fill. |
604
+ | `no_match_group` | No group candidate applied. | Skip the form. |
605
+
606
+ `ambiguous_group` is a deliberate deterministic-contract signal. Before
607
+ the current surface, group matching would silently pick the first
608
+ candidate in list order on a tie; now it raises the tie explicitly and
609
+ forces the caller to decide.
610
+
611
+ ## Failure Contract
612
+
613
+ When `fill` cannot proceed because of a contract problem, it returns a
614
+ structured failure instead of throwing:
615
+
616
+ ```ts
617
+ interface AgentbrowseFillFailureResult {
618
+ success: false;
619
+ failureSurface: 'contract';
620
+ error:
621
+ | 'match_no_match'
622
+ | 'match_ambiguous'
623
+ | 'match_resolver_required'
624
+ | 'match_value_unavailable'
625
+ | 'match_artifact_unavailable';
626
+ outcomeType: 'blocked' | 'unsupported';
627
+ message: string;
628
+ reason: string;
629
+ targetRef?: string;
630
+ fillRef?: string;
631
+ action: 'fill';
632
+ }
633
+ ```
634
+
635
+ Branch on `error`:
636
+
637
+ | `error` | Root cause | Action |
638
+ | --- | --- | --- |
639
+ | `match_no_match` | `fill` received a `no_match` / `no_match_group` plan, or a subject-shape mismatch (field plan on a form, form plan on a field). | Do not retry with the same input. Check the match result first or re-observe. |
640
+ | `match_ambiguous` | `fill` received an `ambiguous` / `ambiguous_group` plan. | Ask the caller to disambiguate. |
641
+ | `match_resolver_required` | Plan needs external resolution and no `resolver` was passed. For grouped plans, also raised when `resolver.fill` is missing. | Supply the resolver adapter (with `fill` implemented when grouped). |
642
+ | `match_value_unavailable` | Internal accessor is gone. Almost always means a ready result was serialised across a process boundary and the non-enumerable accessor was lost. | Run `resolve` → `fill` inside the same process, or ship the `needs_resolution` plan instead. |
643
+ | `match_artifact_unavailable` | Same failure mode as above, for grouped ready plans. | Same fix. |
644
+
645
+ Browser-level failures (stale refs, page-level validation, connection)
646
+ surface through the underlying `ActResult` or `resolver.fill` result —
647
+ see [api-reference.md → Error codes by command](./api-reference.md#error-codes-by-command)
648
+ and [protected-fill.md](./protected-fill.md).
649
+
650
+ ## Relation To `act(...)` And `fillProtectedForm(...)`
651
+
652
+ Three doors into the browser, increasing level of abstraction:
653
+
654
+ | API | Owns | Use it when |
655
+ | --- | --- | --- |
656
+ | `act(session, ref, 'fill', value)` | One raw target, one raw value. | You already hold the value, you already know the target, you do not need a match decision. |
657
+ | `fillProtectedForm(...)` — from `@mercuryo-ai/agentbrowse/protected-fill` | Applying a ready set of protected values to a fillable form with stale-binding, validation, and field-policy guards. | You already have the values artifact (for example from a MagicPay `artifact.kind === 'values'`) and you do not want `match` / `resolve` orchestration on top. |
658
+ | `fill(session, subject, plan, { resolver? })` | End-to-end `match → resolve → apply`. Grouped subjects route through `resolver.fill`, which typically wraps `fillProtectedForm(...)`. | You want one deterministic surface for deciding, resolving, and applying. Also the right shape when your runtime composes several fields or forms in batch. |
659
+
660
+ `fillProtectedForm(...)` did not go away — it is still the right call
661
+ when the artifact is in hand and no match/resolve plan is needed. See
662
+ [protected-fill.md](./protected-fill.md) for its full contract.
663
+
664
+ ## Testing Wrappers Around These Primitives
665
+
666
+ Wrapper packages should exercise the primitives against the published
667
+ `/testing` subpath instead of fabricating result objects by hand:
668
+
669
+ ```ts
670
+ import {
671
+ createFixtureMatchStore,
672
+ createFixtureGroupStore,
673
+ createFixtureResolver,
674
+ fixtureResolvedValue,
675
+ fixtureResolvedArtifact,
676
+ } from '@mercuryo-ai/agentbrowse/testing';
677
+ ```
678
+
679
+ Available helpers:
680
+
681
+ - `createFixtureMatchStore(entries)` — opaque store for single-field
682
+ matching. Implements `AgentbrowseMatchStore` with a backing
683
+ in-memory map.
684
+ - `createFixtureGroupStore(entries)` — opaque store for grouped matching.
685
+ Implements `AgentbrowseGroupMatchStore`.
686
+ - `createFixtureResolver(resourcesByCandidateRef, { enableBatch? })` —
687
+ adapter that satisfies `AgentbrowseMatchResolver` and records
688
+ `resolveCalls` / `resolveBatchCalls` for assertions.
689
+ - `fixtureResolvedValue(value)` / `fixtureResolvedArtifact(artifact)` —
690
+ typed builders for the resolved-resource shape.
691
+
692
+ See [testing.md](./testing.md) for integration patterns.
693
+
694
+ ## Further Reading
695
+
696
+ - [API Reference — Match / Resolve / Fill](./api-reference.md)
697
+ - [Protected Fill Guide](./protected-fill.md)
698
+ - [Testing Guide](./testing.md)
699
+ - [Integration Checklist](./integration-checklist.md)