@capagents/forgeqe 0.0.0-stage → 1.0.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.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,1335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Engine integration: autonomous browser tasks via LLM + Chromium (CDP).
|
|
3
|
+
|
|
4
|
+
Requires Python 3.11+ and the engine library. The Agent gets the whole goal as its task; ForgeQE adds
|
|
5
|
+
error-recovery instructions, a first-navigation hint, a repeated-control loop breaker, and
|
|
6
|
+
workflow/spec export on top.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
20
|
+
|
|
21
|
+
from . import brand
|
|
22
|
+
from .diagnostics import Diagnostics, box_for
|
|
23
|
+
from .logins import load_sessions, parse_logins, prompt_block, save_sessions, use_logins
|
|
24
|
+
from .memory import READ_ONLY_ACTIONS, MemoryStore, compact_step, flow_step, page_id
|
|
25
|
+
from .prompts import text as prompt_text, use_prompts
|
|
26
|
+
from .report import CASE_REPORT_INSTRUCTION, CaseReport, build_timeline, case_report_from_history
|
|
27
|
+
from .replay import ReplayOutcome, handoff_task, replay_flow
|
|
28
|
+
from .settings import (
|
|
29
|
+
_truthy_env,
|
|
30
|
+
_browser_use_agent_tuning_kwargs,
|
|
31
|
+
resolve_browser_headless,
|
|
32
|
+
build_default_browser_login_system_extension,
|
|
33
|
+
)
|
|
34
|
+
from .urls import (
|
|
35
|
+
_parse_allowed_domains,
|
|
36
|
+
_allowed_hosts_violation_for_url,
|
|
37
|
+
_validate_goal_urls_against_allowlist,
|
|
38
|
+
normalize_http_base_url,
|
|
39
|
+
)
|
|
40
|
+
from .llm import (
|
|
41
|
+
_build_llm,
|
|
42
|
+
_time_llm_calls,
|
|
43
|
+
_usage_from_history,
|
|
44
|
+
_agent_usage,
|
|
45
|
+
)
|
|
46
|
+
from .browser import (
|
|
47
|
+
local_browser_launch_kwargs,
|
|
48
|
+
settle_page,
|
|
49
|
+
_shutdown_browser_instance,
|
|
50
|
+
_stop_agent_instance,
|
|
51
|
+
browser_profile,
|
|
52
|
+
)
|
|
53
|
+
from .loops import (
|
|
54
|
+
STEP_EXTENSIONS,
|
|
55
|
+
PROGRESS_WINDOW,
|
|
56
|
+
agent_steps,
|
|
57
|
+
step_outcomes,
|
|
58
|
+
making_progress,
|
|
59
|
+
resume_point,
|
|
60
|
+
resume_steps,
|
|
61
|
+
_resolve_browser_control_loop_max_retries,
|
|
62
|
+
_resolve_browser_control_loop_window,
|
|
63
|
+
_loop_start,
|
|
64
|
+
_loop_nudge_message,
|
|
65
|
+
_control_loop_exceeded,
|
|
66
|
+
_control_loop_break_message,
|
|
67
|
+
_install_nudges,
|
|
68
|
+
)
|
|
69
|
+
from .summary import (
|
|
70
|
+
_primary_summary,
|
|
71
|
+
_failure_trace_markdown,
|
|
72
|
+
_failure_screenshot_asset,
|
|
73
|
+
build_browser_result_markdown,
|
|
74
|
+
judge_from_history,
|
|
75
|
+
)
|
|
76
|
+
from .workflow_yaml import (
|
|
77
|
+
_locator_from_interacted,
|
|
78
|
+
_selector_candidates_from_semantic,
|
|
79
|
+
_semantic_locators_from_locator,
|
|
80
|
+
save_browser_use_run_yaml,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
logger = logging.getLogger(__name__)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
Emit = Callable[[Dict[str, Any]], None]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class RunRequest:
|
|
91
|
+
goal: str
|
|
92
|
+
out_dir: str
|
|
93
|
+
llm: Dict[str, Any]
|
|
94
|
+
start_url: Optional[str] = None
|
|
95
|
+
headed: Optional[bool] = None
|
|
96
|
+
max_steps: Optional[int] = None
|
|
97
|
+
allowed_hosts: List[str] = field(default_factory=list)
|
|
98
|
+
session_id: Optional[str] = None
|
|
99
|
+
extend_system_message: Optional[str] = None
|
|
100
|
+
# {"dir": path, "replay": "verify" | "trust" | "off"}; no dir means no memory.
|
|
101
|
+
memory: Dict[str, Any] = field(default_factory=dict)
|
|
102
|
+
# [{name, domains, username_env, password_env, totp_env, session_file}]
|
|
103
|
+
logins: List[Dict[str, Any]] = field(default_factory=list)
|
|
104
|
+
# Number of steps in the test case this goal came from; the agent then reports a verdict for each.
|
|
105
|
+
case_steps: int = 0
|
|
106
|
+
# Step screenshots for the report: "all", "failures", or "off".
|
|
107
|
+
screenshots: str = "all"
|
|
108
|
+
# Accessibility checks on each page visited, for the report.
|
|
109
|
+
accessibility: bool = True
|
|
110
|
+
# The browser agent's prompts from prompts/browser-*.md (name -> text); missing names keep the engine's own.
|
|
111
|
+
prompts: Dict[str, str] = field(default_factory=dict)
|
|
112
|
+
# The application's API calls (network.json), for API and contract tests.
|
|
113
|
+
network: bool = True
|
|
114
|
+
# axe-core for the accessibility checks: {"script": path to axe.min.js, "tags": [...]}; empty: the built-in checks.
|
|
115
|
+
axe: Dict[str, Any] = field(default_factory=dict)
|
|
116
|
+
# Core Web Vitals and load timings of each page (performance.json).
|
|
117
|
+
vitals: bool = True
|
|
118
|
+
# Screenshots of each page for visual comparison: {"mask": [...], "pages": {...}, "full_page": bool}; None: off.
|
|
119
|
+
visual: Optional[Dict[str, Any]] = None
|
|
120
|
+
# ForgeQE overlay in a visible browser: edge glow, a bar with the current step, the person's input locked.
|
|
121
|
+
overlay: bool = True
|
|
122
|
+
# A failed run's resume.json ({steps, reason, failed_at, run_id}): replay those steps, then the agent carries on.
|
|
123
|
+
resume: Dict[str, Any] = field(default_factory=dict)
|
|
124
|
+
# Browser skills [{name, description, body}]: "given" adds them all, "choose" lets the model pick the ones the goal needs.
|
|
125
|
+
skills: List[Dict[str, Any]] = field(default_factory=list)
|
|
126
|
+
skills_pick: str = "given"
|
|
127
|
+
skills_max_chars: int = 6000
|
|
128
|
+
# Set during the run: the skills added to the agent's prompt, and the tokens choosing them took.
|
|
129
|
+
skills_used: List[str] = field(default_factory=list)
|
|
130
|
+
skills_usage: Dict[str, int] = field(default_factory=dict)
|
|
131
|
+
skills_decided: bool = False
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, d: Dict[str, Any]) -> "RunRequest":
|
|
135
|
+
return cls(
|
|
136
|
+
logins=[x for x in (d.get("logins") or []) if isinstance(x, dict)],
|
|
137
|
+
case_steps=int(d["case_steps"]) if str(d.get("case_steps") or "").isdigit() else 0,
|
|
138
|
+
screenshots=str(d.get("screenshots") or "all").lower(),
|
|
139
|
+
accessibility=d.get("accessibility") is not False,
|
|
140
|
+
overlay=d.get("overlay") is not False,
|
|
141
|
+
network=d.get("network") is not False,
|
|
142
|
+
prompts={str(k): str(v) for k, v in (d.get("prompts") or {}).items()} if isinstance(d.get("prompts"), dict) else {},
|
|
143
|
+
axe=dict(d["axe"]) if isinstance(d.get("axe"), dict) else {},
|
|
144
|
+
vitals=d.get("vitals") is not False,
|
|
145
|
+
visual=dict(d["visual"]) if isinstance(d.get("visual"), dict) else None,
|
|
146
|
+
goal=str(d.get("goal") or ""),
|
|
147
|
+
out_dir=str(d.get("out_dir") or "out"),
|
|
148
|
+
llm=dict(d.get("llm") or {}),
|
|
149
|
+
start_url=(str(d["start_url"]) if d.get("start_url") else None),
|
|
150
|
+
headed=d.get("headed") if isinstance(d.get("headed"), bool) else None,
|
|
151
|
+
max_steps=int(d["max_steps"]) if str(d.get("max_steps") or "").isdigit() else None,
|
|
152
|
+
allowed_hosts=[str(h) for h in (d.get("allowed_hosts") or []) if str(h).strip()],
|
|
153
|
+
session_id=(str(d["session_id"]) if d.get("session_id") else None),
|
|
154
|
+
extend_system_message=(str(d["extend_system_message"]) if d.get("extend_system_message") else None),
|
|
155
|
+
memory=dict(d.get("memory") or {}),
|
|
156
|
+
resume=dict(d["resume"]) if isinstance(d.get("resume"), dict) else {},
|
|
157
|
+
skills=[x for x in (d.get("skills") or []) if isinstance(x, dict)],
|
|
158
|
+
skills_pick="choose" if str(d.get("skills_pick") or "") == "choose" else "given",
|
|
159
|
+
skills_max_chars=int(d["skills_max_chars"]) if str(d.get("skills_max_chars") or "").isdigit() else 6000,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_initial_navigate_actions(url: str) -> List[Dict[str, Any]]:
|
|
164
|
+
"""The library's registry action for deterministic first navigation."""
|
|
165
|
+
return [{"navigate": {"url": url, "new_tab": False}}]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _agent_initial_actions_construction_error(exc: BaseException) -> bool:
|
|
169
|
+
"""True when Agent(**kw) failed because of initial_actions shape/support."""
|
|
170
|
+
if isinstance(exc, (KeyError, TypeError)):
|
|
171
|
+
return True
|
|
172
|
+
try:
|
|
173
|
+
from pydantic import ValidationError
|
|
174
|
+
|
|
175
|
+
if isinstance(exc, ValidationError):
|
|
176
|
+
return True
|
|
177
|
+
except ImportError:
|
|
178
|
+
pass
|
|
179
|
+
msg = str(exc).lower()
|
|
180
|
+
return any(token in msg for token in ("initial_actions", "go_to_url", "navigate", "goto"))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Appended to the library's Agent system prompt so the model does not blindly proceed past UI failures.
|
|
184
|
+
_BROWSER_ERROR_RECOVERY_SYSTEM_MESSAGE = """
|
|
185
|
+
## Error detection and recovery (required)
|
|
186
|
+
After each meaningful action, and before you treat a sub-step as done or advance the scenario:
|
|
187
|
+
|
|
188
|
+
1. **Read the live page** (including modals and corners) for failure signals: `alert()` dialogs, toast/snackbar errors, banner alerts, form validation (red borders, field-level error text), empty required fields, **HTTP error pages** (4xx/5xx), generic “Something went wrong” / “Error” / “Failed” copy, access denied, rate limits, or an unexpected **login / session expired** wall when you expected to be authenticated.
|
|
189
|
+
|
|
190
|
+
2. **If any error or blocking state is visible**, do **not** continue the happy path. **Recover first**: note what failed, dismiss only when safe, fix invalid inputs, choose a different control, navigate back to a good state, or complete sign-in / MFA if required—then **re-attempt** the failed step with the corrected approach.
|
|
191
|
+
|
|
192
|
+
3. **Proceed** to the next scenario action only when the UI consistently shows a non-error state for the current goal (success confirmation, expected next screen, etc.).
|
|
193
|
+
|
|
194
|
+
4. If you cannot recover after **3–4** attempts on the **same** control (search box, autocomplete, outlet picker, dropdown), **stop immediately** and state clearly what blocked you so the user can fix data, environment, or credentials. Do **not** keep retrying the same search/select pattern indefinitely.
|
|
195
|
+
""".strip()
|
|
196
|
+
|
|
197
|
+
# A run that passes becomes a test, so "done" must mean the goal holds on the page, not an approximation of it.
|
|
198
|
+
_GOAL_VERIFICATION_SYSTEM_MESSAGE = """
|
|
199
|
+
## Finishing (required)
|
|
200
|
+
Call done with success=true only when every part of the goal holds on the page right now:
|
|
201
|
+
- Each field shows exactly what the goal asked for. Sites pre-fill fields (a default origin, dates, passengers) or keep earlier choices, and fields that take several values add yours to them: remove every value the goal did not ask for.
|
|
202
|
+
- Every setting the goal names (sort order, filters, dates, options) is applied through the page's own controls and the page shows it (the sort control reads the chosen order, the summary or URL shows the dates). Scanning unsorted results by eye does not replace applying the sort or filter.
|
|
203
|
+
- Whatever the goal asks you to find or check, you read from the page, and your final answer says what you saw.
|
|
204
|
+
- Items the page already lists (result cards, rows, prices, scores) are read from that list in one step; open an item only when the goal needs something the list does not show.
|
|
205
|
+
- A tab that looks blank right after it opened is still loading: wait for it once, and do not conclude it is empty.
|
|
206
|
+
If a part is not met and you cannot make it so, call done with success=false and name that part.
|
|
207
|
+
""".strip()
|
|
208
|
+
|
|
209
|
+
_BLUEPRINT_STEP_EFFICIENCY = """
|
|
210
|
+
## Efficiency (automation blueprint)
|
|
211
|
+
Advance through the scenario with one clear action per step. If the page shows an error, toast, validation failure, or blocking modal, recover briefly and retry—do not re-audit the entire page after every click.
|
|
212
|
+
""".strip()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _compose_agent_extend_system_message(extra: Optional[str], *, include_error_recovery: bool = True) -> Optional[str]:
|
|
216
|
+
"""Merge default recovery instructions with optional caller-provided system extension."""
|
|
217
|
+
goal_check = prompt_text("browser-goal-verification", _GOAL_VERIFICATION_SYSTEM_MESSAGE)
|
|
218
|
+
if _truthy_env("BROWSER_USE_SKIP_ERROR_RECOVERY_PROMPT"):
|
|
219
|
+
return "\n\n".join(p for p in (goal_check, (extra or "").strip()) if p)
|
|
220
|
+
recovery = prompt_text("browser-error-recovery", _BROWSER_ERROR_RECOVERY_SYSTEM_MESSAGE) if include_error_recovery else prompt_text("browser-efficiency", _BLUEPRINT_STEP_EFFICIENCY)
|
|
221
|
+
parts = [p for p in (recovery, goal_check, (extra or "").strip()) if p]
|
|
222
|
+
if not parts:
|
|
223
|
+
return None
|
|
224
|
+
return "\n\n".join(parts)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _serialize_agent_actions(agent_output: Any) -> List[Dict[str, Any]]:
|
|
228
|
+
out: List[Dict[str, Any]] = []
|
|
229
|
+
for act in getattr(agent_output, "action", []) or []:
|
|
230
|
+
try:
|
|
231
|
+
out.append(act.model_dump(exclude_none=True, mode="json"))
|
|
232
|
+
except Exception:
|
|
233
|
+
out.append({"raw": str(act)[:400]})
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _selector_hint_strings_from_interacted(ie: Any) -> List[str]:
|
|
238
|
+
"""Turn the library's interacted_element into UI-friendly locator strings."""
|
|
239
|
+
loc = _locator_from_interacted(ie)
|
|
240
|
+
if not loc:
|
|
241
|
+
return []
|
|
242
|
+
sem = _semantic_locators_from_locator(loc)
|
|
243
|
+
hints: List[str] = []
|
|
244
|
+
for cand in _selector_candidates_from_semantic(sem):
|
|
245
|
+
ctype = str(cand.get("type") or "")
|
|
246
|
+
if ctype == "css" and cand.get("value"):
|
|
247
|
+
hints.append(str(cand["value"]))
|
|
248
|
+
elif ctype == "test_id" and cand.get("value"):
|
|
249
|
+
hints.append(f'[data-testid="{cand["value"]}"]')
|
|
250
|
+
elif ctype == "role" and cand.get("role"):
|
|
251
|
+
name = cand.get("name")
|
|
252
|
+
hints.append(f'role={cand["role"]} name="{name}"' if name else f'role={cand["role"]}')
|
|
253
|
+
elif ctype in ("label", "text") and cand.get("value"):
|
|
254
|
+
hints.append(f'{ctype}="{cand["value"]}"')
|
|
255
|
+
return hints
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _element_label(browser_state: Any, index: Any) -> str:
|
|
259
|
+
"""Accessible name or visible text of the element at a selector-map index, for the transcript."""
|
|
260
|
+
try:
|
|
261
|
+
node = browser_state.dom_state.selector_map.get(int(index))
|
|
262
|
+
except Exception:
|
|
263
|
+
return ""
|
|
264
|
+
if node is None:
|
|
265
|
+
return ""
|
|
266
|
+
for getter in (
|
|
267
|
+
lambda n: getattr(getattr(n, "ax_node", None), "name", None),
|
|
268
|
+
lambda n: (getattr(n, "attributes", None) or {}).get("aria-label"),
|
|
269
|
+
lambda n: (getattr(n, "attributes", None) or {}).get("placeholder"),
|
|
270
|
+
lambda n: n.get_all_children_text() if hasattr(n, "get_all_children_text") else None,
|
|
271
|
+
lambda n: (getattr(n, "attributes", None) or {}).get("value"),
|
|
272
|
+
):
|
|
273
|
+
try:
|
|
274
|
+
text = getter(node)
|
|
275
|
+
except Exception:
|
|
276
|
+
text = None
|
|
277
|
+
if text and str(text).strip():
|
|
278
|
+
return re.sub(r"\s+", " ", str(text)).strip()[:80]
|
|
279
|
+
return str(getattr(node, "tag_name", "") or "")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def describe_action(action: Dict[str, Any], browser_state: Any = None) -> str:
|
|
283
|
+
"""One transcript line per library action, e.g. `click "Accept"` or `input "Mumbai" → "Where are you going?"`."""
|
|
284
|
+
for name, params in action.items():
|
|
285
|
+
if name in ("interacted_element", "result"):
|
|
286
|
+
continue
|
|
287
|
+
p = params if isinstance(params, dict) else {}
|
|
288
|
+
label = _element_label(browser_state, p.get("index")) if p.get("index") is not None else ""
|
|
289
|
+
target = f' "{label}"' if label else (f" #{p.get('index')}" if p.get("index") is not None else "")
|
|
290
|
+
if name in ("navigate", "go_to_url"):
|
|
291
|
+
return f"navigate {p.get('url') or ''}".strip()
|
|
292
|
+
if name == "search":
|
|
293
|
+
return f'search "{p.get("query") or ""}"'
|
|
294
|
+
if name in ("input", "input_text"):
|
|
295
|
+
return f'input "{p.get("text") or ""}" →{target}'
|
|
296
|
+
if name == "click":
|
|
297
|
+
return f"click{target}"
|
|
298
|
+
if name == "done":
|
|
299
|
+
return "done"
|
|
300
|
+
if name == "record_check":
|
|
301
|
+
return f'check "{p.get("expected") or ""}" → "{p.get("text") or ""}"'
|
|
302
|
+
if name == "send_keys":
|
|
303
|
+
return f"keys {p.get('keys') or ''}".strip()
|
|
304
|
+
if name == "scroll":
|
|
305
|
+
return f"scroll {'down' if p.get('down', True) else 'up'}"
|
|
306
|
+
if name == "select_dropdown":
|
|
307
|
+
return f'select "{p.get("text") or ""}" →{target}'
|
|
308
|
+
main = next((p[k] for k in ("query", "pattern", "selector", "text", "file_name", "path", "seconds") if p.get(k) not in (None, "")), None)
|
|
309
|
+
return f'{name}{target}{f" {json.dumps(str(main))}" if main is not None else ""}'[:160]
|
|
310
|
+
return "action"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def describe_action_plainly(action: Dict[str, Any], browser_state: Any = None) -> str:
|
|
314
|
+
"""The same action in plain words for the browser's bar, e.g. `Clicking “Accept”`."""
|
|
315
|
+
for name, params in action.items():
|
|
316
|
+
if name in ("interacted_element", "result"):
|
|
317
|
+
continue
|
|
318
|
+
p = params if isinstance(params, dict) else {}
|
|
319
|
+
label = _element_label(browser_state, p.get("index")) if p.get("index") is not None else ""
|
|
320
|
+
return brand.plain_action(name, p, label)
|
|
321
|
+
return ""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _action_box(action: Dict[str, Any], browser_state: Any) -> Optional[Dict[str, float]]:
|
|
325
|
+
for name, params in action.items():
|
|
326
|
+
if name not in ("interacted_element", "result") and isinstance(params, dict) and params.get("index") is not None:
|
|
327
|
+
return box_for(browser_state, params["index"])
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def serialize_browser_step_payload(browser_state: Any, agent_output: Any, n_steps: int) -> Dict[str, Any]:
|
|
332
|
+
"""One LLM step: URL, goals, and action/locator hints for the transcript."""
|
|
333
|
+
url = getattr(browser_state, "url", None) or ""
|
|
334
|
+
title = getattr(browser_state, "title", None) or ""
|
|
335
|
+
next_goal = getattr(agent_output, "next_goal", None) or ""
|
|
336
|
+
memory = getattr(agent_output, "memory", None) or ""
|
|
337
|
+
eval_prev = getattr(agent_output, "evaluation_previous_goal", None) or ""
|
|
338
|
+
actions = _serialize_agent_actions(agent_output)
|
|
339
|
+
locator_hints: List[str] = []
|
|
340
|
+
for a in actions:
|
|
341
|
+
for key, val in a.items():
|
|
342
|
+
if key == "interacted_element":
|
|
343
|
+
locator_hints.extend(_selector_hint_strings_from_interacted(val))
|
|
344
|
+
continue
|
|
345
|
+
if isinstance(val, dict):
|
|
346
|
+
if val.get("index") is not None:
|
|
347
|
+
continue
|
|
348
|
+
if val.get("url"):
|
|
349
|
+
locator_hints.append(f"navigate → {str(val.get('url'))[:100]}")
|
|
350
|
+
status_line = f"Step {n_steps}"
|
|
351
|
+
if next_goal:
|
|
352
|
+
status_line += f": {next_goal[:200]}"
|
|
353
|
+
elif memory:
|
|
354
|
+
status_line += f": {memory[:200]}"
|
|
355
|
+
elif eval_prev:
|
|
356
|
+
status_line += f": {eval_prev[:200]}"
|
|
357
|
+
if url:
|
|
358
|
+
status_line += f" — {url[:100]}"
|
|
359
|
+
return {
|
|
360
|
+
"step_number": n_steps,
|
|
361
|
+
"decided_at": time.time(),
|
|
362
|
+
"boxes": [_action_box(a, browser_state) for a in actions],
|
|
363
|
+
"url": url,
|
|
364
|
+
"title": title,
|
|
365
|
+
"next_goal": next_goal,
|
|
366
|
+
"memory": memory,
|
|
367
|
+
"evaluation_previous_goal": eval_prev,
|
|
368
|
+
"actions": actions,
|
|
369
|
+
"action_lines": [describe_action(a, browser_state) for a in actions],
|
|
370
|
+
"plain_actions": [describe_action_plainly(a, browser_state) for a in actions],
|
|
371
|
+
"locator_hints": locator_hints[:24],
|
|
372
|
+
"status_line": status_line[:600],
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
REPLAY_MODES = ("verify", "trust", "off")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass
|
|
380
|
+
class MemoryRun:
|
|
381
|
+
"""Page and flow memory for one run: what was replayed, what the agent did, and the store to save it in."""
|
|
382
|
+
|
|
383
|
+
store: MemoryStore
|
|
384
|
+
mode: str
|
|
385
|
+
goal: str
|
|
386
|
+
start_url: Optional[str]
|
|
387
|
+
steps: List[Dict[str, Any]] = field(default_factory=list)
|
|
388
|
+
# The agent step each entry of `steps` came from; 0 for replayed steps and the opening navigate.
|
|
389
|
+
step_of: List[int] = field(default_factory=list)
|
|
390
|
+
replay: Optional[ReplayOutcome] = None
|
|
391
|
+
llm_steps: int = 0
|
|
392
|
+
finished: bool = False
|
|
393
|
+
# After a failed run: {steps, reason, failed_at, run_id}, the steps worth replaying on a retry.
|
|
394
|
+
resume: Optional[Dict[str, Any]] = None
|
|
395
|
+
run_id: str = ""
|
|
396
|
+
# The run whose checkpoint this run picked up, so finishing clears it.
|
|
397
|
+
used_checkpoint: str = ""
|
|
398
|
+
# Actions that worked without changing the page (dead clicks, read-only scripts): not part of the flow.
|
|
399
|
+
no_effect: List[str] = field(default_factory=list)
|
|
400
|
+
|
|
401
|
+
def checkpoint(self) -> Optional[Dict[str, Any]]:
|
|
402
|
+
"""Save the steps that worked so far and the page memory learned so far, so a run cut off here can be picked up."""
|
|
403
|
+
if self.mode == "off" or self.finished:
|
|
404
|
+
return None
|
|
405
|
+
steps = resume_steps(self.steps, self.step_of, 10**9)
|
|
406
|
+
if not steps:
|
|
407
|
+
return None
|
|
408
|
+
data = {"steps": steps, "reason": "", "failed_at": self.llm_steps + 1, "run_id": self.run_id, "interrupted": True}
|
|
409
|
+
self.store.save_checkpoint(self.goal, self.start_url, data)
|
|
410
|
+
self.store.flush()
|
|
411
|
+
return data
|
|
412
|
+
|
|
413
|
+
def add(self, step: Dict[str, Any], agent_step: int = 0) -> None:
|
|
414
|
+
self.steps.append(step)
|
|
415
|
+
self.step_of.append(agent_step)
|
|
416
|
+
|
|
417
|
+
@classmethod
|
|
418
|
+
def from_request(cls, req: RunRequest, start_url: Optional[str]) -> Optional["MemoryRun"]:
|
|
419
|
+
folder = str((req.memory or {}).get("dir") or "").strip()
|
|
420
|
+
if not folder:
|
|
421
|
+
return None
|
|
422
|
+
mode = str((req.memory or {}).get("replay") or "verify").lower()
|
|
423
|
+
return cls(MemoryStore(folder), mode if mode in REPLAY_MODES else "verify", req.goal, start_url)
|
|
424
|
+
|
|
425
|
+
def finish(self, out: Dict[str, Any]) -> Dict[str, Any]:
|
|
426
|
+
self.finished = True
|
|
427
|
+
status = "complete" if out.get("success") else "stopped" if out.get("cancelled") else "fail"
|
|
428
|
+
# A stopped run keeps its checkpoint, like one cut off by a crash; a finished one leaves its own result.
|
|
429
|
+
if status != "stopped":
|
|
430
|
+
try:
|
|
431
|
+
self.store.clear_checkpoint(self.goal, self.start_url, (self.run_id, self.used_checkpoint))
|
|
432
|
+
except Exception as ex:
|
|
433
|
+
logger.warning("memory: could not clear the checkpoint: %s", ex)
|
|
434
|
+
flow = None
|
|
435
|
+
try:
|
|
436
|
+
flow = self.store.save_flow_result(
|
|
437
|
+
self.goal,
|
|
438
|
+
self.start_url,
|
|
439
|
+
status=status,
|
|
440
|
+
steps=self.steps,
|
|
441
|
+
summary=str(out.get("summary") or ""),
|
|
442
|
+
final_url=str(out.get("final_url") or ""),
|
|
443
|
+
replayed=len(self.replay.done) if self.replay else 0,
|
|
444
|
+
llm_steps=self.llm_steps,
|
|
445
|
+
resume=self.resume if status == "fail" else None,
|
|
446
|
+
)
|
|
447
|
+
except Exception as ex:
|
|
448
|
+
logger.warning("memory: could not save the flow: %s", ex)
|
|
449
|
+
try:
|
|
450
|
+
self.store.flush()
|
|
451
|
+
except Exception as ex:
|
|
452
|
+
logger.warning("memory: could not write page memory: %s", ex)
|
|
453
|
+
info: Dict[str, Any] = {
|
|
454
|
+
"dir": str(self.store.root),
|
|
455
|
+
"mode": self.mode,
|
|
456
|
+
"replayed": len(self.replay.done) if self.replay else 0,
|
|
457
|
+
"replay_total": self.replay.total if self.replay else 0,
|
|
458
|
+
"llm_steps": self.llm_steps,
|
|
459
|
+
"flow": flow,
|
|
460
|
+
**self.store.stats(),
|
|
461
|
+
}
|
|
462
|
+
if self.no_effect:
|
|
463
|
+
info["left_out"] = list(self.no_effect)
|
|
464
|
+
if self.replay and not self.replay.complete:
|
|
465
|
+
info["replay_stopped"] = self.replay.reason
|
|
466
|
+
if status == "complete":
|
|
467
|
+
info["repaired"] = {"step": self.replay.stopped_at, "reason": self.replay.reason, "agent_steps": self.llm_steps}
|
|
468
|
+
if self.replay and self.replay.heals:
|
|
469
|
+
info["heals"] = self.replay.heals
|
|
470
|
+
if self.resume and status == "fail":
|
|
471
|
+
info["resume"] = {
|
|
472
|
+
"steps": len(self.resume["steps"]),
|
|
473
|
+
"failed_at": self.resume.get("failed_at"),
|
|
474
|
+
"reason": self.resume.get("reason") or "",
|
|
475
|
+
"lines": [str(st.get("line") or st.get("action")) for st in self.resume["steps"]],
|
|
476
|
+
}
|
|
477
|
+
return info
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _plan_memory_actions(
|
|
481
|
+
mem: MemoryRun,
|
|
482
|
+
browser_state: Any,
|
|
483
|
+
payload: Dict[str, Any],
|
|
484
|
+
pending: List[Tuple[str, Dict[str, Any], Optional[Dict[str, Any]], str]],
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Record the page the LLM just saw and remember which element each planned action targets."""
|
|
487
|
+
pending.clear()
|
|
488
|
+
try:
|
|
489
|
+
dom = getattr(browser_state, "dom_state", None)
|
|
490
|
+
snap = mem.store.observe(
|
|
491
|
+
str(getattr(browser_state, "url", "") or ""),
|
|
492
|
+
str(getattr(browser_state, "title", "") or ""),
|
|
493
|
+
getattr(dom, "selector_map", None) or {},
|
|
494
|
+
)
|
|
495
|
+
except Exception as ex:
|
|
496
|
+
logger.debug("memory: observe failed: %s", ex)
|
|
497
|
+
snap = {}
|
|
498
|
+
for action, line in zip(payload.get("actions") or [], payload.get("action_lines") or []):
|
|
499
|
+
name = next((k for k in action if k not in ("interacted_element", "result")), None)
|
|
500
|
+
if not name:
|
|
501
|
+
continue
|
|
502
|
+
params = action[name] if isinstance(action[name], dict) else {}
|
|
503
|
+
target = None
|
|
504
|
+
if params.get("index") is not None:
|
|
505
|
+
try:
|
|
506
|
+
target = snap.get(int(params["index"]))
|
|
507
|
+
except (TypeError, ValueError):
|
|
508
|
+
target = None
|
|
509
|
+
pending.append((name, params, target, line))
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
async def _point_at_controls(
|
|
513
|
+
browser: Any, browser_state: Any, pending: List[Tuple[str, Dict[str, Any], Optional[Dict[str, Any]], str]]
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Before the step's actions run: point each element action at the control a user acts on, its locators checked on the page."""
|
|
516
|
+
from .capture import flow_target
|
|
517
|
+
|
|
518
|
+
selector_map = getattr(getattr(browser_state, "dom_state", None), "selector_map", None) or {}
|
|
519
|
+
for i, (name, params, target, line) in enumerate(pending):
|
|
520
|
+
if target is None or name not in ("click", "input", "select_dropdown") or params.get("index") is None:
|
|
521
|
+
continue
|
|
522
|
+
try:
|
|
523
|
+
node = selector_map.get(int(params["index"]))
|
|
524
|
+
pending[i] = (name, params, await flow_target(browser, node, target), line)
|
|
525
|
+
except Exception as ex:
|
|
526
|
+
logger.debug("capture: could not check the target of %s: %s", name, ex)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def agent_note(output: Any) -> str:
|
|
530
|
+
"""The browser agent's own words for one step: what it made of the page and what it set out to do."""
|
|
531
|
+
parts: List[str] = []
|
|
532
|
+
for name in ("evaluation_previous_goal", "memory", "next_goal"):
|
|
533
|
+
text = " ".join(str(getattr(output, name, "") or "").split())
|
|
534
|
+
if text and text not in parts:
|
|
535
|
+
parts.append(text)
|
|
536
|
+
return " ".join(parts)[:800]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _record_memory_results(
|
|
540
|
+
mem: MemoryRun,
|
|
541
|
+
pending: List[Tuple[str, Dict[str, Any], Optional[Dict[str, Any]], str]],
|
|
542
|
+
results: List[Any],
|
|
543
|
+
agent_step: int = 0,
|
|
544
|
+
note: str = "",
|
|
545
|
+
*,
|
|
546
|
+
effects: Optional[List[Dict[str, Any]]] = None,
|
|
547
|
+
page_url: str = "",
|
|
548
|
+
) -> None:
|
|
549
|
+
"""
|
|
550
|
+
Score each acted-on element and add the actions that worked to this run's flow, with the agent's note for the step.
|
|
551
|
+
A click, key press, or script that changed nothing on the page is left out of the flow: the path a test follows is
|
|
552
|
+
what changed the page, not every attempt. A script that did change the page stays, marked scripted.
|
|
553
|
+
"""
|
|
554
|
+
effects = effects or []
|
|
555
|
+
for i, ((name, params, target, line), r) in enumerate(zip(pending, results)):
|
|
556
|
+
ok = not str(getattr(r, "error", None) or "").strip()
|
|
557
|
+
eff = effects[i] if i < len(effects) and effects[i].get("action") == name else {}
|
|
558
|
+
if target:
|
|
559
|
+
mem.store.record_outcome(target["page"], target["key"], ok)
|
|
560
|
+
if not ok:
|
|
561
|
+
continue
|
|
562
|
+
if eff.get("effect") is False and name in ("click", "send_keys", "evaluate", "click_selector"):
|
|
563
|
+
mem.no_effect.append(line)
|
|
564
|
+
continue
|
|
565
|
+
if name == "click_selector":
|
|
566
|
+
captured = eff.get("target")
|
|
567
|
+
if not captured:
|
|
568
|
+
continue
|
|
569
|
+
facts = captured["facts"]
|
|
570
|
+
control = {
|
|
571
|
+
"page": page_id(page_url) or "",
|
|
572
|
+
"key": "",
|
|
573
|
+
"facts": facts,
|
|
574
|
+
"locators": captured["locators"],
|
|
575
|
+
"current": {(l["type"], l["value"]): l.get("matches", 1) for l in captured["locators"] if l.get("verified")},
|
|
576
|
+
}
|
|
577
|
+
step = flow_step("click", {}, control, line)
|
|
578
|
+
elif name in READ_ONLY_ACTIONS:
|
|
579
|
+
continue
|
|
580
|
+
else:
|
|
581
|
+
step = flow_step(name, params, target, line)
|
|
582
|
+
if eff.get("effect") is not None:
|
|
583
|
+
step["effect"] = bool(eff["effect"])
|
|
584
|
+
if name == "click" and target and target.get("overlay"):
|
|
585
|
+
step["overlay"] = target["overlay"]
|
|
586
|
+
if name == "evaluate" and eff.get("effect") is True:
|
|
587
|
+
step["scripted"] = True
|
|
588
|
+
if note:
|
|
589
|
+
step["note"] = note
|
|
590
|
+
mem.add(step, agent_step)
|
|
591
|
+
pending.clear()
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _write_artifacts(
|
|
595
|
+
out_dir: Path,
|
|
596
|
+
*,
|
|
597
|
+
req: RunRequest,
|
|
598
|
+
out: Dict[str, Any],
|
|
599
|
+
history: Any,
|
|
600
|
+
collected_steps: List[Dict[str, Any]],
|
|
601
|
+
live_trace_source: str,
|
|
602
|
+
start_url: Optional[str],
|
|
603
|
+
shot: Optional[Tuple[str, bytes]] = None,
|
|
604
|
+
mem: Optional[MemoryRun] = None,
|
|
605
|
+
timeline_history: Any = None,
|
|
606
|
+
diag: Optional[Diagnostics] = None,
|
|
607
|
+
llm_calls: Optional[List[Tuple[float, float]]] = None,
|
|
608
|
+
) -> List[str]:
|
|
609
|
+
"""workflow.yaml, trace.md, steps.json, run.json (and failure screenshot) in the run directory."""
|
|
610
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
611
|
+
files: List[str] = []
|
|
612
|
+
try:
|
|
613
|
+
timeline = build_timeline(
|
|
614
|
+
out_dir,
|
|
615
|
+
history=timeline_history if timeline_history is not None else history,
|
|
616
|
+
collected_steps=collected_steps,
|
|
617
|
+
replayed=list(mem.replay.done) if mem is not None and mem.replay else [],
|
|
618
|
+
screenshots=req.screenshots,
|
|
619
|
+
ok=bool(out.get("success")),
|
|
620
|
+
replay_times=list(mem.replay.times) if mem is not None and mem.replay else [],
|
|
621
|
+
llm_calls=llm_calls or [],
|
|
622
|
+
events=diag.events if diag is not None else [],
|
|
623
|
+
)
|
|
624
|
+
if timeline:
|
|
625
|
+
files.append(timeline)
|
|
626
|
+
except Exception as ex:
|
|
627
|
+
logger.warning("steps.json not written: %s", ex)
|
|
628
|
+
if diag is not None:
|
|
629
|
+
out["diagnostics"] = diag.summary()
|
|
630
|
+
try:
|
|
631
|
+
checks = diag.write(out_dir)
|
|
632
|
+
if checks:
|
|
633
|
+
files.append(checks)
|
|
634
|
+
except Exception as ex:
|
|
635
|
+
logger.warning("accessibility.json not written: %s", ex)
|
|
636
|
+
try:
|
|
637
|
+
perf = diag.write_perf(out_dir)
|
|
638
|
+
if perf:
|
|
639
|
+
files.append(perf)
|
|
640
|
+
except Exception as ex:
|
|
641
|
+
logger.warning("performance.json not written: %s", ex)
|
|
642
|
+
try:
|
|
643
|
+
shots = diag.write_visual(out_dir)
|
|
644
|
+
if shots:
|
|
645
|
+
files.append(shots)
|
|
646
|
+
except Exception as ex:
|
|
647
|
+
logger.warning("visual captures not written: %s", ex)
|
|
648
|
+
if diag.network is not None:
|
|
649
|
+
try:
|
|
650
|
+
calls = diag.network.write(out_dir)
|
|
651
|
+
if calls:
|
|
652
|
+
files.append(calls)
|
|
653
|
+
except Exception as ex:
|
|
654
|
+
logger.warning("network.json not written: %s", ex)
|
|
655
|
+
memory_steps: Optional[List[Dict[str, Any]]] = None
|
|
656
|
+
if mem is not None:
|
|
657
|
+
out["memory"] = mem.finish(out)
|
|
658
|
+
memory_steps = [compact_step(s) for s in mem.steps] or None
|
|
659
|
+
resume_path = out_dir / "resume.json"
|
|
660
|
+
if (out["memory"] or {}).get("resume") and mem.resume:
|
|
661
|
+
resume_path.write_text(
|
|
662
|
+
json.dumps({"goal": req.goal, "start_url": start_url or "", **mem.resume}, indent=2), encoding="utf-8"
|
|
663
|
+
)
|
|
664
|
+
files.append(str(resume_path.resolve()))
|
|
665
|
+
out["memory"]["resume"]["file"] = str(resume_path.resolve())
|
|
666
|
+
elif not out.get("cancelled"):
|
|
667
|
+
resume_path.unlink(missing_ok=True)
|
|
668
|
+
yaml_path, _ = save_browser_use_run_yaml(
|
|
669
|
+
out_dir,
|
|
670
|
+
req.goal,
|
|
671
|
+
success=bool(out.get("success")),
|
|
672
|
+
summary=str(out.get("summary") or out.get("error") or ""),
|
|
673
|
+
session_id=req.session_id,
|
|
674
|
+
history=history,
|
|
675
|
+
collected_steps=collected_steps,
|
|
676
|
+
live_trace_source=live_trace_source,
|
|
677
|
+
compact_steps=memory_steps,
|
|
678
|
+
checks=out.get("checks") or [],
|
|
679
|
+
)
|
|
680
|
+
if yaml_path:
|
|
681
|
+
files.append(yaml_path)
|
|
682
|
+
out["yaml_path"] = yaml_path
|
|
683
|
+
|
|
684
|
+
if shot:
|
|
685
|
+
ext, data = shot
|
|
686
|
+
shot_path = out_dir / f"failure.{ext}"
|
|
687
|
+
shot_path.write_bytes(data)
|
|
688
|
+
files.append(str(shot_path.resolve()))
|
|
689
|
+
out["failure_screenshot"] = str(shot_path.resolve())
|
|
690
|
+
|
|
691
|
+
trace = out.get("result_markdown") or f"### Task\n\n{req.goal}\n\n---\n\n## Result\n\n{out.get('summary') or out.get('error') or ''}"
|
|
692
|
+
trace_path = out_dir / "trace.md"
|
|
693
|
+
trace_path.write_text(str(trace).rstrip() + "\n", encoding="utf-8")
|
|
694
|
+
files.append(str(trace_path.resolve()))
|
|
695
|
+
|
|
696
|
+
if req.skills_decided:
|
|
697
|
+
out["skills"] = list(req.skills_used)
|
|
698
|
+
if req.skills_usage and isinstance(out.get("usage"), dict):
|
|
699
|
+
for k in ("input", "output"):
|
|
700
|
+
out["usage"][k] = int(out["usage"].get(k) or 0) + int(req.skills_usage.get(k) or 0)
|
|
701
|
+
run_path = out_dir / "run.json"
|
|
702
|
+
slim = {k: v for k, v in out.items() if k != "result_markdown"}
|
|
703
|
+
slim["goal"] = req.goal
|
|
704
|
+
slim["start_url"] = start_url or ""
|
|
705
|
+
slim["driver"] = "engine"
|
|
706
|
+
slim["run_id"] = out_dir.name
|
|
707
|
+
run_path.write_text(json.dumps(slim, indent=2, ensure_ascii=False, default=str), encoding="utf-8")
|
|
708
|
+
files.insert(0, str(run_path.resolve()))
|
|
709
|
+
return files
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Event) -> Dict[str, Any]:
|
|
713
|
+
"""
|
|
714
|
+
Run a single library Agent task and write artifacts into ``req.out_dir``.
|
|
715
|
+
``emit`` receives transcript events; setting ``cancel_event`` stops the run.
|
|
716
|
+
"""
|
|
717
|
+
goal = (req.goal or "").strip()
|
|
718
|
+
if not goal:
|
|
719
|
+
return {"success": False, "error": "Empty task goal.", "summary": ""}
|
|
720
|
+
|
|
721
|
+
allowed_domains = _parse_allowed_domains(req.allowed_hosts)
|
|
722
|
+
err = _validate_goal_urls_against_allowlist(goal, allowed_domains)
|
|
723
|
+
if err:
|
|
724
|
+
return {"success": False, "error": err, "summary": ""}
|
|
725
|
+
|
|
726
|
+
normalized_initial: Optional[str] = None
|
|
727
|
+
if (req.start_url or "").strip():
|
|
728
|
+
normalized_initial = normalize_http_base_url(req.start_url)
|
|
729
|
+
if not normalized_initial:
|
|
730
|
+
emit({"type": "log", "level": "warn", "text": f"Ignoring start URL (not http/https): {req.start_url}"})
|
|
731
|
+
else:
|
|
732
|
+
err_init = _allowed_hosts_violation_for_url(normalized_initial, allowed_domains)
|
|
733
|
+
if err_init:
|
|
734
|
+
return {"success": False, "error": err_init, "summary": ""}
|
|
735
|
+
|
|
736
|
+
from browser_use import Agent, Browser, Tools
|
|
737
|
+
|
|
738
|
+
from .capture import CAPTURE_SYSTEM_MESSAGE, install_effects, register_click_selector
|
|
739
|
+
from .checks import CHECKS_SYSTEM_MESSAGE, register_checks
|
|
740
|
+
from .skills import choose_skills, skills_block
|
|
741
|
+
|
|
742
|
+
recorded_checks: List[Dict[str, Any]] = []
|
|
743
|
+
brand.install()
|
|
744
|
+
headless = resolve_browser_headless(req.headed)
|
|
745
|
+
timeout_sec = float(os.getenv("BROWSER_USE_TIMEOUT_SECONDS", "600"))
|
|
746
|
+
step_timeout = int(os.getenv("BROWSER_USE_STEP_TIMEOUT", "180"))
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
llm = _build_llm(req.llm)
|
|
750
|
+
except Exception as e:
|
|
751
|
+
return {"success": False, "error": str(e), "summary": ""}
|
|
752
|
+
llm_calls: List[Tuple[float, float]] = []
|
|
753
|
+
_time_llm_calls(llm, llm_calls)
|
|
754
|
+
use_prompts(req.prompts)
|
|
755
|
+
diag = Diagnostics(accessibility=req.accessibility, network=req.network, axe=req.axe or None, vitals=req.vitals, visual=req.visual)
|
|
756
|
+
|
|
757
|
+
browser_kw = local_browser_launch_kwargs(headless=headless)
|
|
758
|
+
if allowed_domains is not None:
|
|
759
|
+
browser_kw["allowed_domains"] = allowed_domains
|
|
760
|
+
logins = parse_logins(req.logins)
|
|
761
|
+
sensitive, missing = use_logins(logins)
|
|
762
|
+
for env in missing:
|
|
763
|
+
emit({"type": "log", "level": "warn", "text": f"Saved login: {env} is not set, so that value is not available to the agent"})
|
|
764
|
+
stored = load_sessions(logins)
|
|
765
|
+
if stored:
|
|
766
|
+
browser_kw["storage_state"] = stored
|
|
767
|
+
names = [l.name for l in logins if l.session_file and Path(l.session_file).is_file()]
|
|
768
|
+
emit({"type": "log", "level": "info", "text": f"Signed-in session restored for {', '.join(names)}"})
|
|
769
|
+
emit(
|
|
770
|
+
{
|
|
771
|
+
"type": "log",
|
|
772
|
+
"level": "info",
|
|
773
|
+
"text": f"ForgeQE engine · {browser_kw.get('channel')} · {'headless' if headless else 'visible'}"
|
|
774
|
+
+ (f" · opening {normalized_initial}" if normalized_initial else " · agent picks the site"),
|
|
775
|
+
}
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
out_dir = Path(req.out_dir)
|
|
779
|
+
browser = Browser(**browser_kw)
|
|
780
|
+
overlay = brand.Overlay(browser) if req.overlay and not headless else None
|
|
781
|
+
agent = None
|
|
782
|
+
mem = MemoryRun.from_request(req, normalized_initial)
|
|
783
|
+
if mem is not None:
|
|
784
|
+
mem.run_id = out_dir.name
|
|
785
|
+
sessions_written: Dict[str, str] = {}
|
|
786
|
+
announced: Set[str] = set()
|
|
787
|
+
|
|
788
|
+
async def _save_logins() -> None:
|
|
789
|
+
saved = [n for n in await save_sessions(browser, logins, sessions_written) if n not in announced]
|
|
790
|
+
if saved:
|
|
791
|
+
announced.update(saved)
|
|
792
|
+
emit({"type": "log", "level": "info", "text": f"Signed-in session saved for {', '.join(saved)}"})
|
|
793
|
+
|
|
794
|
+
try:
|
|
795
|
+
collected_steps: List[Dict[str, Any]] = []
|
|
796
|
+
|
|
797
|
+
def _save(out: Dict[str, Any], source: str, *, history: Any = None, shot: Optional[Tuple[str, bytes]] = None) -> Dict[str, Any]:
|
|
798
|
+
"""Write the run's artifacts for any way the run ended (`source` names it in the trace) and return `out`."""
|
|
799
|
+
out["files"] = _write_artifacts(
|
|
800
|
+
out_dir, req=req, out=out, history=history, collected_steps=collected_steps, live_trace_source=source,
|
|
801
|
+
start_url=normalized_initial, diag=diag, llm_calls=llm_calls, shot=shot, mem=mem,
|
|
802
|
+
# Without a finished history, the steps so far come from the agent itself.
|
|
803
|
+
timeline_history=getattr(agent, "history", None) if history is None else None,
|
|
804
|
+
)
|
|
805
|
+
return out
|
|
806
|
+
|
|
807
|
+
launch_profile: Dict[str, Any] = {}
|
|
808
|
+
loop_break_state: Dict[str, Any] = {}
|
|
809
|
+
loop_max_retries = _resolve_browser_control_loop_max_retries()
|
|
810
|
+
loop_window = _resolve_browser_control_loop_window()
|
|
811
|
+
loop_breaker_enabled = not _truthy_env("BROWSER_USE_SKIP_CONTROL_LOOP_BREAKER")
|
|
812
|
+
|
|
813
|
+
nudges: List[str] = []
|
|
814
|
+
|
|
815
|
+
def _evaluate_control_loop() -> bool:
|
|
816
|
+
"""The first loop gets a nudge to change approach; a loop after the nudge stops the run."""
|
|
817
|
+
if not loop_breaker_enabled or loop_break_state.get("triggered"):
|
|
818
|
+
return loop_break_state.get("triggered", False)
|
|
819
|
+
since = int(loop_break_state.get("since") or 0)
|
|
820
|
+
recent = collected_steps[since:]
|
|
821
|
+
exceeded, fp, count = _control_loop_exceeded(recent, max_retries=loop_max_retries, window=loop_window)
|
|
822
|
+
if not exceeded or not fp:
|
|
823
|
+
return False
|
|
824
|
+
first, line = _loop_start(recent, fp, loop_window)
|
|
825
|
+
loop_break_state.setdefault("from", since + first)
|
|
826
|
+
if not loop_break_state.get("nudged") and loop_break_state.get("can_nudge"):
|
|
827
|
+
loop_break_state.update({"nudged": True, "since": len(collected_steps)})
|
|
828
|
+
nudges.append(_loop_nudge_message(line=line, count=count, window=loop_window))
|
|
829
|
+
emit({"type": "log", "level": "warn", "text": f"Repeated {line or 'the same action'} {count} times: told the agent to try a different approach"})
|
|
830
|
+
return False
|
|
831
|
+
loop_break_state.update(
|
|
832
|
+
{
|
|
833
|
+
"triggered": True,
|
|
834
|
+
"fingerprint": fp,
|
|
835
|
+
"count": count,
|
|
836
|
+
"max_retries": loop_max_retries,
|
|
837
|
+
"message": _control_loop_break_message(
|
|
838
|
+
count=count, max_retries=loop_max_retries, fingerprint=fp, nudged=bool(loop_break_state.get("nudged"))
|
|
839
|
+
),
|
|
840
|
+
}
|
|
841
|
+
)
|
|
842
|
+
cancel_event.set()
|
|
843
|
+
return True
|
|
844
|
+
|
|
845
|
+
pending_actions: List[Tuple[str, Dict[str, Any], Optional[Dict[str, Any]], str]] = []
|
|
846
|
+
# What each action of the current step changed on the page, in call order (capture.install_effects).
|
|
847
|
+
action_effects: List[Dict[str, Any]] = []
|
|
848
|
+
captured: Dict[str, Any] = {}
|
|
849
|
+
step_url: Dict[str, str] = {"url": ""}
|
|
850
|
+
replay_offset = 0
|
|
851
|
+
|
|
852
|
+
async def _on_new_step(browser_state: Any, agent_output: Any, n_steps: int) -> None:
|
|
853
|
+
payload = serialize_browser_step_payload(browser_state, agent_output, n_steps + replay_offset)
|
|
854
|
+
collected_steps.append(payload)
|
|
855
|
+
_evaluate_control_loop()
|
|
856
|
+
emit({"type": "step", **{k: v for k, v in payload.items() if k not in ("actions", "boxes")}})
|
|
857
|
+
action_effects.clear()
|
|
858
|
+
step_url["url"] = str(payload.get("url") or "")
|
|
859
|
+
if mem is not None:
|
|
860
|
+
_plan_memory_actions(mem, browser_state, payload, pending_actions)
|
|
861
|
+
await _point_at_controls(browser, browser_state, pending_actions)
|
|
862
|
+
await diag.sample(browser)
|
|
863
|
+
if overlay is not None:
|
|
864
|
+
await overlay.acting(brand.step_text(payload))
|
|
865
|
+
|
|
866
|
+
handoff: Optional[str] = None
|
|
867
|
+
flow: Optional[Dict[str, Any]] = None
|
|
868
|
+
kind = ""
|
|
869
|
+
if mem is not None and mem.mode != "off":
|
|
870
|
+
saved = mem.store.load_flow(goal, normalized_initial)
|
|
871
|
+
checkpoint = mem.store.load_checkpoint(goal, normalized_initial)
|
|
872
|
+
if req.resume.get("steps"):
|
|
873
|
+
flow, kind = req.resume, "interrupted" if req.resume.get("interrupted") else "resume"
|
|
874
|
+
elif saved and saved.get("successes") and saved.get("steps"):
|
|
875
|
+
flow, kind = saved, "exact"
|
|
876
|
+
elif checkpoint:
|
|
877
|
+
flow, kind = checkpoint, "interrupted"
|
|
878
|
+
mem.used_checkpoint = str(checkpoint.get("run_id") or "")
|
|
879
|
+
elif saved and isinstance(saved.get("resume"), dict) and saved["resume"].get("steps"):
|
|
880
|
+
flow, kind = saved["resume"], "resume"
|
|
881
|
+
if flow and kind and flow.get("steps"):
|
|
882
|
+
if kind == "interrupted":
|
|
883
|
+
replay_text = (
|
|
884
|
+
f"Replaying {len(flow['steps'])} steps an earlier run of this task got through before it was cut off"
|
|
885
|
+
+ (f" ({flow['run_id']})" if flow.get("run_id") else "")
|
|
886
|
+
)
|
|
887
|
+
elif kind == "resume":
|
|
888
|
+
replay_text = (
|
|
889
|
+
f"Replaying {len(flow['steps'])} steps that worked in an earlier run of this task"
|
|
890
|
+
+ (f" ({flow['run_id']})" if flow.get("run_id") else "")
|
|
891
|
+
+ (f", which went wrong at step {flow['failed_at']}" if flow.get("failed_at") else "")
|
|
892
|
+
)
|
|
893
|
+
else:
|
|
894
|
+
replay_text = (
|
|
895
|
+
f"Replaying {len(flow['steps'])} recorded steps from memory "
|
|
896
|
+
f"(last passed {str(flow.get('last_success') or '')[:10]} · {flow.get('successes')} of {flow.get('runs')} runs passed)"
|
|
897
|
+
)
|
|
898
|
+
emit({"type": "memory", "text": replay_text})
|
|
899
|
+
await browser.start()
|
|
900
|
+
await diag.start(browser)
|
|
901
|
+
replay = await replay_flow(
|
|
902
|
+
flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay
|
|
903
|
+
)
|
|
904
|
+
mem.replay = replay
|
|
905
|
+
for done_step in replay.done:
|
|
906
|
+
mem.add(done_step)
|
|
907
|
+
replay_offset = replay.emitted
|
|
908
|
+
if replay.complete and kind == "exact":
|
|
909
|
+
emit({"type": "memory", "text": f"Replayed all {len(replay.done)} steps without the LLM"})
|
|
910
|
+
elif replay.complete:
|
|
911
|
+
emit({"type": "memory", "text": f"Replayed {len(replay.done)} remembered steps without the LLM; the agent continues from here"})
|
|
912
|
+
else:
|
|
913
|
+
emit({"type": "memory", "text": f"Replayed {len(replay.done)} of {replay.total} steps; {replay.reason}"})
|
|
914
|
+
if cancel_event.is_set():
|
|
915
|
+
err = "Browser task stopped by user"
|
|
916
|
+
out = {"success": False, "error": err, "summary": err, "step_events": collected_steps, "cancelled": True}
|
|
917
|
+
_save(out, "cancelled")
|
|
918
|
+
return out
|
|
919
|
+
if replay.complete and mem.mode == "trust" and kind == "exact":
|
|
920
|
+
summary = f"Replayed all {len(replay.done)} recorded steps from memory without calling the LLM."
|
|
921
|
+
if flow.get("summary"):
|
|
922
|
+
summary += f"\n\nResult of the run that recorded them: {flow['summary']}"
|
|
923
|
+
out = {
|
|
924
|
+
"success": True,
|
|
925
|
+
"summary": summary,
|
|
926
|
+
"result_markdown": f"### Task\n\n{goal}\n\n---\n\n## Result\n\n{summary}",
|
|
927
|
+
"session_id": req.session_id,
|
|
928
|
+
"final_url": replay.url,
|
|
929
|
+
"usage": {"input": 0, "output": 0},
|
|
930
|
+
"steps": replay.emitted,
|
|
931
|
+
"step_events": collected_steps,
|
|
932
|
+
"browser": await browser_profile(browser, browser_kw),
|
|
933
|
+
}
|
|
934
|
+
_save(out, "memory_replay")
|
|
935
|
+
return out
|
|
936
|
+
if replay.done:
|
|
937
|
+
handoff = handoff_task(goal, flow, replay, kind=kind)
|
|
938
|
+
emit({"type": "memory", "text": "The agent checks the result" if replay.complete and kind == "exact" else "The agent takes over from here"})
|
|
939
|
+
|
|
940
|
+
extra_parts: List[str] = [prompt_text("browser-checks", CHECKS_SYSTEM_MESSAGE), prompt_text("browser-capture", CAPTURE_SYSTEM_MESSAGE)]
|
|
941
|
+
login_block = build_default_browser_login_system_extension()
|
|
942
|
+
if login_block:
|
|
943
|
+
extra_parts.append(login_block)
|
|
944
|
+
saved_logins = prompt_block(logins)
|
|
945
|
+
if saved_logins:
|
|
946
|
+
extra_parts.append(saved_logins)
|
|
947
|
+
if req.case_steps > 0:
|
|
948
|
+
extra_parts.append(prompt_text("browser-case-report", CASE_REPORT_INSTRUCTION).replace("{n}", str(req.case_steps)))
|
|
949
|
+
if (req.extend_system_message or "").strip():
|
|
950
|
+
extra_parts.append((req.extend_system_message or "").strip())
|
|
951
|
+
if req.skills:
|
|
952
|
+
if req.skills_pick == "choose":
|
|
953
|
+
chosen, req.skills_usage = await choose_skills(llm, goal, req.skills)
|
|
954
|
+
else:
|
|
955
|
+
chosen = [str(s.get("name") or "") for s in req.skills]
|
|
956
|
+
block, req.skills_used, dropped = skills_block(chosen, req.skills, req.skills_max_chars)
|
|
957
|
+
req.skills_decided = True
|
|
958
|
+
if block:
|
|
959
|
+
extra_parts.append(block)
|
|
960
|
+
emit({"type": "log", "level": "info", "text": f"Skills for this run: {', '.join(req.skills_used)}"})
|
|
961
|
+
if dropped:
|
|
962
|
+
emit({"type": "log", "level": "warn", "text": f"Skills left out (over skills.browser_max_chars): {', '.join(dropped)}"})
|
|
963
|
+
if mem is not None:
|
|
964
|
+
hints = mem.store.prompt_hints(normalized_initial, goal)
|
|
965
|
+
if hints:
|
|
966
|
+
extra_parts.append(hints)
|
|
967
|
+
extend_msg = _compose_agent_extend_system_message("\n\n".join(extra_parts) if extra_parts else None)
|
|
968
|
+
|
|
969
|
+
agent_kw: Dict[str, Any] = dict(
|
|
970
|
+
task=handoff or goal,
|
|
971
|
+
llm=llm,
|
|
972
|
+
browser=browser,
|
|
973
|
+
step_timeout=step_timeout,
|
|
974
|
+
register_new_step_callback=_on_new_step,
|
|
975
|
+
enable_signal_handler=False,
|
|
976
|
+
)
|
|
977
|
+
agent_tools = Tools()
|
|
978
|
+
register_checks(agent_tools, recorded_checks)
|
|
979
|
+
register_click_selector(agent_tools, captured)
|
|
980
|
+
install_effects(agent_tools, action_effects, captured)
|
|
981
|
+
agent_kw["tools"] = agent_tools
|
|
982
|
+
agent_kw.update(_browser_use_agent_tuning_kwargs())
|
|
983
|
+
if extend_msg:
|
|
984
|
+
agent_kw["extend_system_message"] = extend_msg
|
|
985
|
+
if sensitive:
|
|
986
|
+
agent_kw["sensitive_data"] = sensitive
|
|
987
|
+
if req.case_steps > 0:
|
|
988
|
+
agent_kw["output_model_schema"] = CaseReport
|
|
989
|
+
# Only a pinned start page is opened for the agent; the library's own URL guessing from the task stays off.
|
|
990
|
+
agent_kw["directly_open_url"] = False
|
|
991
|
+
if normalized_initial and not handoff:
|
|
992
|
+
agent_kw["initial_actions"] = _build_initial_navigate_actions(normalized_initial)
|
|
993
|
+
if mem is not None:
|
|
994
|
+
mem.add(flow_step("navigate", {"url": normalized_initial, "new_tab": False}, None, f"navigate {normalized_initial}"))
|
|
995
|
+
|
|
996
|
+
async def _should_stop_callback() -> bool:
|
|
997
|
+
if cancel_event.is_set():
|
|
998
|
+
return True
|
|
999
|
+
return _evaluate_control_loop()
|
|
1000
|
+
|
|
1001
|
+
agent_kw["register_should_stop_callback"] = _should_stop_callback
|
|
1002
|
+
task_with_url_preamble = False
|
|
1003
|
+
for _ in range(10):
|
|
1004
|
+
try:
|
|
1005
|
+
agent = Agent(**agent_kw)
|
|
1006
|
+
break
|
|
1007
|
+
except Exception as exc:
|
|
1008
|
+
err_txt = str(exc)
|
|
1009
|
+
err_l = err_txt.lower()
|
|
1010
|
+
if "enable_signal_handler" in agent_kw and "enable_signal_handler" in err_l:
|
|
1011
|
+
agent_kw.pop("enable_signal_handler", None)
|
|
1012
|
+
continue
|
|
1013
|
+
if "initial_actions" in agent_kw and normalized_initial and _agent_initial_actions_construction_error(exc):
|
|
1014
|
+
agent_kw.pop("initial_actions", None)
|
|
1015
|
+
if not task_with_url_preamble:
|
|
1016
|
+
task_with_url_preamble = True
|
|
1017
|
+
agent_kw["task"] = (
|
|
1018
|
+
f"Navigate to {normalized_initial} first (open this exact URL in the browser), "
|
|
1019
|
+
f"wait for the page to finish loading, then complete the task below:\n\n{goal}"
|
|
1020
|
+
)
|
|
1021
|
+
continue
|
|
1022
|
+
if "extend_system_message" in agent_kw and "extend_system_message" in err_txt:
|
|
1023
|
+
agent_kw.pop("extend_system_message", None)
|
|
1024
|
+
continue
|
|
1025
|
+
if "directly_open_url" in agent_kw and "directly_open_url" in err_txt:
|
|
1026
|
+
agent_kw.pop("directly_open_url", None)
|
|
1027
|
+
continue
|
|
1028
|
+
if "output_model_schema" in agent_kw and "output_model_schema" in err_txt:
|
|
1029
|
+
agent_kw.pop("output_model_schema", None)
|
|
1030
|
+
continue
|
|
1031
|
+
if "register_should_stop_callback" in agent_kw and "register_should_stop" in err_l:
|
|
1032
|
+
agent_kw.pop("register_should_stop_callback", None)
|
|
1033
|
+
continue
|
|
1034
|
+
tuning_keys = (
|
|
1035
|
+
"enable_planning",
|
|
1036
|
+
"use_thinking",
|
|
1037
|
+
"use_judge",
|
|
1038
|
+
"flash_mode",
|
|
1039
|
+
"loop_detection_enabled",
|
|
1040
|
+
"loop_detection_window",
|
|
1041
|
+
"vision_detail_level",
|
|
1042
|
+
"max_actions_per_step",
|
|
1043
|
+
"llm_timeout",
|
|
1044
|
+
"max_failures",
|
|
1045
|
+
"max_history_items",
|
|
1046
|
+
"message_compaction",
|
|
1047
|
+
)
|
|
1048
|
+
popped = False
|
|
1049
|
+
for tk in tuning_keys:
|
|
1050
|
+
if tk in agent_kw and tk in err_txt:
|
|
1051
|
+
agent_kw.pop(tk, None)
|
|
1052
|
+
popped = True
|
|
1053
|
+
if popped:
|
|
1054
|
+
continue
|
|
1055
|
+
raise
|
|
1056
|
+
if agent is None:
|
|
1057
|
+
raise RuntimeError("engine library Agent: failed to construct after parameter fallbacks")
|
|
1058
|
+
loop_break_state["can_nudge"] = _install_nudges(agent, nudges)
|
|
1059
|
+
|
|
1060
|
+
max_steps = req.max_steps or 0
|
|
1061
|
+
env_steps = (os.getenv("BROWSER_USE_MAX_STEPS") or "").strip()
|
|
1062
|
+
if not max_steps and env_steps.isdigit() and int(env_steps) > 0:
|
|
1063
|
+
max_steps = int(env_steps)
|
|
1064
|
+
step_cap = max_steps * (1 + STEP_EXTENSIONS) if max_steps > 0 else 0
|
|
1065
|
+
budget_state: Dict[str, Any] = {}
|
|
1066
|
+
|
|
1067
|
+
def _plan_resume(reason: str) -> None:
|
|
1068
|
+
"""Keep the steps that worked before this failed run went wrong, for the next run or a retry to replay."""
|
|
1069
|
+
if mem is None or mem.mode == "off":
|
|
1070
|
+
return
|
|
1071
|
+
items = list(getattr(getattr(agent, "history", None), "history", None) or [])
|
|
1072
|
+
at = resume_point(step_outcomes(items), loop_from=loop_break_state.get("from"), stalled=bool(budget_state.get("message")))
|
|
1073
|
+
steps = resume_steps(mem.steps, mem.step_of, at)
|
|
1074
|
+
if not steps:
|
|
1075
|
+
return
|
|
1076
|
+
first_line = (str(reason or "").strip().splitlines() or [""])[0]
|
|
1077
|
+
mem.resume = {"steps": steps, "reason": first_line[:400], "failed_at": at, "run_id": out_dir.name}
|
|
1078
|
+
emit({"type": "memory", "text": f"Kept the {len(steps)} steps that worked before step {at}; the next run of this task replays them and picks up there"})
|
|
1079
|
+
|
|
1080
|
+
async def _on_step_end(agent_ref: Any) -> None:
|
|
1081
|
+
try:
|
|
1082
|
+
last = agent_ref.history.history[-1]
|
|
1083
|
+
except Exception:
|
|
1084
|
+
return
|
|
1085
|
+
results = getattr(last, "result", None) or []
|
|
1086
|
+
if mem is not None:
|
|
1087
|
+
mem.llm_steps = len(agent_steps(agent_ref.history.history))
|
|
1088
|
+
_record_memory_results(
|
|
1089
|
+
mem, pending_actions, results, mem.llm_steps, agent_note(getattr(last, "model_output", None)),
|
|
1090
|
+
effects=list(action_effects), page_url=step_url["url"],
|
|
1091
|
+
)
|
|
1092
|
+
try:
|
|
1093
|
+
point = mem.checkpoint()
|
|
1094
|
+
if point:
|
|
1095
|
+
(out_dir / "resume.json").write_text(
|
|
1096
|
+
json.dumps({"goal": req.goal, "start_url": normalized_initial or "", **point}, indent=2), encoding="utf-8"
|
|
1097
|
+
)
|
|
1098
|
+
except Exception as ex:
|
|
1099
|
+
logger.warning("memory: could not save the checkpoint: %s", ex)
|
|
1100
|
+
items = []
|
|
1101
|
+
for r in results:
|
|
1102
|
+
err = str(getattr(r, "error", None) or "").strip()
|
|
1103
|
+
text = err or str(getattr(r, "extracted_content", None) or "").strip()
|
|
1104
|
+
items.append(
|
|
1105
|
+
{
|
|
1106
|
+
"ok": not err,
|
|
1107
|
+
"done": bool(getattr(r, "is_done", False)),
|
|
1108
|
+
"text": (text.splitlines() or [""])[0][:240],
|
|
1109
|
+
}
|
|
1110
|
+
)
|
|
1111
|
+
emit({"type": "result", "step": len(agent_ref.history.history) + replay_offset, "items": items})
|
|
1112
|
+
n = len(agent_ref.history.history)
|
|
1113
|
+
if max_steps > 0 and n < step_cap and n % max_steps == 0 and not any(i["done"] for i in items):
|
|
1114
|
+
if making_progress(step_outcomes(agent_ref.history.history)):
|
|
1115
|
+
emit({"type": "log", "level": "info", "text": f"Reached {n} steps and still making progress: carrying on, up to {step_cap}"})
|
|
1116
|
+
else:
|
|
1117
|
+
budget_state["message"] = f"Stopped at {n} steps: the last {PROGRESS_WINDOW} steps made no progress."
|
|
1118
|
+
emit({"type": "log", "level": "warn", "text": budget_state["message"]})
|
|
1119
|
+
agent_ref.stop()
|
|
1120
|
+
if not launch_profile:
|
|
1121
|
+
launch_profile.update(await browser_profile(browser, browser_kw))
|
|
1122
|
+
await diag.sample(browser)
|
|
1123
|
+
with contextlib.suppress(Exception):
|
|
1124
|
+
await _save_logins()
|
|
1125
|
+
try:
|
|
1126
|
+
u = await agent_ref.token_cost_service.get_usage_summary()
|
|
1127
|
+
emit({"type": "usage", "input": int(u.total_prompt_tokens or 0), "output": int(u.total_completion_tokens or 0), "cached": int(getattr(u, "total_prompt_cached_tokens", 0) or 0)})
|
|
1128
|
+
except Exception:
|
|
1129
|
+
pass
|
|
1130
|
+
if overlay is not None:
|
|
1131
|
+
await overlay.thinking()
|
|
1132
|
+
|
|
1133
|
+
async def _on_step_start(agent_ref: Any) -> None:
|
|
1134
|
+
if not collected_steps:
|
|
1135
|
+
await diag.start(browser)
|
|
1136
|
+
if overlay is not None and not collected_steps:
|
|
1137
|
+
await overlay.thinking("Reading the page")
|
|
1138
|
+
if collected_steps:
|
|
1139
|
+
waited = await settle_page(browser)
|
|
1140
|
+
if waited >= 1:
|
|
1141
|
+
emit({"type": "log", "level": "debug", "text": f"Waited {waited:.1f}s for the page to finish loading"})
|
|
1142
|
+
|
|
1143
|
+
async def _run_agent():
|
|
1144
|
+
if max_steps > 0:
|
|
1145
|
+
return await agent.run(max_steps=step_cap, on_step_start=_on_step_start, on_step_end=_on_step_end)
|
|
1146
|
+
return await agent.run(on_step_start=_on_step_start, on_step_end=_on_step_end)
|
|
1147
|
+
|
|
1148
|
+
run_task = asyncio.create_task(_run_agent())
|
|
1149
|
+
cancel_wait = asyncio.create_task(cancel_event.wait())
|
|
1150
|
+
done, _pending = await asyncio.wait({run_task, cancel_wait}, return_when=asyncio.FIRST_COMPLETED, timeout=timeout_sec)
|
|
1151
|
+
|
|
1152
|
+
if not done:
|
|
1153
|
+
cancel_wait.cancel()
|
|
1154
|
+
await _stop_agent_instance(agent)
|
|
1155
|
+
run_task.cancel()
|
|
1156
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
1157
|
+
await run_task
|
|
1158
|
+
err = f"Browser task timed out after {timeout_sec:.0f}s"
|
|
1159
|
+
out = {"success": False, "error": err, "summary": err, "step_events": collected_steps, "usage": await _agent_usage(agent)}
|
|
1160
|
+
_plan_resume(err)
|
|
1161
|
+
_save(out, "timeout")
|
|
1162
|
+
return out
|
|
1163
|
+
|
|
1164
|
+
if cancel_wait in done and not run_task.done():
|
|
1165
|
+
await _stop_agent_instance(agent)
|
|
1166
|
+
run_task.cancel()
|
|
1167
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
1168
|
+
await run_task
|
|
1169
|
+
triggered = bool(loop_break_state.get("triggered"))
|
|
1170
|
+
err = loop_break_state.get("message") if triggered else "Browser task stopped by user"
|
|
1171
|
+
out = {
|
|
1172
|
+
"success": False,
|
|
1173
|
+
"error": err,
|
|
1174
|
+
"summary": err,
|
|
1175
|
+
"step_events": collected_steps,
|
|
1176
|
+
"cancelled": not triggered,
|
|
1177
|
+
"loop_breaker": triggered,
|
|
1178
|
+
"usage": await _agent_usage(agent),
|
|
1179
|
+
}
|
|
1180
|
+
if triggered:
|
|
1181
|
+
_plan_resume(str(err))
|
|
1182
|
+
_save(out, "cancelled")
|
|
1183
|
+
return out
|
|
1184
|
+
|
|
1185
|
+
cancel_wait.cancel()
|
|
1186
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1187
|
+
await cancel_wait
|
|
1188
|
+
|
|
1189
|
+
try:
|
|
1190
|
+
history = run_task.result()
|
|
1191
|
+
except Exception as e:
|
|
1192
|
+
triggered = bool(loop_break_state.get("triggered"))
|
|
1193
|
+
if cancel_event.is_set():
|
|
1194
|
+
err = loop_break_state.get("message") if triggered else "Browser task stopped by user"
|
|
1195
|
+
out = {
|
|
1196
|
+
"success": False,
|
|
1197
|
+
"error": err,
|
|
1198
|
+
"summary": err,
|
|
1199
|
+
"step_events": collected_steps,
|
|
1200
|
+
"cancelled": not triggered,
|
|
1201
|
+
"loop_breaker": triggered,
|
|
1202
|
+
}
|
|
1203
|
+
else:
|
|
1204
|
+
logger.exception("engine library Agent.run failed")
|
|
1205
|
+
out = {"success": False, "error": str(e), "summary": str(e), "step_events": collected_steps}
|
|
1206
|
+
out["usage"] = await _agent_usage(agent)
|
|
1207
|
+
if not out.get("cancelled"):
|
|
1208
|
+
_plan_resume(str(out["error"]))
|
|
1209
|
+
_save(out, "agent_run_exception")
|
|
1210
|
+
return out
|
|
1211
|
+
|
|
1212
|
+
case_report = case_report_from_history(history) if req.case_steps > 0 else None
|
|
1213
|
+
primary = str(case_report.get("summary") or "").strip() if case_report else ""
|
|
1214
|
+
primary = primary or _primary_summary(history)
|
|
1215
|
+
success_flag = history.is_successful()
|
|
1216
|
+
if success_flag is None:
|
|
1217
|
+
success_flag = not history.has_errors() and bool(primary)
|
|
1218
|
+
ok = bool(success_flag)
|
|
1219
|
+
if loop_break_state.get("triggered"):
|
|
1220
|
+
ok = False
|
|
1221
|
+
judge = judge_from_history(history)
|
|
1222
|
+
judge_rejected = ok and judge is not None and judge["verdict"] is False
|
|
1223
|
+
if judge_rejected:
|
|
1224
|
+
ok = False
|
|
1225
|
+
# The agent can call done(success=true) after recording an expected result that does not hold, or after its own
|
|
1226
|
+
# case report fails a step. Its evidence wins over its verdict: the test case failed.
|
|
1227
|
+
contradicted = ok and evidence_fails(recorded_checks, case_report)
|
|
1228
|
+
if contradicted:
|
|
1229
|
+
ok = False
|
|
1230
|
+
errs_list = [e for e in history.errors() if e]
|
|
1231
|
+
if not ok:
|
|
1232
|
+
if loop_break_state.get("triggered"):
|
|
1233
|
+
summary = str(loop_break_state.get("message") or "Stopped after repeated search/select attempts.")
|
|
1234
|
+
elif budget_state.get("message"):
|
|
1235
|
+
summary = str(budget_state["message"]) + (f"\n\n{primary}" if primary else "")
|
|
1236
|
+
elif judge_rejected:
|
|
1237
|
+
why = judge.get("failure_reason") or judge.get("reasoning") or "the trace does not show the goal met"
|
|
1238
|
+
summary = f"The agent reported success, but the judge found the goal not met: {why}"
|
|
1239
|
+
if primary:
|
|
1240
|
+
summary += f"\n\nThe agent's report: {primary}"
|
|
1241
|
+
else:
|
|
1242
|
+
summary = primary or (errs_list[-1] if errs_list else "Browser task finished without a clear result.")
|
|
1243
|
+
else:
|
|
1244
|
+
summary = primary or ""
|
|
1245
|
+
|
|
1246
|
+
try:
|
|
1247
|
+
result_md = build_browser_result_markdown(history, recorded_checks if ok else None)
|
|
1248
|
+
except Exception as ex:
|
|
1249
|
+
logger.warning("build_browser_result_markdown failed: %s", ex)
|
|
1250
|
+
result_md = str(summary)
|
|
1251
|
+
result_md = f"### Task\n\n{goal}\n\n---\n\n{result_md}"
|
|
1252
|
+
|
|
1253
|
+
shot: Optional[Tuple[str, bytes]] = None
|
|
1254
|
+
trace_md = ""
|
|
1255
|
+
if not ok:
|
|
1256
|
+
trace_md = _failure_trace_markdown(history)
|
|
1257
|
+
shot = _failure_screenshot_asset(history)
|
|
1258
|
+
fail_parts: List[str] = ["## Failure details", "", trace_md, ""]
|
|
1259
|
+
if shot:
|
|
1260
|
+
fail_parts.extend(["### Screenshot (last state)", "", f"", ""])
|
|
1261
|
+
result_md = result_md.rstrip() + "\n\n" + "\n".join(fail_parts).strip()
|
|
1262
|
+
|
|
1263
|
+
try:
|
|
1264
|
+
urls = [u for u in (history.urls() or []) if u]
|
|
1265
|
+
except Exception:
|
|
1266
|
+
urls = []
|
|
1267
|
+
out: Dict[str, Any] = {
|
|
1268
|
+
"success": ok,
|
|
1269
|
+
"summary": str(summary),
|
|
1270
|
+
"result_markdown": result_md,
|
|
1271
|
+
"session_id": req.session_id,
|
|
1272
|
+
"final_url": urls[-1] if urls else "",
|
|
1273
|
+
"usage": _usage_from_history(history),
|
|
1274
|
+
"steps": len(history.history or []) + replay_offset,
|
|
1275
|
+
"step_events": collected_steps,
|
|
1276
|
+
"browser": dict(launch_profile) if launch_profile else await browser_profile(browser, browser_kw),
|
|
1277
|
+
}
|
|
1278
|
+
if judge is not None:
|
|
1279
|
+
out["judge"] = judge
|
|
1280
|
+
if loop_break_state.get("triggered"):
|
|
1281
|
+
out["loop_breaker"] = True
|
|
1282
|
+
if case_report:
|
|
1283
|
+
out["case_report"] = case_report
|
|
1284
|
+
failed_checks = [c for c in recorded_checks if c.get("holds") is False]
|
|
1285
|
+
# The steps were done and an expected result did not hold: the test failed, the run did not.
|
|
1286
|
+
assertion_failed = contradicted or (
|
|
1287
|
+
not ok and bool(failed_checks) and not loop_break_state.get("triggered") and not budget_state.get("message") and not judge_rejected
|
|
1288
|
+
)
|
|
1289
|
+
out["outcome"] = "passed" if ok else "assertion_failed" if assertion_failed else "error"
|
|
1290
|
+
if recorded_checks and (ok or assertion_failed):
|
|
1291
|
+
out["checks"] = list(recorded_checks)
|
|
1292
|
+
if not ok:
|
|
1293
|
+
out["error"] = str(summary)
|
|
1294
|
+
if trace_md:
|
|
1295
|
+
out["failure_trace"] = trace_md
|
|
1296
|
+
if not assertion_failed:
|
|
1297
|
+
_plan_resume(str(summary))
|
|
1298
|
+
_save(out, "history", history=history, shot=shot)
|
|
1299
|
+
return out
|
|
1300
|
+
finally:
|
|
1301
|
+
if mem is not None and not mem.finished:
|
|
1302
|
+
with contextlib.suppress(Exception):
|
|
1303
|
+
mem.store.flush()
|
|
1304
|
+
with contextlib.suppress(Exception):
|
|
1305
|
+
await _save_logins()
|
|
1306
|
+
if overlay is not None:
|
|
1307
|
+
overlay.close()
|
|
1308
|
+
await _shutdown_browser_instance(browser)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def evidence_fails(checks: List[Dict[str, Any]], case_report: Optional[Dict[str, Any]]) -> bool:
|
|
1312
|
+
"""Whether the agent's own evidence fails the test: a recorded check that does not hold, or a failed case step."""
|
|
1313
|
+
if any(c.get("holds") is False for c in checks or []):
|
|
1314
|
+
return True
|
|
1315
|
+
return any(str(step.get("status")) == "failed" for step in ((case_report or {}).get("steps") or []))
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
async def run_with_events(req: RunRequest, emit: Emit, cancel_event: asyncio.Event) -> Dict[str, Any]:
|
|
1319
|
+
"""Run and always finish with a `done` event, even when setup fails before the Agent starts."""
|
|
1320
|
+
try:
|
|
1321
|
+
out = await run_browser_task(req, emit, cancel_event)
|
|
1322
|
+
except Exception as e:
|
|
1323
|
+
logger.exception("run failed")
|
|
1324
|
+
out = {"success": False, "error": str(e), "summary": str(e)}
|
|
1325
|
+
if "files" not in out:
|
|
1326
|
+
try:
|
|
1327
|
+
out["files"] = _write_artifacts(
|
|
1328
|
+
Path(req.out_dir), req=req, out=out, history=None, collected_steps=out.get("step_events") or [],
|
|
1329
|
+
live_trace_source="setup", start_url=req.start_url,
|
|
1330
|
+
)
|
|
1331
|
+
except Exception as ex:
|
|
1332
|
+
logger.warning("artifact write failed: %s", ex)
|
|
1333
|
+
out["files"] = []
|
|
1334
|
+
emit({"type": "done", "result": {k: v for k, v in out.items() if k != "step_events"}})
|
|
1335
|
+
return out
|