jevnav 0.1.0__py3-none-any.whl
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.
- jevnav/__init__.py +3 -0
- jevnav/__main__.py +4 -0
- jevnav/agent.py +422 -0
- jevnav/browser.py +314 -0
- jevnav/cli.py +515 -0
- jevnav/decide.py +97 -0
- jevnav/diffpage.py +204 -0
- jevnav/flow.py +256 -0
- jevnav/gates.py +150 -0
- jevnav/mcp.py +1155 -0
- jevnav/page.py +398 -0
- jevnav/play.py +188 -0
- jevnav/replay.py +183 -0
- jevnav/report.py +207 -0
- jevnav/trace.py +176 -0
- jevnav-0.1.0.dist-info/METADATA +529 -0
- jevnav-0.1.0.dist-info/RECORD +20 -0
- jevnav-0.1.0.dist-info/WHEEL +4 -0
- jevnav-0.1.0.dist-info/entry_points.txt +2 -0
- jevnav-0.1.0.dist-info/licenses/LICENSE +201 -0
jevnav/__init__.py
ADDED
jevnav/__main__.py
ADDED
jevnav/agent.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Goal-driven loop: Jev decides every step, jevnav executes, gates and records.
|
|
2
|
+
|
|
3
|
+
One request per step answers four questions together: is the goal already
|
|
4
|
+
achieved (status), what to do (action), on what (target) and with which context
|
|
5
|
+
value (value_key). The loop stops on `done`, on a gate verdict that is not
|
|
6
|
+
`auto`, when the page stops changing, or at `max_steps`.
|
|
7
|
+
|
|
8
|
+
The model's `done` is a claim, not evidence: with ``success`` (a selector) the
|
|
9
|
+
claim is verified against the page, and the result says so either way.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import page as page_module
|
|
20
|
+
from .decide import DEFAULT_MODEL, failed_decision
|
|
21
|
+
from .flow import recorded_url
|
|
22
|
+
from .gates import AUTO, verdict
|
|
23
|
+
from .trace import NullWriter, TraceWriter, describe_options, dom_hash
|
|
24
|
+
|
|
25
|
+
STATUSES = ("in_progress", "done", "stuck")
|
|
26
|
+
ACTIONS = ("click", "fill", "select", "check", "hover", "press")
|
|
27
|
+
FILLABLE = {"textbox", "searchbox", "combobox", "spinbutton"}
|
|
28
|
+
ENV_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
29
|
+
|
|
30
|
+
TEXT_JS = """() => {
|
|
31
|
+
const main = document.querySelector('main') || document.body;
|
|
32
|
+
const lines = (main.innerText || '').split('\\n')
|
|
33
|
+
.map((line) => line.split(/\\s+/).filter(Boolean).join(' '))
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
return lines.join('\\n');
|
|
36
|
+
}"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def text_digest(page: Any, limit: int = 700) -> str:
|
|
40
|
+
"""Visible text, collapsed — the state the model needs to judge 'done'."""
|
|
41
|
+
try:
|
|
42
|
+
text = page.evaluate(TEXT_JS)
|
|
43
|
+
except Exception:
|
|
44
|
+
return ""
|
|
45
|
+
return text[:limit]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def context_value(raw: str) -> tuple[str | None, str | None]:
|
|
49
|
+
"""``${VAR}`` is resolved from the environment and recorded as a name only."""
|
|
50
|
+
match = ENV_PATTERN.match(str(raw))
|
|
51
|
+
if match:
|
|
52
|
+
name = match.group(1)
|
|
53
|
+
if name not in os.environ:
|
|
54
|
+
raise KeyError(f"environment variable {name} is not set (needed by the goal context)")
|
|
55
|
+
return os.environ[name], name
|
|
56
|
+
return str(raw), None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_state(
|
|
60
|
+
*,
|
|
61
|
+
goal: str,
|
|
62
|
+
context_keys: list[str],
|
|
63
|
+
page: Any,
|
|
64
|
+
candidates: list[dict[str, Any]],
|
|
65
|
+
history: list[str],
|
|
66
|
+
step: int,
|
|
67
|
+
max_steps: int,
|
|
68
|
+
) -> str:
|
|
69
|
+
return (
|
|
70
|
+
f"Goal: {goal}\n"
|
|
71
|
+
f"Step {step} of {max_steps}.\n"
|
|
72
|
+
f"Context values available: {', '.join(context_keys) or '(none)'}\n"
|
|
73
|
+
f"Current page: {page.title()!r} — {page.url}\n"
|
|
74
|
+
f"Visible text on the page:\n{text_digest(page)}\n"
|
|
75
|
+
f"{len(candidates)} interactive elements are listed as options of the target question.\n"
|
|
76
|
+
"Steps so far:\n" + ("\n".join(f" {line}" for line in history) if history else " (none)")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_questions(
|
|
81
|
+
goal: str, context_keys: list[str], candidates: list[dict[str, Any]]
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
options = describe_options(candidates)
|
|
84
|
+
options["none"] = "No visible element fits."
|
|
85
|
+
questions: dict[str, Any] = {
|
|
86
|
+
"status": {
|
|
87
|
+
"type": "choice",
|
|
88
|
+
"instructions": (
|
|
89
|
+
f"The goal is: {goal}. Decide from the current page state and the steps already "
|
|
90
|
+
"taken: is the goal already achieved (done), is there still a sensible next step "
|
|
91
|
+
"(in_progress), or can it not be reached from here (stuck)? Answer done only if "
|
|
92
|
+
"the page itself shows it; answer stuck if no listed element can make progress."
|
|
93
|
+
),
|
|
94
|
+
"criteria": {
|
|
95
|
+
"in_progress": "A sensible next step exists.",
|
|
96
|
+
"done": "The page already shows the goal is achieved.",
|
|
97
|
+
"stuck": "No listed element can make progress towards the goal.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
"action": {
|
|
101
|
+
"type": "choice",
|
|
102
|
+
"instructions": (
|
|
103
|
+
"Which action should be performed next? click presses buttons and links, fill "
|
|
104
|
+
"types a context value into a text input, select picks a dropdown option, check "
|
|
105
|
+
"ticks a checkbox, press sends a key, hover moves the pointer."
|
|
106
|
+
),
|
|
107
|
+
"criteria": {
|
|
108
|
+
"click": "Press the element (buttons, links).",
|
|
109
|
+
"fill": "Type a context value into a text input (textbox, searchbox, combobox).",
|
|
110
|
+
"select": "Pick an option in a dropdown.",
|
|
111
|
+
"check": "Tick a checkbox or radio button.",
|
|
112
|
+
"hover": "Move the pointer over the element.",
|
|
113
|
+
"press": "Send a keyboard key to the element.",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
"target": {
|
|
117
|
+
"type": "choice",
|
|
118
|
+
"instructions": (
|
|
119
|
+
"Which single listed element should the chosen action be performed on? Pick "
|
|
120
|
+
"'none' if no element fits the action and the goal."
|
|
121
|
+
),
|
|
122
|
+
"criteria": options,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
if context_keys:
|
|
126
|
+
questions["value_key"] = {
|
|
127
|
+
"type": "choice",
|
|
128
|
+
"instructions": (
|
|
129
|
+
"If the action you chose is fill or select, you MUST pick the context value that "
|
|
130
|
+
"belongs in the target element here — never 'none' for a fill. Pick 'none' only "
|
|
131
|
+
"when the chosen action does not need a value (click, check, hover, press)."
|
|
132
|
+
),
|
|
133
|
+
"criteria": {
|
|
134
|
+
**{key: f"the value of {key}" for key in context_keys},
|
|
135
|
+
"none": "No value is needed.",
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
return questions
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def alternatives(
|
|
142
|
+
candidates: list[dict[str, Any]], decision: dict[str, Any], limit: int = 3
|
|
143
|
+
) -> list[dict[str, Any]]:
|
|
144
|
+
"""The runners-up, so a caller can resolve a review without guessing.
|
|
145
|
+
|
|
146
|
+
Surfaced when the gate wants a human: a more specific `browse` intent scores
|
|
147
|
+
much higher on the same page (measured on Wikipedia: p 0.44 -> 0.93).
|
|
148
|
+
"""
|
|
149
|
+
probabilities = decision.get("probabilities") or {}
|
|
150
|
+
ranked = [
|
|
151
|
+
{
|
|
152
|
+
"name": candidate["name"],
|
|
153
|
+
"role": candidate["role"],
|
|
154
|
+
"confidence": round(probabilities.get(candidate["cid"], 0.0), 3),
|
|
155
|
+
}
|
|
156
|
+
for candidate in candidates
|
|
157
|
+
if candidate["cid"] != decision.get("choice") and probabilities.get(candidate["cid"])
|
|
158
|
+
]
|
|
159
|
+
ranked.sort(key=lambda item: item["confidence"], reverse=True)
|
|
160
|
+
return ranked[:limit]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def resolve_value_key(
|
|
164
|
+
answer_key: str | None, target_name: str | None, context: dict[str, str]
|
|
165
|
+
) -> tuple[str | None, str | None]:
|
|
166
|
+
"""The model's choice, or a deterministic match of the field name to a context key."""
|
|
167
|
+
if answer_key in context:
|
|
168
|
+
return answer_key, "model"
|
|
169
|
+
if target_name:
|
|
170
|
+
for key in context:
|
|
171
|
+
if key.casefold() in target_name.casefold():
|
|
172
|
+
return key, "name-match"
|
|
173
|
+
return None, None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_goal(
|
|
177
|
+
goal: str,
|
|
178
|
+
*,
|
|
179
|
+
page: Any,
|
|
180
|
+
client: Any,
|
|
181
|
+
gates: dict[str, Any],
|
|
182
|
+
writer: TraceWriter | NullWriter,
|
|
183
|
+
model: str = DEFAULT_MODEL,
|
|
184
|
+
context: dict[str, str] | None = None,
|
|
185
|
+
start: str | None = None,
|
|
186
|
+
success: str | None = None,
|
|
187
|
+
max_steps: int = 8,
|
|
188
|
+
min_confidence: float | None = None,
|
|
189
|
+
dry_run: bool = False,
|
|
190
|
+
allow_risky: bool = False,
|
|
191
|
+
settle_ms: int = 300,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""Walk towards the goal until it is done, blocked, or out of steps."""
|
|
194
|
+
context = context or {}
|
|
195
|
+
if start:
|
|
196
|
+
page.goto(start, wait_until="domcontentloaded")
|
|
197
|
+
history: list[str] = []
|
|
198
|
+
records: list[dict[str, Any]] = []
|
|
199
|
+
status = "max_steps"
|
|
200
|
+
reason: str | None = None
|
|
201
|
+
unchanged = 0
|
|
202
|
+
finished = False
|
|
203
|
+
for step in range(1, max_steps + 1):
|
|
204
|
+
if settle_ms:
|
|
205
|
+
page.wait_for_timeout(settle_ms)
|
|
206
|
+
candidates, total, dropped = page_module.extract(page)
|
|
207
|
+
state = build_state(
|
|
208
|
+
goal=goal,
|
|
209
|
+
context_keys=list(context),
|
|
210
|
+
page=page,
|
|
211
|
+
candidates=candidates,
|
|
212
|
+
history=history,
|
|
213
|
+
step=step,
|
|
214
|
+
max_steps=max_steps,
|
|
215
|
+
)
|
|
216
|
+
questions = build_questions(goal, list(context), candidates)
|
|
217
|
+
try:
|
|
218
|
+
response, latency_ms = client.system_one(state, questions, model=model)
|
|
219
|
+
answers = response.get("answers") or {}
|
|
220
|
+
usage = response.get("usage") or {}
|
|
221
|
+
decision = {
|
|
222
|
+
"status": (answers.get("status") or {}).get("choice"),
|
|
223
|
+
"status_confidence": (answers.get("status") or {}).get("confidence"),
|
|
224
|
+
"action": (answers.get("action") or {}).get("choice"),
|
|
225
|
+
"action_confidence": (answers.get("action") or {}).get("confidence"),
|
|
226
|
+
"choice": (answers.get("target") or {}).get("choice"),
|
|
227
|
+
"confidence": (answers.get("target") or {}).get("confidence"),
|
|
228
|
+
"value_key": (answers.get("value_key") or {}).get("choice"),
|
|
229
|
+
"probabilities": (answers.get("target") or {}).get("probabilities") or {},
|
|
230
|
+
"model": response.get("model") or model,
|
|
231
|
+
"latency_ms": round(latency_ms, 1),
|
|
232
|
+
"usage": usage,
|
|
233
|
+
"cost_usd": (usage.get("input_tokens") or 0) * 0.042 / 1_000_000,
|
|
234
|
+
"error": None,
|
|
235
|
+
}
|
|
236
|
+
except Exception as error:
|
|
237
|
+
decision = failed_decision(error) | {"status": None, "action": None, "value_key": None}
|
|
238
|
+
chosen = page_module.by_cid(candidates, decision.get("choice") or "")
|
|
239
|
+
if chosen is not None:
|
|
240
|
+
decision["chosen_fp"] = chosen["fp"]
|
|
241
|
+
decision["chosen_name"] = chosen["name"]
|
|
242
|
+
else:
|
|
243
|
+
decision["chosen_fp"] = None
|
|
244
|
+
decision["chosen_name"] = None
|
|
245
|
+
decision["alternatives"] = alternatives(candidates, decision)
|
|
246
|
+
gate, gate_reason = verdict(
|
|
247
|
+
decision,
|
|
248
|
+
intent=goal,
|
|
249
|
+
candidate=chosen,
|
|
250
|
+
dropped=dropped,
|
|
251
|
+
gates=gates,
|
|
252
|
+
default_key="loop_min_confidence",
|
|
253
|
+
min_confidence=min_confidence,
|
|
254
|
+
)
|
|
255
|
+
if gate != AUTO and decision.get("choice") in (None, "none"):
|
|
256
|
+
gate, gate_reason = "blocked", gate_reason
|
|
257
|
+
record: dict[str, Any] = {
|
|
258
|
+
"step": step,
|
|
259
|
+
"intent": goal,
|
|
260
|
+
"action": {"type": decision.get("action") or "none"},
|
|
261
|
+
"url": recorded_url(page.url, _base_dir(writer)),
|
|
262
|
+
"title": page.title(),
|
|
263
|
+
"total_on_page": total,
|
|
264
|
+
"dropped": dropped,
|
|
265
|
+
"dom_hash": dom_hash(candidates),
|
|
266
|
+
"candidates": candidates,
|
|
267
|
+
"expected_cid": None,
|
|
268
|
+
"decision": decision,
|
|
269
|
+
"gate": {"verdict": gate, "reason": gate_reason},
|
|
270
|
+
"locator": None,
|
|
271
|
+
"result": {"correct": None, "executed": False, "error": None},
|
|
272
|
+
}
|
|
273
|
+
if chosen is not None:
|
|
274
|
+
selector, unique = page_module.locator_for(page, chosen)
|
|
275
|
+
record["locator"] = {"selector": selector, "unique": unique}
|
|
276
|
+
status = decision.get("status") or "error"
|
|
277
|
+
if status == "done":
|
|
278
|
+
reason = f"model says the goal is achieved (p={decision.get('status_confidence')})"
|
|
279
|
+
verified = None
|
|
280
|
+
if success:
|
|
281
|
+
try:
|
|
282
|
+
verified = page.locator(success).first.is_visible()
|
|
283
|
+
except Exception:
|
|
284
|
+
verified = False
|
|
285
|
+
if not verified:
|
|
286
|
+
status = "unverified"
|
|
287
|
+
reason = f"the model says done but {success!r} is not visible on the page"
|
|
288
|
+
record["verify"] = {"selector": success, "verified": verified}
|
|
289
|
+
record["gate"] = {"verdict": "n/a", "reason": "goal achieved, no action needed"}
|
|
290
|
+
records.append(writer.step(**record))
|
|
291
|
+
finished = True
|
|
292
|
+
break
|
|
293
|
+
if status == "stuck" or chosen is None or gate == "blocked":
|
|
294
|
+
status = "stuck" if status != "error" else "error"
|
|
295
|
+
reason = gate_reason or "no element can make progress"
|
|
296
|
+
records.append(writer.step(**record))
|
|
297
|
+
finished = True
|
|
298
|
+
break
|
|
299
|
+
if gate == "review" and not allow_risky:
|
|
300
|
+
status = "review"
|
|
301
|
+
reason = f"{gate_reason} — the loop stopped before acting"
|
|
302
|
+
records.append(writer.step(**record))
|
|
303
|
+
finished = True
|
|
304
|
+
break
|
|
305
|
+
action = decision.get("action")
|
|
306
|
+
if action not in ACTIONS:
|
|
307
|
+
status, reason = "error", f"model chose an unknown action {action!r}"
|
|
308
|
+
records.append(writer.step(**record))
|
|
309
|
+
finished = True
|
|
310
|
+
break
|
|
311
|
+
if action == "fill" and chosen["role"] not in FILLABLE:
|
|
312
|
+
status, reason = "blocked", f"fill does not apply to role {chosen['role']!r}"
|
|
313
|
+
records.append(writer.step(**record))
|
|
314
|
+
finished = True
|
|
315
|
+
break
|
|
316
|
+
action_dict: dict[str, Any] = {"type": action}
|
|
317
|
+
if action in {"fill", "select"}:
|
|
318
|
+
key, source = resolve_value_key(decision.get("value_key"), chosen["name"], context)
|
|
319
|
+
if key is None:
|
|
320
|
+
status, reason = "blocked", f"no context value fits the field {chosen['name']!r}"
|
|
321
|
+
records.append(writer.step(**record))
|
|
322
|
+
finished = True
|
|
323
|
+
break
|
|
324
|
+
raw = context[key]
|
|
325
|
+
value, env_name = context_value(raw)
|
|
326
|
+
action_dict["value"] = value
|
|
327
|
+
record["action"] = (
|
|
328
|
+
{"type": action, "value_from_env": env_name}
|
|
329
|
+
if env_name
|
|
330
|
+
else {"type": action, "value": value}
|
|
331
|
+
)
|
|
332
|
+
record["value_source"] = source
|
|
333
|
+
if action == "press":
|
|
334
|
+
action_dict["key"] = "Enter"
|
|
335
|
+
if not dry_run:
|
|
336
|
+
try:
|
|
337
|
+
page_module.execute(page, chosen["cid"], action_dict, settle_ms=settle_ms)
|
|
338
|
+
record["result"]["executed"] = True
|
|
339
|
+
except Exception as error:
|
|
340
|
+
record["result"]["error"] = f"{type(error).__name__}: {error}"
|
|
341
|
+
after, _, _ = page_module.extract(page)
|
|
342
|
+
changed = dom_hash(after) != record["dom_hash"]
|
|
343
|
+
unchanged = 0 if changed else unchanged + 1
|
|
344
|
+
history.append(
|
|
345
|
+
f"step {step}: {action} on {chosen['name']!r}"
|
|
346
|
+
+ (f" with {decision.get('value_key')}" if action in {"fill", "select"} else "")
|
|
347
|
+
+ (
|
|
348
|
+
f" -> ERROR {record['result']['error']}"
|
|
349
|
+
if record["result"]["error"]
|
|
350
|
+
else f" -> page {'changed' if changed else 'unchanged'}"
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
records.append(writer.step(**record))
|
|
354
|
+
if unchanged >= 2:
|
|
355
|
+
status, reason = "no_progress", "two steps in a row changed nothing on the page"
|
|
356
|
+
finished = True
|
|
357
|
+
break
|
|
358
|
+
if not finished and status != "max_steps":
|
|
359
|
+
status, reason = "max_steps", f"{max_steps} steps without reaching the goal"
|
|
360
|
+
verified = (records[-1].get("verify") or {}).get("verified") if records else None
|
|
361
|
+
if status == "done" and not success:
|
|
362
|
+
reason = (reason or "") + " — pass --success <selector> to verify the outcome"
|
|
363
|
+
return {
|
|
364
|
+
"goal": goal,
|
|
365
|
+
"status": status,
|
|
366
|
+
"reason": reason,
|
|
367
|
+
"verified": verified,
|
|
368
|
+
"success": success,
|
|
369
|
+
"steps": records,
|
|
370
|
+
"context_keys": list(context),
|
|
371
|
+
"history": history,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _base_dir(writer: TraceWriter | NullWriter) -> Any:
|
|
376
|
+
return writer.path.resolve().parent if writer.path else Path.cwd()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def summarize_goal(result: dict[str, Any]) -> dict[str, Any]:
|
|
380
|
+
gates = [record["gate"]["verdict"] for record in result["steps"]]
|
|
381
|
+
latencies = [
|
|
382
|
+
r["decision"]["latency_ms"]
|
|
383
|
+
for r in result["steps"]
|
|
384
|
+
if r["decision"].get("latency_ms") is not None
|
|
385
|
+
]
|
|
386
|
+
return {
|
|
387
|
+
"goal": result["goal"],
|
|
388
|
+
"status": result["status"],
|
|
389
|
+
"verified": result["verified"],
|
|
390
|
+
"reason": result["reason"],
|
|
391
|
+
"steps": len(result["steps"]),
|
|
392
|
+
"auto": gates.count("auto"),
|
|
393
|
+
"review": gates.count("review"),
|
|
394
|
+
"blocked": gates.count("blocked"),
|
|
395
|
+
"stopped": gates.count("n/a"),
|
|
396
|
+
"alternatives": (result["steps"][-1]["decision"].get("alternatives") or [])
|
|
397
|
+
if result["steps"] and result["status"] in {"review", "stuck"}
|
|
398
|
+
else [],
|
|
399
|
+
"hint": (
|
|
400
|
+
"Call browse again with a more specific intent (name the element and where it is); "
|
|
401
|
+
"specific intents score much higher than a broad goal."
|
|
402
|
+
)
|
|
403
|
+
if result["status"] in {"review", "stuck"}
|
|
404
|
+
else None,
|
|
405
|
+
"cost_usd": round(sum(r["decision"].get("cost_usd") or 0 for r in result["steps"]), 6),
|
|
406
|
+
"latency_p50_ms": sorted(latencies)[len(latencies) // 2] if latencies else None,
|
|
407
|
+
"latency_p95_ms": sorted(latencies)[min(len(latencies) - 1, int(0.95 * len(latencies)))]
|
|
408
|
+
if latencies
|
|
409
|
+
else None,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
__all__ = [
|
|
414
|
+
"ACTIONS",
|
|
415
|
+
"STATUSES",
|
|
416
|
+
"build_questions",
|
|
417
|
+
"build_state",
|
|
418
|
+
"context_value",
|
|
419
|
+
"run_goal",
|
|
420
|
+
"summarize_goal",
|
|
421
|
+
"text_digest",
|
|
422
|
+
]
|