wrangle 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE.txt +48 -0
- data/README.md +473 -0
- data/exe/wrangle +374 -0
- data/lib/wrangle/action_space.rb +67 -0
- data/lib/wrangle/decider.rb +167 -0
- data/lib/wrangle/errors.rb +53 -0
- data/lib/wrangle/jev.rb +87 -0
- data/lib/wrangle/js/bridge.js +390 -0
- data/lib/wrangle/js/page.js +127 -0
- data/lib/wrangle/js/snapshot.js +118 -0
- data/lib/wrangle/jxa_bridge.rb +194 -0
- data/lib/wrangle/mcp_bridge.rb +300 -0
- data/lib/wrangle/observation.rb +50 -0
- data/lib/wrangle/run_loop.rb +298 -0
- data/lib/wrangle/safari.rb +404 -0
- data/lib/wrangle/session_server.rb +447 -0
- data/lib/wrangle/version.rb +5 -0
- data/lib/wrangle.rb +19 -0
- data/skills/wrangle/SKILL.md +206 -0
- metadata +69 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Wrangle
|
|
6
|
+
# Runs a goal, or a plan of goals, to a stopping point.
|
|
7
|
+
#
|
|
8
|
+
# This is the only part of Wrangle that acts without being told to, so it is all policy: when a
|
|
9
|
+
# decision may be executed, when a page deserves another look, and when the run has to hand back.
|
|
10
|
+
# It never touches Safari itself — the session it is given owns that, and owns the page — which
|
|
11
|
+
# keeps "what may happen" separate from "how it happens".
|
|
12
|
+
#
|
|
13
|
+
# The rules here were each written after a live page broke the version without them, and the
|
|
14
|
+
# comments say which, because the reason is the only thing that makes them worth keeping.
|
|
15
|
+
class RunLoop
|
|
16
|
+
# Acting on a 5%-confidence target is how an early build typed the origin into Google's
|
|
17
|
+
# multi-city field. Below the floor the run stops and says what it was torn between.
|
|
18
|
+
DEFAULT_CONFIDENCE = 0.5
|
|
19
|
+
# Settling starts impatient: a long poll on every step is what makes a run feel like it is
|
|
20
|
+
# stalling, and a page that streams content in never goes still anyway. The first attempt does
|
|
21
|
+
# not wait at all — the settle now runs underneath the Jev request, where it is free.
|
|
22
|
+
STEADY_BUDGET = 0.2
|
|
23
|
+
STEADY_CEILING = 1.2
|
|
24
|
+
MAX_MISSES = 4
|
|
25
|
+
# A page half-rendered when the model looked reads as ambiguity. A second decision costs ~350ms
|
|
26
|
+
# and a handoff to the calling agent costs a full model turn, measured at 5-6s.
|
|
27
|
+
SOFT_SETTLE = 0.6
|
|
28
|
+
MAX_SOFT = 1
|
|
29
|
+
SPIN_ALLOWANCE = 3
|
|
30
|
+
RECONSIDER = %i[unsure soft_done absent].freeze
|
|
31
|
+
DEFAULT_LEG_STEPS = 8
|
|
32
|
+
# Acting wrongly costs one action, which the next step can usually undo. Declaring BLOCKED throws
|
|
33
|
+
# away every remaining leg, and no later step can recover it.
|
|
34
|
+
BLOCKED_FLOOR = 0.6
|
|
35
|
+
BLOCKED_RELOOKS = 3
|
|
36
|
+
# Settling a churning page and waiting for a control to arrive are different kinds of patience.
|
|
37
|
+
# Amazon's filter sidebar has taken over two seconds after its results were already interactive.
|
|
38
|
+
BLOCKED_CEILING = 2.5
|
|
39
|
+
# A confident disagreement from the verification head outweighs the claim; an unsure one is noise.
|
|
40
|
+
VERIFY_FLOOR = 0.6
|
|
41
|
+
# A claim is put back to the page this many times before the loop stops arguing with it. A leg
|
|
42
|
+
# that has acted is only arguing about whether its own work shows, so one more look settles it; a
|
|
43
|
+
# leg that has done nothing is the premature-DONE case, and that one is worth pressing.
|
|
44
|
+
CLAIM_LOOKS = { acted: 2, idle: 3 }.freeze
|
|
45
|
+
|
|
46
|
+
def initialize(session, request, expect)
|
|
47
|
+
@session = session
|
|
48
|
+
@request = request
|
|
49
|
+
@expect = expect
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One CLI call, several sub-goals. Each leg runs to its own DONE and the next begins, so the
|
|
53
|
+
# caller is not paid a full model turn for every handoff — on a form that was ten round trips to
|
|
54
|
+
# the calling agent, which dwarfed the decisions themselves. A leg that does not reach DONE ends
|
|
55
|
+
# the plan: the later legs assume the earlier ones happened, so guessing past a failure is how a
|
|
56
|
+
# run types a date into a passenger field.
|
|
57
|
+
def run(plan)
|
|
58
|
+
budget = (@request["steps"] || 20).to_i
|
|
59
|
+
leg_cap = (@request["leg_steps"] || DEFAULT_LEG_STEPS).to_i
|
|
60
|
+
steps = []
|
|
61
|
+
plan.each_with_index do |goal, index|
|
|
62
|
+
remaining = budget - steps.count { |step| step["operation"] != "GOAL" }
|
|
63
|
+
break if remaining <= 0
|
|
64
|
+
|
|
65
|
+
steps << goal_step(goal, index) if plan.size > 1
|
|
66
|
+
leg = leg(@request.merge("goal" => goal), [remaining, leg_cap].min)
|
|
67
|
+
steps.concat(leg)
|
|
68
|
+
break unless leg.last&.fetch("operation", nil) == "DONE"
|
|
69
|
+
end
|
|
70
|
+
steps
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def goal_step(goal, index) = { "operation" => "GOAL", "action" => goal, "confidence" => 0.0, "index" => index + 1 }
|
|
76
|
+
|
|
77
|
+
# The budget counts work done, not attempts made. A stale retry, a second look at a half-rendered
|
|
78
|
+
# page, or a claim that did not check out is overhead, and charging it to the leg means a form
|
|
79
|
+
# that churns a little runs out of allowance before it finishes — while a separate spin cap still
|
|
80
|
+
# stops a loop that is making no progress at all.
|
|
81
|
+
def leg(request, budget)
|
|
82
|
+
steps = []
|
|
83
|
+
tally = { missed: 0, soft: 0, done: 0, claims: 0 }
|
|
84
|
+
spins = 0
|
|
85
|
+
while tally[:done] < budget && spins < budget * SPIN_ALLOWANCE
|
|
86
|
+
spins += 1
|
|
87
|
+
outcome = attempt(request, steps, tally)
|
|
88
|
+
break if outcome == :stale && tally[:missed] >= MAX_MISSES
|
|
89
|
+
next if outcome == :stale
|
|
90
|
+
|
|
91
|
+
look = reconsider(outcome, steps, tally)
|
|
92
|
+
next if look == :again
|
|
93
|
+
break if look == :spent
|
|
94
|
+
|
|
95
|
+
# A disputed DONE is not work and does not spend the budget. The leg carries on and looks
|
|
96
|
+
# again, which is what it would have done had it never claimed to be finished — but not
|
|
97
|
+
# instantly: "the page has not got there yet" is the commonest true reason for a dispute, and
|
|
98
|
+
# asking again in the same breath gets the same answer. Amazon's results were mid-load for
|
|
99
|
+
# both claims, and a search that had plainly worked was handed back.
|
|
100
|
+
if outcome == :unproven
|
|
101
|
+
@session.steady(patience(:absent, tally[:claims] - 1))
|
|
102
|
+
next
|
|
103
|
+
end
|
|
104
|
+
break if outcome == :stop
|
|
105
|
+
|
|
106
|
+
tally.merge!(soft: 0, missed: 0, done: tally[:done] + 1)
|
|
107
|
+
break if @expect && @session.page["text"].match?(@expect)
|
|
108
|
+
break if @session.stalled?
|
|
109
|
+
end
|
|
110
|
+
steps
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Spend the cheap look before the expensive handoff, then stop asking.
|
|
114
|
+
def reconsider(outcome, steps, tally)
|
|
115
|
+
return nil unless RECONSIDER.include?(outcome)
|
|
116
|
+
return :spent if tally[:soft] >= (outcome == :absent ? BLOCKED_RELOOKS : MAX_SOFT)
|
|
117
|
+
|
|
118
|
+
waited = patience(outcome, tally[:soft])
|
|
119
|
+
tally[:soft] += 1
|
|
120
|
+
began = now
|
|
121
|
+
@session.steady(waited)
|
|
122
|
+
steps.push(looked_again(steps.pop, tally[:soft], ((now - began) * 1000).round))
|
|
123
|
+
:again
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A stale page means the decision was made about a page that no longer exists. The freshness
|
|
127
|
+
# check fires before any input, so nothing was delivered and deciding again is safe. This is the
|
|
128
|
+
# one retry Wrangle allows itself, and only because no mutation happened.
|
|
129
|
+
def attempt(request, steps, tally)
|
|
130
|
+
began = now
|
|
131
|
+
# When the page proves it is churning faster than a decision can be made, back off instead of
|
|
132
|
+
# spinning — an animating menu will invalidate the target forever at a fixed retry rate.
|
|
133
|
+
@session.steady(backoff(request["steady"].to_f, tally[:missed]))
|
|
134
|
+
settled = now
|
|
135
|
+
step = @session.decide(request)
|
|
136
|
+
steps << step.except("choice").merge("settle_ms" => ((settled - began) * 1000).round)
|
|
137
|
+
advance(step, request, steps.last, began, tally)
|
|
138
|
+
rescue StalePage => e
|
|
139
|
+
tally[:missed] += 1
|
|
140
|
+
steps << missed_step(tally[:missed], began, e)
|
|
141
|
+
:stale
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def advance(step, request, record, began, tally)
|
|
145
|
+
choice = step.fetch("choice")
|
|
146
|
+
return stopping(choice, request, record, tally) if choice.stop?
|
|
147
|
+
return :unsure if unsure?(choice, request, record)
|
|
148
|
+
|
|
149
|
+
text, wanted = text_for(choice, request)
|
|
150
|
+
if wanted
|
|
151
|
+
record.merge!("operation" => "HANDOFF", "action" => wanted)
|
|
152
|
+
return :stop
|
|
153
|
+
end
|
|
154
|
+
record.merge!(@session.perform(choice, text).merge("step_ms" => ((now - began) * 1000).round))
|
|
155
|
+
:go
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# DONE and BLOCKED are not symmetric. Inside a plan an uncertain DONE is cheap to be wrong about —
|
|
159
|
+
# the next leg simply does the work that was not done — while an uncertain BLOCKED abandons every
|
|
160
|
+
# remaining leg. So look again at both, then let DONE through and make BLOCKED earn a handoff.
|
|
161
|
+
def stopping(choice, request, record, tally)
|
|
162
|
+
return :absent if unconfirmed_blocked?(choice, request, record, tally)
|
|
163
|
+
|
|
164
|
+
disputed = disputed_done(choice, request, record, tally)
|
|
165
|
+
return disputed if disputed
|
|
166
|
+
return :stop unless weak?(choice, request)
|
|
167
|
+
return :absent if choice.operation == "BLOCKED" && unsure?(choice, request, record)
|
|
168
|
+
|
|
169
|
+
:soft_done
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# DONE is the model reporting on its own work, chosen from the same look that proposed the
|
|
173
|
+
# actions, and it is optimistic: an Amazon plan reported success with the filter it had been
|
|
174
|
+
# asked for never applied. The verification head asked alongside it has no action to gain by
|
|
175
|
+
# saying yes, so a confident disagreement is worth more than the claim.
|
|
176
|
+
#
|
|
177
|
+
# But it is worth more, not final, and the two ways of being wrong do not cost the same. The
|
|
178
|
+
# verifier reads one snapshot; the actor knows what it did. A leg that applied an Amazon filter
|
|
179
|
+
# was disputed at 80% five runs in a row, because the only proof on the page was one link
|
|
180
|
+
# offering to remove the filter, among a hundred other elements — and handing back there throws
|
|
181
|
+
# away every remaining leg to win an argument the page cannot settle.
|
|
182
|
+
#
|
|
183
|
+
# So the dispute is decisive only against a leg that has not done anything, which is the case it
|
|
184
|
+
# was built for. A leg that acted is taken at its word once it has looked again, and the doubt is
|
|
185
|
+
# written into the transcript for whoever reads it.
|
|
186
|
+
def disputed_done(choice, request, record, tally)
|
|
187
|
+
return nil unless choice.operation == "DONE" && choice.disputed?(VERIFY_FLOOR)
|
|
188
|
+
|
|
189
|
+
tally[:claims] += 1
|
|
190
|
+
idle = tally[:done].zero?
|
|
191
|
+
said = "the page does not show #{request["goal"].to_s.inspect} (#{pct(choice.met_confidence)} sure)"
|
|
192
|
+
return unproven(record, choice, said) if tally[:claims] < CLAIM_LOOKS[idle ? :idle : :acted]
|
|
193
|
+
return handoff(record, choice, said, tally) if idle
|
|
194
|
+
|
|
195
|
+
record["action"] = "Done, but #{said}; taking the work at its word"
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def unproven(record, choice, said)
|
|
200
|
+
record.merge!("operation" => "UNPROVEN", "confidence" => choice.met_confidence,
|
|
201
|
+
"action" => "Said done, but #{said}; carrying on")
|
|
202
|
+
:unproven
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def handoff(record, choice, said, tally)
|
|
206
|
+
record.merge!("operation" => "HANDOFF", "confidence" => choice.met_confidence,
|
|
207
|
+
"action" => "Said done #{tally[:claims]} times without doing anything, " \
|
|
208
|
+
"but #{said}; check it yourself")
|
|
209
|
+
:stop
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A leg begins the instant the one before it ends, and the action that ended it may have started
|
|
213
|
+
# a navigation. So the first decisions of a leg are looking at the previous page as often as not,
|
|
214
|
+
# and "the control is not here" is exactly what a half-loaded document looks like.
|
|
215
|
+
#
|
|
216
|
+
# Confidence cannot gate this. Looking again at a page whose sidebar still has not arrived makes
|
|
217
|
+
# the model surer of the absence, not less — 43% then 76%, both wrong. What makes a BLOCKED cheap
|
|
218
|
+
# to disbelieve is that the leg has not done anything yet: there is nothing to undo, and nothing
|
|
219
|
+
# to lose but the wait.
|
|
220
|
+
def fresh_leg?(tally) = tally[:done].zero? && tally[:soft].to_i < BLOCKED_RELOOKS
|
|
221
|
+
|
|
222
|
+
def unconfirmed_blocked?(choice, request, record, tally)
|
|
223
|
+
return false unless choice.operation == "BLOCKED" && fresh_leg?(tally)
|
|
224
|
+
|
|
225
|
+
unsure?(choice, request, record, force: true)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def unsure?(choice, request, record, force: false)
|
|
229
|
+
return false unless force || weak?(choice, request)
|
|
230
|
+
|
|
231
|
+
weakest = confidence(choice)
|
|
232
|
+
reason = if force && !weak?(choice, request)
|
|
233
|
+
"#{choice.label.inspect} on a leg that has not acted yet (#{pct(weakest)} sure)"
|
|
234
|
+
else
|
|
235
|
+
"Not sure enough to act (#{pct(weakest)} on #{choice.label.inspect})"
|
|
236
|
+
end
|
|
237
|
+
record.merge!("operation" => "HANDOFF", "confidence" => weakest,
|
|
238
|
+
"action" => "#{reason}; look at the page and choose")
|
|
239
|
+
true
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Jev chooses; it never writes. The value comes from the caller, and when the caller has not
|
|
243
|
+
# supplied one the run stops and asks. The agent driving Wrangle is already a language model, so
|
|
244
|
+
# the "small model that types" is whoever is reading this output — no second key, no extra hop.
|
|
245
|
+
def text_for(choice, request)
|
|
246
|
+
return [nil, nil] unless choice.action["kind"] == "fill"
|
|
247
|
+
|
|
248
|
+
label = choice.label.downcase
|
|
249
|
+
_, literal = (request["literals"] || {}).find { |key, _| label.include?(key.to_s.downcase) }
|
|
250
|
+
return [literal, nil] if literal
|
|
251
|
+
|
|
252
|
+
[nil, "needs text for #{choice.label.inspect}; re-run with --literal #{label.split(/[^a-z]/).first}=VALUE"]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# A second look is not free and it is not nothing: it explains both the pause and why the run
|
|
256
|
+
# ended up where it did, so it belongs in the transcript rather than being quietly discarded.
|
|
257
|
+
# The pause is reported as the time actually spent, not the time allowed — a page that goes still
|
|
258
|
+
# early cuts it short, and a transcript that quoted the budget would overstate every one of them.
|
|
259
|
+
def looked_again(step, look, waited)
|
|
260
|
+
{ "operation" => "RELOOK", "confidence" => step["confidence"], "step_ms" => waited,
|
|
261
|
+
"action" => "#{step["operation"] == "HANDOFF" ? step["action"][/\A[^;]+/] : "Not sure yet"}; " \
|
|
262
|
+
"waited #{waited}ms and looked again (#{look})" }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Which kind of staleness this was is the whole diagnostic value of the step: a guard that moved,
|
|
266
|
+
# a document that was replaced, and a target that went behind an overlay are three different
|
|
267
|
+
# problems, and a transcript that calls them all "the page moved" hides which one is costing the
|
|
268
|
+
# run its time.
|
|
269
|
+
def missed_step(missed, began, error)
|
|
270
|
+
@session.observe!
|
|
271
|
+
{ "operation" => "RESTALE", "confidence" => 0.0, "step_ms" => ((now - began) * 1000).round,
|
|
272
|
+
"reason" => error.message.sub(/\.?\s*Observe again\.?\z/, ""),
|
|
273
|
+
"action" => "The page moved while deciding; looked again (#{missed})" }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def floor_for(choice, request)
|
|
277
|
+
base = (request["min_confidence"] || DEFAULT_CONFIDENCE).to_f
|
|
278
|
+
choice.operation == "BLOCKED" ? [base, BLOCKED_FLOOR].max : base
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def weak?(choice, request) = confidence(choice) < floor_for(choice, request)
|
|
282
|
+
def confidence(choice) = [choice.confidence, choice.target_confidence].compact.min.to_f
|
|
283
|
+
|
|
284
|
+
# A retry is the page saying it is churning faster than a decision can be made, so back off
|
|
285
|
+
# rather than spinning: an animating menu or a calendar streaming its prices in will invalidate
|
|
286
|
+
# the target forever at a fixed retry rate. The first attempt waits only if the caller asked it
|
|
287
|
+
# to, because the watch during the request has already done the settling for free.
|
|
288
|
+
def backoff(base, missed)
|
|
289
|
+
return base if missed.zero?
|
|
290
|
+
|
|
291
|
+
[[base, STEADY_BUDGET].max * (2**missed), STEADY_CEILING].min
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def patience(outcome, soft) = [SOFT_SETTLE * (2**soft), outcome == :absent ? BLOCKED_CEILING : STEADY_CEILING].min
|
|
295
|
+
def pct(value) = "#{(value.to_f * 100).round}%"
|
|
296
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
297
|
+
end
|
|
298
|
+
end
|