fleet-python 0.2.17__py3-none-any.whl → 0.2.19__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.
Potentially problematic release.
This version of fleet-python might be problematic. Click here for more details.
- fleet/__init__.py +0 -5
- fleet/client.py +4 -10
- fleet/resources/mcp.py +1 -1
- fleet/resources/sqlite.py +352 -38
- fleet/verifiers/code.py +2 -1
- {fleet_python-0.2.17.dist-info → fleet_python-0.2.19.dist-info}/METADATA +1 -1
- {fleet_python-0.2.17.dist-info → fleet_python-0.2.19.dist-info}/RECORD +10 -12
- fleet/_async/playwright.py +0 -291
- fleet/playwright.py +0 -290
- {fleet_python-0.2.17.dist-info → fleet_python-0.2.19.dist-info}/WHEEL +0 -0
- {fleet_python-0.2.17.dist-info → fleet_python-0.2.19.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.17.dist-info → fleet_python-0.2.19.dist-info}/top_level.txt +0 -0
fleet/__init__.py
CHANGED
|
@@ -37,9 +37,6 @@ from .verifiers import (
|
|
|
37
37
|
TASK_SUCCESSFUL_SCORE,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
# Import Playwright wrapper
|
|
41
|
-
from .playwright import FleetPlaywrightWrapper
|
|
42
|
-
|
|
43
40
|
# Import async verifiers (default verifier is async for modern usage)
|
|
44
41
|
from ._async.verifiers import (
|
|
45
42
|
verifier,
|
|
@@ -75,8 +72,6 @@ __all__ = [
|
|
|
75
72
|
"FleetAPIError",
|
|
76
73
|
"FleetTimeoutError",
|
|
77
74
|
"FleetConfigurationError",
|
|
78
|
-
# Playwright wrapper
|
|
79
|
-
"FleetPlaywrightWrapper",
|
|
80
75
|
# Verifiers (async is default)
|
|
81
76
|
"verifier",
|
|
82
77
|
"verifier_sync",
|
fleet/client.py
CHANGED
|
@@ -537,12 +537,6 @@ class Fleet:
|
|
|
537
537
|
browser = new_env.browser()
|
|
538
538
|
browser.start(width=snapshot.viewport_size[0], height=snapshot.viewport_size[1])
|
|
539
539
|
|
|
540
|
-
from fleet.playwright import FleetPlaywrightWrapper
|
|
541
|
-
|
|
542
|
-
playwright_wrapper = FleetPlaywrightWrapper(
|
|
543
|
-
cdp_url=browser.cdp_url(), instance_client=new_env.instance
|
|
544
|
-
)
|
|
545
|
-
|
|
546
540
|
# Replay tool logs in order
|
|
547
541
|
validation_errors = []
|
|
548
542
|
last_timestamp = None
|
|
@@ -559,7 +553,7 @@ class Fleet:
|
|
|
559
553
|
|
|
560
554
|
# Replay the tool action
|
|
561
555
|
_replay_tool_action(
|
|
562
|
-
|
|
556
|
+
None,
|
|
563
557
|
tool_log,
|
|
564
558
|
new_env.instance._client,
|
|
565
559
|
replay_session_id,
|
|
@@ -588,7 +582,7 @@ class Fleet:
|
|
|
588
582
|
|
|
589
583
|
if validate:
|
|
590
584
|
validation = _validate_resumed_state(
|
|
591
|
-
new_env, snapshot,
|
|
585
|
+
new_env, snapshot, None, validation_errors
|
|
592
586
|
)
|
|
593
587
|
|
|
594
588
|
return new_env, validation
|
|
@@ -664,7 +658,7 @@ def _execute_verifier_remote(
|
|
|
664
658
|
|
|
665
659
|
|
|
666
660
|
def _replay_tool_action(
|
|
667
|
-
playwright_wrapper
|
|
661
|
+
playwright_wrapper,
|
|
668
662
|
tool_log: ToolLogEntry,
|
|
669
663
|
client: "SyncWrapper",
|
|
670
664
|
session_id: str,
|
|
@@ -760,7 +754,7 @@ def _replay_tool_action(
|
|
|
760
754
|
def _validate_resumed_state(
|
|
761
755
|
new_env: Environment,
|
|
762
756
|
snapshot: EnvironmentSnapshot,
|
|
763
|
-
playwright_wrapper
|
|
757
|
+
playwright_wrapper,
|
|
764
758
|
existing_errors: List[str],
|
|
765
759
|
) -> SnapshotValidation:
|
|
766
760
|
"""Validate that the resumed state matches the snapshot."""
|
fleet/resources/mcp.py
CHANGED
fleet/resources/sqlite.py
CHANGED
|
@@ -18,7 +18,7 @@ from ..verifiers.db import IgnoreConfig, _get_row_identifier, _format_row_for_er
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class SyncDatabaseSnapshot:
|
|
21
|
-
"""
|
|
21
|
+
"""Lazy database snapshot that fetches data on-demand through API."""
|
|
22
22
|
|
|
23
23
|
def __init__(self, resource: "SQLiteResource", name: str | None = None):
|
|
24
24
|
self.resource = resource
|
|
@@ -26,11 +26,12 @@ class SyncDatabaseSnapshot:
|
|
|
26
26
|
self.created_at = datetime.utcnow()
|
|
27
27
|
self._data: dict[str, list[dict[str, Any]]] = {}
|
|
28
28
|
self._schemas: dict[str, list[str]] = {}
|
|
29
|
-
self.
|
|
29
|
+
self._table_names: list[str] | None = None
|
|
30
|
+
self._fetched_tables: set[str] = set()
|
|
30
31
|
|
|
31
|
-
def
|
|
32
|
-
"""Fetch
|
|
33
|
-
if self.
|
|
32
|
+
def _ensure_tables_list(self):
|
|
33
|
+
"""Fetch just the list of table names if not already fetched."""
|
|
34
|
+
if self._table_names is not None:
|
|
34
35
|
return
|
|
35
36
|
|
|
36
37
|
# Get all tables
|
|
@@ -39,34 +40,37 @@ class SyncDatabaseSnapshot:
|
|
|
39
40
|
)
|
|
40
41
|
|
|
41
42
|
if not tables_response.rows:
|
|
42
|
-
self.
|
|
43
|
+
self._table_names = []
|
|
43
44
|
return
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
for table
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
46
|
+
self._table_names = [row[0] for row in tables_response.rows]
|
|
47
|
+
|
|
48
|
+
def _ensure_table_data(self, table: str):
|
|
49
|
+
"""Fetch data for a specific table on demand."""
|
|
50
|
+
if table in self._fetched_tables:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Get table schema
|
|
54
|
+
schema_response = self.resource.query(f"PRAGMA table_info({table})")
|
|
55
|
+
if schema_response.rows:
|
|
56
|
+
self._schemas[table] = [row[1] for row in schema_response.rows] # Column names
|
|
57
|
+
|
|
58
|
+
# Get all data for this table
|
|
59
|
+
data_response = self.resource.query(f"SELECT * FROM {table}")
|
|
60
|
+
if data_response.rows and data_response.columns:
|
|
61
|
+
self._data[table] = [
|
|
62
|
+
dict(zip(data_response.columns, row))
|
|
63
|
+
for row in data_response.rows
|
|
64
|
+
]
|
|
65
|
+
else:
|
|
66
|
+
self._data[table] = []
|
|
67
|
+
|
|
68
|
+
self._fetched_tables.add(table)
|
|
65
69
|
|
|
66
70
|
def tables(self) -> list[str]:
|
|
67
71
|
"""Get list of all tables in the snapshot."""
|
|
68
|
-
self.
|
|
69
|
-
return list(self.
|
|
72
|
+
self._ensure_tables_list()
|
|
73
|
+
return list(self._table_names) if self._table_names else []
|
|
70
74
|
|
|
71
75
|
def table(self, table_name: str) -> "SyncSnapshotQueryBuilder":
|
|
72
76
|
"""Create a query builder for snapshot data."""
|
|
@@ -78,13 +82,12 @@ class SyncDatabaseSnapshot:
|
|
|
78
82
|
ignore_config: IgnoreConfig | None = None,
|
|
79
83
|
) -> "SyncSnapshotDiff":
|
|
80
84
|
"""Compare this snapshot with another."""
|
|
81
|
-
|
|
82
|
-
other._ensure_fetched()
|
|
85
|
+
# No need to fetch all data upfront - diff will fetch on demand
|
|
83
86
|
return SyncSnapshotDiff(self, other, ignore_config)
|
|
84
87
|
|
|
85
88
|
|
|
86
89
|
class SyncSnapshotQueryBuilder:
|
|
87
|
-
"""Query builder that works on
|
|
90
|
+
"""Query builder that works on snapshot data - can use targeted queries when possible."""
|
|
88
91
|
|
|
89
92
|
def __init__(self, snapshot: SyncDatabaseSnapshot, table: str):
|
|
90
93
|
self._snapshot = snapshot
|
|
@@ -94,10 +97,63 @@ class SyncSnapshotQueryBuilder:
|
|
|
94
97
|
self._limit: int | None = None
|
|
95
98
|
self._order_by: str | None = None
|
|
96
99
|
self._order_desc: bool = False
|
|
100
|
+
self._use_targeted_query = True # Try to use targeted queries when possible
|
|
101
|
+
|
|
102
|
+
def _can_use_targeted_query(self) -> bool:
|
|
103
|
+
"""Check if we can use a targeted query instead of loading all data."""
|
|
104
|
+
# We can use targeted query if:
|
|
105
|
+
# 1. We have simple equality conditions
|
|
106
|
+
# 2. No complex operations like joins
|
|
107
|
+
# 3. The query is selective (has conditions)
|
|
108
|
+
if not self._conditions:
|
|
109
|
+
return False
|
|
110
|
+
for col, op, val in self._conditions:
|
|
111
|
+
if op not in ["=", "IS", "IS NOT"]:
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def _execute_targeted_query(self) -> list[dict[str, Any]]:
|
|
116
|
+
"""Execute a targeted query directly instead of loading all data."""
|
|
117
|
+
# Build WHERE clause
|
|
118
|
+
where_parts = []
|
|
119
|
+
for col, op, val in self._conditions:
|
|
120
|
+
if op == "=" and val is None:
|
|
121
|
+
where_parts.append(f"{col} IS NULL")
|
|
122
|
+
elif op == "IS":
|
|
123
|
+
where_parts.append(f"{col} IS NULL")
|
|
124
|
+
elif op == "IS NOT":
|
|
125
|
+
where_parts.append(f"{col} IS NOT NULL")
|
|
126
|
+
elif op == "=":
|
|
127
|
+
if isinstance(val, str):
|
|
128
|
+
escaped_val = val.replace("'", "''")
|
|
129
|
+
where_parts.append(f"{col} = '{escaped_val}'")
|
|
130
|
+
else:
|
|
131
|
+
where_parts.append(f"{col} = '{val}'")
|
|
132
|
+
|
|
133
|
+
where_clause = " AND ".join(where_parts)
|
|
134
|
+
|
|
135
|
+
# Build full query
|
|
136
|
+
cols = ", ".join(self._select_cols)
|
|
137
|
+
query = f"SELECT {cols} FROM {self._table} WHERE {where_clause}"
|
|
138
|
+
|
|
139
|
+
if self._order_by:
|
|
140
|
+
query += f" ORDER BY {self._order_by}"
|
|
141
|
+
if self._limit is not None:
|
|
142
|
+
query += f" LIMIT {self._limit}"
|
|
143
|
+
|
|
144
|
+
# Execute query
|
|
145
|
+
response = self._snapshot.resource.query(query)
|
|
146
|
+
if response.rows and response.columns:
|
|
147
|
+
return [dict(zip(response.columns, row)) for row in response.rows]
|
|
148
|
+
return []
|
|
97
149
|
|
|
98
150
|
def _get_data(self) -> list[dict[str, Any]]:
|
|
99
|
-
"""Get table data
|
|
100
|
-
self.
|
|
151
|
+
"""Get table data - use targeted query if possible, otherwise load all data."""
|
|
152
|
+
if self._use_targeted_query and self._can_use_targeted_query():
|
|
153
|
+
return self._execute_targeted_query()
|
|
154
|
+
|
|
155
|
+
# Fall back to loading all data
|
|
156
|
+
self._snapshot._ensure_table_data(self._table)
|
|
101
157
|
return self._snapshot._data.get(self._table, [])
|
|
102
158
|
|
|
103
159
|
def eq(self, column: str, value: Any) -> "SyncSnapshotQueryBuilder":
|
|
@@ -121,6 +177,11 @@ class SyncSnapshotQueryBuilder:
|
|
|
121
177
|
return rows[0] if rows else None
|
|
122
178
|
|
|
123
179
|
def all(self) -> list[dict[str, Any]]:
|
|
180
|
+
# If we can use targeted query, _get_data already applies filters
|
|
181
|
+
if self._use_targeted_query and self._can_use_targeted_query():
|
|
182
|
+
return self._get_data()
|
|
183
|
+
|
|
184
|
+
# Otherwise, get all data and apply filters manually
|
|
124
185
|
data = self._get_data()
|
|
125
186
|
|
|
126
187
|
# Apply filters
|
|
@@ -188,6 +249,7 @@ class SyncSnapshotDiff:
|
|
|
188
249
|
self.after = after
|
|
189
250
|
self.ignore_config = ignore_config or IgnoreConfig()
|
|
190
251
|
self._cached: dict[str, Any] | None = None
|
|
252
|
+
self._targeted_mode = False # Flag to use targeted queries
|
|
191
253
|
|
|
192
254
|
def _get_primary_key_columns(self, table: str) -> list[str]:
|
|
193
255
|
"""Get primary key columns for a table."""
|
|
@@ -228,6 +290,10 @@ class SyncSnapshotDiff:
|
|
|
228
290
|
# Get primary key columns
|
|
229
291
|
pk_columns = self._get_primary_key_columns(tbl)
|
|
230
292
|
|
|
293
|
+
# Ensure data is fetched for this table
|
|
294
|
+
self.before._ensure_table_data(tbl)
|
|
295
|
+
self.after._ensure_table_data(tbl)
|
|
296
|
+
|
|
231
297
|
# Get data from both snapshots
|
|
232
298
|
before_data = self.before._data.get(tbl, [])
|
|
233
299
|
after_data = self.after._data.get(tbl, [])
|
|
@@ -303,10 +369,245 @@ class SyncSnapshotDiff:
|
|
|
303
369
|
self._cached = diff
|
|
304
370
|
return diff
|
|
305
371
|
|
|
306
|
-
def
|
|
307
|
-
"""
|
|
372
|
+
def _can_use_targeted_queries(self, allowed_changes: list[dict[str, Any]]) -> bool:
|
|
373
|
+
"""Check if we can use targeted queries for optimization."""
|
|
374
|
+
# We can use targeted queries if all allowed changes specify table and pk
|
|
375
|
+
for change in allowed_changes:
|
|
376
|
+
if "table" not in change or "pk" not in change:
|
|
377
|
+
return False
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
def _expect_only_targeted(self, allowed_changes: list[dict[str, Any]]):
|
|
381
|
+
"""Optimized version that only queries specific rows mentioned in allowed_changes."""
|
|
382
|
+
# Group allowed changes by table
|
|
383
|
+
changes_by_table: dict[str, list[dict[str, Any]]] = {}
|
|
384
|
+
for change in allowed_changes:
|
|
385
|
+
table = change["table"]
|
|
386
|
+
if table not in changes_by_table:
|
|
387
|
+
changes_by_table[table] = []
|
|
388
|
+
changes_by_table[table].append(change)
|
|
389
|
+
|
|
390
|
+
# For each table with allowed changes, query only those specific rows
|
|
391
|
+
for table, table_changes in changes_by_table.items():
|
|
392
|
+
if self.ignore_config.should_ignore_table(table):
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
# Get primary key columns
|
|
396
|
+
pk_columns = self._get_primary_key_columns(table)
|
|
397
|
+
|
|
398
|
+
# Extract unique PKs to check
|
|
399
|
+
pks_to_check = {change["pk"] for change in table_changes}
|
|
400
|
+
|
|
401
|
+
# Query only these specific rows from both snapshots
|
|
402
|
+
for pk in pks_to_check:
|
|
403
|
+
# Build WHERE clause for this PK
|
|
404
|
+
where_sql = self._build_pk_where_clause(pk_columns, pk)
|
|
405
|
+
|
|
406
|
+
# Query before snapshot
|
|
407
|
+
before_query = f"SELECT * FROM {table} WHERE {where_sql}"
|
|
408
|
+
before_response = self.before.resource.query(before_query)
|
|
409
|
+
before_row = dict(zip(before_response.columns, before_response.rows[0])) if before_response.rows else None
|
|
410
|
+
|
|
411
|
+
# Query after snapshot
|
|
412
|
+
after_response = self.after.resource.query(before_query)
|
|
413
|
+
after_row = dict(zip(after_response.columns, after_response.rows[0])) if after_response.rows else None
|
|
414
|
+
|
|
415
|
+
# Check changes for this row
|
|
416
|
+
if before_row and after_row:
|
|
417
|
+
# Modified row - check fields
|
|
418
|
+
for field in set(before_row.keys()) | set(after_row.keys()):
|
|
419
|
+
if self.ignore_config.should_ignore_field(table, field):
|
|
420
|
+
continue
|
|
421
|
+
before_val = before_row.get(field)
|
|
422
|
+
after_val = after_row.get(field)
|
|
423
|
+
if not _values_equivalent(before_val, after_val):
|
|
424
|
+
# Check if this change is allowed
|
|
425
|
+
if not self._is_field_change_allowed(table_changes, pk, field, after_val):
|
|
426
|
+
raise AssertionError(
|
|
427
|
+
f"Unexpected change in table '{table}', "
|
|
428
|
+
f"row {pk}, field '{field}': "
|
|
429
|
+
f"{repr(before_val)} -> {repr(after_val)}"
|
|
430
|
+
)
|
|
431
|
+
elif not before_row and after_row:
|
|
432
|
+
# Added row
|
|
433
|
+
if not self._is_row_change_allowed(table_changes, pk, "__added__"):
|
|
434
|
+
raise AssertionError(f"Unexpected row added in table '{table}': {pk}")
|
|
435
|
+
elif before_row and not after_row:
|
|
436
|
+
# Removed row
|
|
437
|
+
if not self._is_row_change_allowed(table_changes, pk, "__removed__"):
|
|
438
|
+
raise AssertionError(f"Unexpected row removed from table '{table}': {pk}")
|
|
439
|
+
|
|
440
|
+
# Now check tables not mentioned in allowed_changes to ensure no changes
|
|
441
|
+
all_tables = set(self.before.tables()) | set(self.after.tables())
|
|
442
|
+
for table in all_tables:
|
|
443
|
+
if table in changes_by_table or self.ignore_config.should_ignore_table(table):
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
# For tables with no allowed changes, just check row counts
|
|
447
|
+
before_count_response = self.before.resource.query(f"SELECT COUNT(*) FROM {table}")
|
|
448
|
+
before_count = before_count_response.rows[0][0] if before_count_response.rows else 0
|
|
449
|
+
|
|
450
|
+
after_count_response = self.after.resource.query(f"SELECT COUNT(*) FROM {table}")
|
|
451
|
+
after_count = after_count_response.rows[0][0] if after_count_response.rows else 0
|
|
452
|
+
|
|
453
|
+
if before_count != after_count:
|
|
454
|
+
raise AssertionError(
|
|
455
|
+
f"Unexpected change in table '{table}': "
|
|
456
|
+
f"row count changed from {before_count} to {after_count}"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return self
|
|
460
|
+
|
|
461
|
+
def _build_pk_where_clause(self, pk_columns: list[str], pk_value: Any) -> str:
|
|
462
|
+
"""Build WHERE clause for primary key lookup."""
|
|
463
|
+
# Escape single quotes in values to prevent SQL injection
|
|
464
|
+
def escape_value(val: Any) -> str:
|
|
465
|
+
if val is None:
|
|
466
|
+
return "NULL"
|
|
467
|
+
elif isinstance(val, str):
|
|
468
|
+
escaped = str(val).replace("'", "''")
|
|
469
|
+
return f"'{escaped}'"
|
|
470
|
+
else:
|
|
471
|
+
return f"'{val}'"
|
|
472
|
+
|
|
473
|
+
if len(pk_columns) == 1:
|
|
474
|
+
return f"{pk_columns[0]} = {escape_value(pk_value)}"
|
|
475
|
+
else:
|
|
476
|
+
# Composite key
|
|
477
|
+
if isinstance(pk_value, tuple):
|
|
478
|
+
conditions = [f"{col} = {escape_value(val)}" for col, val in zip(pk_columns, pk_value)]
|
|
479
|
+
return " AND ".join(conditions)
|
|
480
|
+
else:
|
|
481
|
+
# Shouldn't happen if data is consistent
|
|
482
|
+
return f"{pk_columns[0]} = {escape_value(pk_value)}"
|
|
483
|
+
|
|
484
|
+
def _is_field_change_allowed(self, table_changes: list[dict[str, Any]], pk: Any, field: str, after_val: Any) -> bool:
|
|
485
|
+
"""Check if a specific field change is allowed."""
|
|
486
|
+
for change in table_changes:
|
|
487
|
+
if (str(change.get("pk")) == str(pk) and
|
|
488
|
+
change.get("field") == field and
|
|
489
|
+
_values_equivalent(change.get("after"), after_val)):
|
|
490
|
+
return True
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
def _is_row_change_allowed(self, table_changes: list[dict[str, Any]], pk: Any, change_type: str) -> bool:
|
|
494
|
+
"""Check if a row addition/deletion is allowed."""
|
|
495
|
+
for change in table_changes:
|
|
496
|
+
if str(change.get("pk")) == str(pk) and change.get("after") == change_type:
|
|
497
|
+
return True
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
def _expect_no_changes(self):
|
|
501
|
+
"""Efficiently verify that no changes occurred between snapshots using row counts."""
|
|
502
|
+
try:
|
|
503
|
+
# Get all tables from both snapshots
|
|
504
|
+
before_tables = set(self.before.tables())
|
|
505
|
+
after_tables = set(self.after.tables())
|
|
506
|
+
|
|
507
|
+
# Check for added/removed tables (excluding ignored ones)
|
|
508
|
+
added_tables = after_tables - before_tables
|
|
509
|
+
removed_tables = before_tables - after_tables
|
|
510
|
+
|
|
511
|
+
for table in added_tables:
|
|
512
|
+
if not self.ignore_config.should_ignore_table(table):
|
|
513
|
+
raise AssertionError(f"Unexpected table added: {table}")
|
|
514
|
+
|
|
515
|
+
for table in removed_tables:
|
|
516
|
+
if not self.ignore_config.should_ignore_table(table):
|
|
517
|
+
raise AssertionError(f"Unexpected table removed: {table}")
|
|
518
|
+
|
|
519
|
+
# For each table, compare row counts
|
|
520
|
+
all_tables = before_tables | after_tables
|
|
521
|
+
for table in all_tables:
|
|
522
|
+
if self.ignore_config.should_ignore_table(table):
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
# Get row counts from both snapshots
|
|
526
|
+
before_count = 0
|
|
527
|
+
after_count = 0
|
|
528
|
+
|
|
529
|
+
if table in before_tables:
|
|
530
|
+
before_count_response = self.before.resource.query(f"SELECT COUNT(*) FROM {table}")
|
|
531
|
+
before_count = before_count_response.rows[0][0] if before_count_response.rows else 0
|
|
532
|
+
|
|
533
|
+
if table in after_tables:
|
|
534
|
+
after_count_response = self.after.resource.query(f"SELECT COUNT(*) FROM {table}")
|
|
535
|
+
after_count = after_count_response.rows[0][0] if after_count_response.rows else 0
|
|
536
|
+
|
|
537
|
+
if before_count != after_count:
|
|
538
|
+
raise AssertionError(
|
|
539
|
+
f"Unexpected change in table '{table}': "
|
|
540
|
+
f"row count changed from {before_count} to {after_count}"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# If counts match but there could be modifications, we need to check further
|
|
544
|
+
# For now, we'll do a more detailed check only if counts are small
|
|
545
|
+
# to avoid performance issues
|
|
546
|
+
if before_count > 0 and before_count <= 1000: # Threshold for detailed check
|
|
547
|
+
# Do a quick hash check of the data
|
|
548
|
+
self._verify_table_unchanged(table)
|
|
549
|
+
elif before_count > 1000:
|
|
550
|
+
# For large tables, we could sample or use checksums
|
|
551
|
+
# For now, we'll trust that count matching means no changes
|
|
552
|
+
# This is a reasonable assumption for expect_only([])
|
|
553
|
+
pass
|
|
554
|
+
|
|
555
|
+
return self
|
|
556
|
+
|
|
557
|
+
except AssertionError:
|
|
558
|
+
# Re-raise assertion errors (these are expected failures)
|
|
559
|
+
raise
|
|
560
|
+
except Exception as e:
|
|
561
|
+
# If the optimized check fails for other reasons, fall back to full diff
|
|
562
|
+
print(f"Warning: Optimized no-changes check failed: {e}")
|
|
563
|
+
print("Falling back to full diff...")
|
|
564
|
+
return self._expect_only_fallback([])
|
|
565
|
+
|
|
566
|
+
def _verify_table_unchanged(self, table: str):
|
|
567
|
+
"""Verify that a table's data hasn't changed (for small tables)."""
|
|
568
|
+
# Get primary key columns
|
|
569
|
+
pk_columns = self._get_primary_key_columns(table)
|
|
570
|
+
|
|
571
|
+
# Get sorted data from both snapshots
|
|
572
|
+
order_by = ", ".join(pk_columns) if pk_columns else "rowid"
|
|
573
|
+
|
|
574
|
+
before_response = self.before.resource.query(f"SELECT * FROM {table} ORDER BY {order_by}")
|
|
575
|
+
after_response = self.after.resource.query(f"SELECT * FROM {table} ORDER BY {order_by}")
|
|
576
|
+
|
|
577
|
+
# Quick check: if column counts differ, there's a schema change
|
|
578
|
+
if before_response.columns != after_response.columns:
|
|
579
|
+
raise AssertionError(f"Schema changed in table '{table}'")
|
|
580
|
+
|
|
581
|
+
# Compare row by row
|
|
582
|
+
if len(before_response.rows) != len(after_response.rows):
|
|
583
|
+
raise AssertionError(
|
|
584
|
+
f"Row count mismatch in table '{table}': "
|
|
585
|
+
f"{len(before_response.rows)} vs {len(after_response.rows)}"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
for i, (before_row, after_row) in enumerate(zip(before_response.rows, after_response.rows)):
|
|
589
|
+
before_dict = dict(zip(before_response.columns, before_row))
|
|
590
|
+
after_dict = dict(zip(after_response.columns, after_row))
|
|
591
|
+
|
|
592
|
+
# Compare fields, ignoring those in ignore config
|
|
593
|
+
for field in before_response.columns:
|
|
594
|
+
if self.ignore_config.should_ignore_field(table, field):
|
|
595
|
+
continue
|
|
596
|
+
|
|
597
|
+
if not _values_equivalent(before_dict.get(field), after_dict.get(field)):
|
|
598
|
+
pk_val = before_dict.get(pk_columns[0]) if pk_columns else i
|
|
599
|
+
raise AssertionError(
|
|
600
|
+
f"Unexpected change in table '{table}', row {pk_val}, "
|
|
601
|
+
f"field '{field}': {repr(before_dict.get(field))} -> {repr(after_dict.get(field))}"
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def _expect_only_fallback(self, allowed_changes: list[dict[str, Any]]):
|
|
605
|
+
"""Fallback to full diff collection when optimized methods fail."""
|
|
308
606
|
diff = self._collect()
|
|
607
|
+
return self._validate_diff_against_allowed_changes(diff, allowed_changes)
|
|
309
608
|
|
|
609
|
+
def _validate_diff_against_allowed_changes(self, diff: dict[str, Any], allowed_changes: list[dict[str, Any]]):
|
|
610
|
+
"""Validate a collected diff against allowed changes."""
|
|
310
611
|
def _is_change_allowed(
|
|
311
612
|
table: str, row_id: Any, field: str | None, after_value: Any
|
|
312
613
|
) -> bool:
|
|
@@ -419,6 +720,20 @@ class SyncSnapshotDiff:
|
|
|
419
720
|
raise AssertionError("\n".join(error_lines))
|
|
420
721
|
|
|
421
722
|
return self
|
|
723
|
+
|
|
724
|
+
def expect_only(self, allowed_changes: list[dict[str, Any]]):
|
|
725
|
+
"""Ensure only specified changes occurred."""
|
|
726
|
+
# Special case: empty allowed_changes means no changes should have occurred
|
|
727
|
+
if not allowed_changes:
|
|
728
|
+
return self._expect_no_changes()
|
|
729
|
+
|
|
730
|
+
# For expect_only, we can optimize by only checking the specific rows mentioned
|
|
731
|
+
if self._can_use_targeted_queries(allowed_changes):
|
|
732
|
+
return self._expect_only_targeted(allowed_changes)
|
|
733
|
+
|
|
734
|
+
# Fall back to full diff for complex cases
|
|
735
|
+
diff = self._collect()
|
|
736
|
+
return self._validate_diff_against_allowed_changes(diff, allowed_changes)
|
|
422
737
|
|
|
423
738
|
|
|
424
739
|
class SyncQueryBuilder:
|
|
@@ -669,9 +984,8 @@ class SQLiteResource(Resource):
|
|
|
669
984
|
|
|
670
985
|
def snapshot(self, name: str | None = None) -> SyncDatabaseSnapshot:
|
|
671
986
|
"""Create a snapshot of the current database state."""
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
return snapshot
|
|
987
|
+
# No longer fetch all data upfront - let it be lazy
|
|
988
|
+
return SyncDatabaseSnapshot(self, name)
|
|
675
989
|
|
|
676
990
|
def diff(
|
|
677
991
|
self,
|
fleet/verifiers/code.py
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
TASK_SUCCESSFUL_SCORE = 1
|
|
1
|
+
TASK_SUCCESSFUL_SCORE = 1
|
|
2
|
+
TASK_FAILED_SCORE = 0
|
|
@@ -15,20 +15,18 @@ examples/openai_example.py,sha256=I2vk_SJN9BkSRQCYRJfbtGJ-HJ2xzQj-lOjwqmLos5M,82
|
|
|
15
15
|
examples/openai_simple_example.py,sha256=I42ytIwv0INgDO39pp1MOQSqsJz2YYH8GeNNBaUtq3A,1748
|
|
16
16
|
examples/query_builder_example.py,sha256=Q3lUBETHpu1aS2FXAO79ADYqCxOjMMMZNgCcFVapiII,3918
|
|
17
17
|
examples/quickstart.py,sha256=1VT39IRRhemsJgxi0O0gprdpcw7HB4pYO97GAYagIcg,3788
|
|
18
|
-
fleet/__init__.py,sha256=
|
|
18
|
+
fleet/__init__.py,sha256=9EsG068VCTqCCyBA2jw8IMLw4_oFkWLhtY394CtjEgM,2234
|
|
19
19
|
fleet/base.py,sha256=0yYuMN0lBkrfTTZBt5NQp5112xWgziuWEk4GuHJB1wE,9189
|
|
20
|
-
fleet/client.py,sha256=
|
|
20
|
+
fleet/client.py,sha256=HrHkxQMk9_2GsyG7_edauKets3xySglCUuXW3WtXmTE,28263
|
|
21
21
|
fleet/config.py,sha256=zd19st83NJdW9DdOq7Irpc0x-iUnMad0JOtAr_nD5DM,273
|
|
22
22
|
fleet/exceptions.py,sha256=fUmPwWhnT8SR97lYsRq0kLHQHKtSh2eJS0VQ2caSzEI,5055
|
|
23
23
|
fleet/models.py,sha256=YMHFAgBF4OqoAOv-arHze3bPDDZ5DAzb3CXhVLF6pPw,9019
|
|
24
|
-
fleet/playwright.py,sha256=BmRvez5DUa0ttAQB084hPAyt9_8WxdzCGBGF-GZbTuQ,8593
|
|
25
24
|
fleet/tasks.py,sha256=w-0vVGfEuCWRHMEJ73SN141J7Lz2pD_0v-nNG4TyJTU,1645
|
|
26
25
|
fleet/types.py,sha256=eXeI8BFmiU5hln0PVvJbUZs7BSjl6wSqAtN9zaJT6yY,652
|
|
27
26
|
fleet/_async/__init__.py,sha256=AJWCnuo7XKja4yBb8fK2wX7ntciLXQrpzdRHwjTRP6M,62
|
|
28
27
|
fleet/_async/base.py,sha256=s0rYOtXsMJeitOvpa-Oh8ciLV226p_TIPp3fplzWvV4,9209
|
|
29
28
|
fleet/_async/client.py,sha256=yyNawgGro2sfFh0ok7DtbjnyxaBGrHg9RtTFEISvwdE,10581
|
|
30
29
|
fleet/_async/exceptions.py,sha256=fUmPwWhnT8SR97lYsRq0kLHQHKtSh2eJS0VQ2caSzEI,5055
|
|
31
|
-
fleet/_async/playwright.py,sha256=NXKcwJezl6jEV2CKBgeaB7zLshFWypgSA5GYXevDRX8,8908
|
|
32
30
|
fleet/_async/tasks.py,sha256=w-0vVGfEuCWRHMEJ73SN141J7Lz2pD_0v-nNG4TyJTU,1645
|
|
33
31
|
fleet/_async/env/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
32
|
fleet/_async/env/client.py,sha256=PQGKcvnVTr3o2KNsgQM7ZBqaCjimv-wEn-XiYGm44Ko,753
|
|
@@ -51,19 +49,19 @@ fleet/instance/models.py,sha256=ZTiue0YOuhuwX8jYfJAoCzGfqjLqqXRLqK1LVFhq6rQ,4183
|
|
|
51
49
|
fleet/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
50
|
fleet/resources/base.py,sha256=203gD54NP1IvjuSqFo-f7FvrkhtjChggtzrxJK7xf2E,667
|
|
53
51
|
fleet/resources/browser.py,sha256=OEqfNYPAptANza0T8K7UEn6nI7JnzlCICFMW8r2r83Q,1367
|
|
54
|
-
fleet/resources/mcp.py,sha256=
|
|
55
|
-
fleet/resources/sqlite.py,sha256=
|
|
52
|
+
fleet/resources/mcp.py,sha256=lDMyY4pMC3Khwp2Wycc6Ds6KeLAUOgHkDnmztuAnXm8,1872
|
|
53
|
+
fleet/resources/sqlite.py,sha256=aYR6OOrnUHwcUcbkXPLk4biNZI4VXGtPpN3F9n6t3-4,42132
|
|
56
54
|
fleet/verifiers/__init__.py,sha256=-dm2x0wC8UbfGcMCbPtjb0-LospGUhD-S3Pm4oha6BY,445
|
|
57
55
|
fleet/verifiers/bundler.py,sha256=A4yR3wBOcVZYFAv87CD58QlJn6L4QXeilrasnVm8n74,26185
|
|
58
|
-
fleet/verifiers/code.py,sha256=
|
|
56
|
+
fleet/verifiers/code.py,sha256=EOi6ES8Zdzlm9iybRFaJmz9t2W4Ulo2wrCdbEBqxzbc,47
|
|
59
57
|
fleet/verifiers/db.py,sha256=tssmvJjDHuBIy8qlL_P5-UdmEFUw2DZcqLsWZ8ot3Xw,27766
|
|
60
58
|
fleet/verifiers/decorator.py,sha256=Q-KHhicnIYFwX7FX_VZguzNfu8ZslqNUeWxcS2CwNVY,3386
|
|
61
59
|
fleet/verifiers/sql_differ.py,sha256=dmiGCFXVMEMbAX519OjhVqgA8ZvhnvdmC1BVpL7QCF0,6490
|
|
62
60
|
fleet/verifiers/verifier.py,sha256=C5L24M7VBHWKaWlryC1EdW1g7keZBGzVSjgXC_SlpXA,12205
|
|
63
|
-
fleet_python-0.2.
|
|
61
|
+
fleet_python-0.2.19.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
64
62
|
scripts/fix_sync_imports.py,sha256=BIQfnaOoQ7bwR1c-pDqH9ifN47W1bwL7OafWVXZNkuA,4368
|
|
65
63
|
scripts/unasync.py,sha256=--Fmaae47o-dZ1HYgX1c3Nvi-rMjcFymTRlJcWWnmpw,725
|
|
66
|
-
fleet_python-0.2.
|
|
67
|
-
fleet_python-0.2.
|
|
68
|
-
fleet_python-0.2.
|
|
69
|
-
fleet_python-0.2.
|
|
64
|
+
fleet_python-0.2.19.dist-info/METADATA,sha256=pzkr1yf1dDYvvaEvnwuit1M8goW9Xp3ah50dPyxmHew,3297
|
|
65
|
+
fleet_python-0.2.19.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
66
|
+
fleet_python-0.2.19.dist-info/top_level.txt,sha256=_3DSmTohvSDf3AIP_BYfGzhwO1ECFwuzg83X-wHCx3Y,23
|
|
67
|
+
fleet_python-0.2.19.dist-info/RECORD,,
|
fleet/_async/playwright.py
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import base64
|
|
2
|
-
from typing import List, Dict, Any
|
|
3
|
-
from playwright.async_api import async_playwright, Browser, Page
|
|
4
|
-
from .client import AsyncEnv
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
# Key mapping for computer use actions
|
|
8
|
-
CUA_KEY_TO_PLAYWRIGHT_KEY = {
|
|
9
|
-
"/": "Divide",
|
|
10
|
-
"\\": "Backslash",
|
|
11
|
-
"alt": "Alt",
|
|
12
|
-
"arrowdown": "ArrowDown",
|
|
13
|
-
"arrowleft": "ArrowLeft",
|
|
14
|
-
"arrowright": "ArrowRight",
|
|
15
|
-
"arrowup": "ArrowUp",
|
|
16
|
-
"backspace": "Backspace",
|
|
17
|
-
"capslock": "CapsLock",
|
|
18
|
-
"cmd": "Meta",
|
|
19
|
-
"ctrl": "Control",
|
|
20
|
-
"delete": "Delete",
|
|
21
|
-
"end": "End",
|
|
22
|
-
"enter": "Enter",
|
|
23
|
-
"esc": "Escape",
|
|
24
|
-
"home": "Home",
|
|
25
|
-
"insert": "Insert",
|
|
26
|
-
"option": "Alt",
|
|
27
|
-
"pagedown": "PageDown",
|
|
28
|
-
"pageup": "PageUp",
|
|
29
|
-
"shift": "Shift",
|
|
30
|
-
"space": " ",
|
|
31
|
-
"super": "Meta",
|
|
32
|
-
"tab": "Tab",
|
|
33
|
-
"win": "Meta",
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class AsyncFleetPlaywrightWrapper:
|
|
38
|
-
"""
|
|
39
|
-
A wrapper that adds Playwright browser automation to Fleet environment instances.
|
|
40
|
-
|
|
41
|
-
This class handles:
|
|
42
|
-
- Browser connection via CDP
|
|
43
|
-
- Computer actions (click, scroll, type, etc.)
|
|
44
|
-
- Screenshot capture
|
|
45
|
-
- Integration with OpenAI computer use API
|
|
46
|
-
|
|
47
|
-
Usage:
|
|
48
|
-
instance = await fleet.env.make(env_key="hubspot", version="v1.2.7")
|
|
49
|
-
browser = AsyncFleetPlaywrightWrapper(instance)
|
|
50
|
-
await browser.start()
|
|
51
|
-
|
|
52
|
-
# Use browser methods
|
|
53
|
-
screenshot = await browser.screenshot()
|
|
54
|
-
tools = [browser.openai_cua_tool]
|
|
55
|
-
|
|
56
|
-
# Clean up when done
|
|
57
|
-
await browser.close()
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
def get_environment(self):
|
|
61
|
-
return "browser"
|
|
62
|
-
|
|
63
|
-
def get_dimensions(self):
|
|
64
|
-
return (1920, 1080)
|
|
65
|
-
|
|
66
|
-
def __init__(
|
|
67
|
-
self,
|
|
68
|
-
env: AsyncEnv,
|
|
69
|
-
display_width: int = 1920,
|
|
70
|
-
display_height: int = 1080,
|
|
71
|
-
):
|
|
72
|
-
"""
|
|
73
|
-
Initialize the Fleet Playwright wrapper.
|
|
74
|
-
|
|
75
|
-
Args:
|
|
76
|
-
env: Fleet environment instance
|
|
77
|
-
display_width: Browser viewport width
|
|
78
|
-
display_height: Browser viewport height
|
|
79
|
-
"""
|
|
80
|
-
self.env = env
|
|
81
|
-
self.display_width = display_width
|
|
82
|
-
self.display_height = display_height
|
|
83
|
-
|
|
84
|
-
self._playwright = None
|
|
85
|
-
self._browser: Browser | None = None
|
|
86
|
-
self._page: Page | None = None
|
|
87
|
-
self._started = False
|
|
88
|
-
|
|
89
|
-
async def start(self):
|
|
90
|
-
"""Start the browser and establish connection."""
|
|
91
|
-
if self._started:
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
# Start Playwright
|
|
95
|
-
self._playwright = await async_playwright().start()
|
|
96
|
-
|
|
97
|
-
# Start browser on the Fleet instance
|
|
98
|
-
print("Starting browser...")
|
|
99
|
-
await self.env.browser().start()
|
|
100
|
-
cdp = await self.env.browser().describe()
|
|
101
|
-
|
|
102
|
-
# Connect to browser
|
|
103
|
-
self._browser = await self._playwright.chromium.connect_over_cdp(
|
|
104
|
-
cdp.cdp_browser_url
|
|
105
|
-
)
|
|
106
|
-
self._page = self._browser.contexts[0].pages[0]
|
|
107
|
-
await self._page.set_viewport_size(
|
|
108
|
-
{"width": self.display_width, "height": self.display_height}
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
self._started = True
|
|
112
|
-
print(f"Track agent: {cdp.cdp_devtools_url}")
|
|
113
|
-
|
|
114
|
-
async def close(self):
|
|
115
|
-
"""Close the browser connection."""
|
|
116
|
-
if self._playwright:
|
|
117
|
-
await self._playwright.stop()
|
|
118
|
-
self._playwright = None
|
|
119
|
-
self._browser = None
|
|
120
|
-
self._page = None
|
|
121
|
-
self._started = False
|
|
122
|
-
|
|
123
|
-
def _ensure_started(self):
|
|
124
|
-
"""Ensure browser is started before operations."""
|
|
125
|
-
if not self._started:
|
|
126
|
-
raise RuntimeError("Browser not started. Call await browser.start() first.")
|
|
127
|
-
|
|
128
|
-
@property
|
|
129
|
-
def openai_cua_tool(self) -> Dict[str, Any]:
|
|
130
|
-
"""
|
|
131
|
-
Tool definition for OpenAI computer use API.
|
|
132
|
-
|
|
133
|
-
Returns:
|
|
134
|
-
Tool definition dict for use with OpenAI responses API
|
|
135
|
-
"""
|
|
136
|
-
return {
|
|
137
|
-
"type": "computer_use_preview",
|
|
138
|
-
"display_width": self.display_width,
|
|
139
|
-
"display_height": self.display_height,
|
|
140
|
-
"environment": "browser",
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async def screenshot(self) -> str:
|
|
144
|
-
"""
|
|
145
|
-
Take a screenshot and return base64 encoded string.
|
|
146
|
-
|
|
147
|
-
Returns:
|
|
148
|
-
Base64 encoded PNG screenshot
|
|
149
|
-
"""
|
|
150
|
-
self._ensure_started()
|
|
151
|
-
|
|
152
|
-
png_bytes = await self._page.screenshot(full_page=False)
|
|
153
|
-
return base64.b64encode(png_bytes).decode("utf-8")
|
|
154
|
-
|
|
155
|
-
def get_current_url(self) -> str:
|
|
156
|
-
"""Get the current page URL."""
|
|
157
|
-
self._ensure_started()
|
|
158
|
-
return self._page.url
|
|
159
|
-
|
|
160
|
-
async def execute_computer_action(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
161
|
-
"""
|
|
162
|
-
Execute a computer action and return the result for OpenAI API.
|
|
163
|
-
|
|
164
|
-
Args:
|
|
165
|
-
action: Computer action dict from OpenAI response
|
|
166
|
-
|
|
167
|
-
Returns:
|
|
168
|
-
Result dict for computer_call_output
|
|
169
|
-
"""
|
|
170
|
-
self._ensure_started()
|
|
171
|
-
|
|
172
|
-
action_type = action["type"]
|
|
173
|
-
action_args = {k: v for k, v in action.items() if k != "type"}
|
|
174
|
-
|
|
175
|
-
print(f"Executing: {action_type}({action_args})")
|
|
176
|
-
|
|
177
|
-
# Execute the action
|
|
178
|
-
if hasattr(self, f"_{action_type}"):
|
|
179
|
-
method = getattr(self, f"_{action_type}")
|
|
180
|
-
await method(**action_args)
|
|
181
|
-
else:
|
|
182
|
-
raise ValueError(f"Unsupported action type: {action_type}")
|
|
183
|
-
|
|
184
|
-
# Take screenshot after action
|
|
185
|
-
screenshot_base64 = await self.screenshot()
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
"type": "input_image",
|
|
189
|
-
"image_url": f"data:image/png;base64,{screenshot_base64}",
|
|
190
|
-
"current_url": self.get_current_url(),
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
# Computer action implementations
|
|
194
|
-
async def _click(self, x: int, y: int, button: str = "left") -> None:
|
|
195
|
-
"""Click at coordinates."""
|
|
196
|
-
self._ensure_started()
|
|
197
|
-
await self._page.mouse.click(x, y, button=button)
|
|
198
|
-
|
|
199
|
-
async def _double_click(self, x: int, y: int) -> None:
|
|
200
|
-
"""Double-click at coordinates."""
|
|
201
|
-
self._ensure_started()
|
|
202
|
-
await self._page.mouse.dblclick(x, y)
|
|
203
|
-
|
|
204
|
-
async def _scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
|
|
205
|
-
"""Scroll from coordinates."""
|
|
206
|
-
self._ensure_started()
|
|
207
|
-
await self._page.mouse.move(x, y)
|
|
208
|
-
await self._page.evaluate(f"window.scrollBy({scroll_x}, {scroll_y})")
|
|
209
|
-
|
|
210
|
-
async def _type(self, text: str) -> None:
|
|
211
|
-
"""Type text."""
|
|
212
|
-
self._ensure_started()
|
|
213
|
-
await self._page.keyboard.type(text)
|
|
214
|
-
|
|
215
|
-
async def _keypress(self, keys: List[str]) -> None:
|
|
216
|
-
"""Press key combination."""
|
|
217
|
-
self._ensure_started()
|
|
218
|
-
mapped_keys = [CUA_KEY_TO_PLAYWRIGHT_KEY.get(key.lower(), key) for key in keys]
|
|
219
|
-
for key in mapped_keys:
|
|
220
|
-
await self._page.keyboard.down(key)
|
|
221
|
-
for key in reversed(mapped_keys):
|
|
222
|
-
await self._page.keyboard.up(key)
|
|
223
|
-
|
|
224
|
-
async def _move(self, x: int, y: int) -> None:
|
|
225
|
-
"""Move mouse to coordinates."""
|
|
226
|
-
self._ensure_started()
|
|
227
|
-
await self._page.mouse.move(x, y)
|
|
228
|
-
|
|
229
|
-
async def _drag(self, path: List[Dict[str, int]]) -> None:
|
|
230
|
-
"""Drag mouse along path."""
|
|
231
|
-
self._ensure_started()
|
|
232
|
-
if not path:
|
|
233
|
-
return
|
|
234
|
-
await self._page.mouse.move(path[0]["x"], path[0]["y"])
|
|
235
|
-
await self._page.mouse.down()
|
|
236
|
-
for point in path[1:]:
|
|
237
|
-
await self._page.mouse.move(point["x"], point["y"])
|
|
238
|
-
await self._page.mouse.up()
|
|
239
|
-
|
|
240
|
-
async def _wait(self, ms: int = 1000) -> None:
|
|
241
|
-
"""Wait for specified milliseconds."""
|
|
242
|
-
import asyncio
|
|
243
|
-
|
|
244
|
-
await asyncio.sleep(ms / 1000)
|
|
245
|
-
|
|
246
|
-
# Browser-specific actions
|
|
247
|
-
async def _goto(self, url: str) -> None:
|
|
248
|
-
"""Navigate to URL."""
|
|
249
|
-
self._ensure_started()
|
|
250
|
-
try:
|
|
251
|
-
await self._page.goto(url)
|
|
252
|
-
except Exception as e:
|
|
253
|
-
print(f"Error navigating to {url}: {e}")
|
|
254
|
-
|
|
255
|
-
async def _back(self) -> None:
|
|
256
|
-
"""Go back in browser history."""
|
|
257
|
-
self._ensure_started()
|
|
258
|
-
await self._page.go_back()
|
|
259
|
-
|
|
260
|
-
async def _forward(self) -> None:
|
|
261
|
-
"""Go forward in browser history."""
|
|
262
|
-
self._ensure_started()
|
|
263
|
-
await self._page.go_forward()
|
|
264
|
-
|
|
265
|
-
async def _refresh(self) -> None:
|
|
266
|
-
"""Refresh the page."""
|
|
267
|
-
self._ensure_started()
|
|
268
|
-
await self._page.reload()
|
|
269
|
-
|
|
270
|
-
# ------------------------------------------------------------------
|
|
271
|
-
# Public aliases (no leading underscore) expected by the Agent &
|
|
272
|
-
# OpenAI computer-use API. They forward directly to the underscored
|
|
273
|
-
# implementations above so the external interface matches the older
|
|
274
|
-
# BasePlaywrightComputer class.
|
|
275
|
-
# ------------------------------------------------------------------
|
|
276
|
-
|
|
277
|
-
# Mouse / keyboard actions
|
|
278
|
-
click = _click
|
|
279
|
-
double_click = _double_click
|
|
280
|
-
scroll = _scroll
|
|
281
|
-
type = _type # noqa: A003 – shadowing built-in for API compatibility
|
|
282
|
-
keypress = _keypress
|
|
283
|
-
move = _move
|
|
284
|
-
drag = _drag
|
|
285
|
-
wait = _wait
|
|
286
|
-
|
|
287
|
-
# Browser navigation actions
|
|
288
|
-
goto = _goto
|
|
289
|
-
back = _back
|
|
290
|
-
forward = _forward
|
|
291
|
-
refresh = _refresh
|
fleet/playwright.py
DELETED
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
import base64
|
|
2
|
-
from typing import List, Dict, Any
|
|
3
|
-
from playwright.sync_api import sync_playwright, Browser, Page
|
|
4
|
-
from .client import Environment
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
# Key mapping for computer use actions
|
|
8
|
-
CUA_KEY_TO_PLAYWRIGHT_KEY = {
|
|
9
|
-
"/": "Divide",
|
|
10
|
-
"\\": "Backslash",
|
|
11
|
-
"alt": "Alt",
|
|
12
|
-
"arrowdown": "ArrowDown",
|
|
13
|
-
"arrowleft": "ArrowLeft",
|
|
14
|
-
"arrowright": "ArrowRight",
|
|
15
|
-
"arrowup": "ArrowUp",
|
|
16
|
-
"backspace": "Backspace",
|
|
17
|
-
"capslock": "CapsLock",
|
|
18
|
-
"cmd": "Meta",
|
|
19
|
-
"ctrl": "Control",
|
|
20
|
-
"delete": "Delete",
|
|
21
|
-
"end": "End",
|
|
22
|
-
"enter": "Enter",
|
|
23
|
-
"esc": "Escape",
|
|
24
|
-
"home": "Home",
|
|
25
|
-
"insert": "Insert",
|
|
26
|
-
"option": "Alt",
|
|
27
|
-
"pagedown": "PageDown",
|
|
28
|
-
"pageup": "PageUp",
|
|
29
|
-
"shift": "Shift",
|
|
30
|
-
"space": " ",
|
|
31
|
-
"super": "Meta",
|
|
32
|
-
"tab": "Tab",
|
|
33
|
-
"win": "Meta",
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class FleetPlaywrightWrapper:
|
|
38
|
-
"""
|
|
39
|
-
A wrapper that adds Playwright browser automation to Fleet environment instances.
|
|
40
|
-
|
|
41
|
-
This class handles:
|
|
42
|
-
- Browser connection via CDP
|
|
43
|
-
- Computer actions (click, scroll, type, etc.)
|
|
44
|
-
- Screenshot capture
|
|
45
|
-
- Integration with OpenAI computer use API
|
|
46
|
-
|
|
47
|
-
Usage:
|
|
48
|
-
instance = fleet.env.make(env_key="hubspot", version="v1.2.7")
|
|
49
|
-
browser = FleetPlaywrightWrapper(instance)
|
|
50
|
-
browser.start()
|
|
51
|
-
|
|
52
|
-
# Use browser methods
|
|
53
|
-
screenshot = browser.screenshot()
|
|
54
|
-
tools = [browser.openai_cua_tool]
|
|
55
|
-
|
|
56
|
-
# Clean up when done
|
|
57
|
-
browser.close()
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
def get_environment(self):
|
|
61
|
-
return "browser"
|
|
62
|
-
|
|
63
|
-
def get_dimensions(self):
|
|
64
|
-
return (1920, 1080)
|
|
65
|
-
|
|
66
|
-
def __init__(
|
|
67
|
-
self,
|
|
68
|
-
env: Environment,
|
|
69
|
-
display_width: int = 1920,
|
|
70
|
-
display_height: int = 1080,
|
|
71
|
-
):
|
|
72
|
-
"""
|
|
73
|
-
Initialize the Fleet Playwright wrapper.
|
|
74
|
-
|
|
75
|
-
Args:
|
|
76
|
-
env: Fleet environment instance
|
|
77
|
-
display_width: Browser viewport width
|
|
78
|
-
display_height: Browser viewport height
|
|
79
|
-
"""
|
|
80
|
-
self.env = env
|
|
81
|
-
self.display_width = display_width
|
|
82
|
-
self.display_height = display_height
|
|
83
|
-
|
|
84
|
-
self._playwright = None
|
|
85
|
-
self._browser: Browser | None = None
|
|
86
|
-
self._page: Page | None = None
|
|
87
|
-
self._started = False
|
|
88
|
-
|
|
89
|
-
def start(self):
|
|
90
|
-
"""Start the browser and establish connection."""
|
|
91
|
-
if self._started:
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
# Start Playwright
|
|
95
|
-
self._playwright = sync_playwright().start()
|
|
96
|
-
|
|
97
|
-
# Start browser on the Fleet instance
|
|
98
|
-
print("Starting browser...")
|
|
99
|
-
self.env.browser().start()
|
|
100
|
-
cdp = self.env.browser().describe()
|
|
101
|
-
|
|
102
|
-
# Connect to browser
|
|
103
|
-
self._browser = self._playwright.chromium.connect_over_cdp(
|
|
104
|
-
cdp.cdp_browser_url
|
|
105
|
-
)
|
|
106
|
-
self._page = self._browser.contexts[0].pages[0]
|
|
107
|
-
self._page.set_viewport_size(
|
|
108
|
-
{"width": self.display_width, "height": self.display_height}
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
self._started = True
|
|
112
|
-
print(f"Track agent: {cdp.cdp_devtools_url}")
|
|
113
|
-
|
|
114
|
-
def close(self):
|
|
115
|
-
"""Close the browser connection."""
|
|
116
|
-
if self._playwright:
|
|
117
|
-
self._playwright.stop()
|
|
118
|
-
self._playwright = None
|
|
119
|
-
self._browser = None
|
|
120
|
-
self._page = None
|
|
121
|
-
self._started = False
|
|
122
|
-
|
|
123
|
-
def _ensure_started(self):
|
|
124
|
-
"""Ensure browser is started before operations."""
|
|
125
|
-
if not self._started:
|
|
126
|
-
raise RuntimeError("Browser not started. Call browser.start() first.")
|
|
127
|
-
|
|
128
|
-
@property
|
|
129
|
-
def openai_cua_tool(self) -> Dict[str, Any]:
|
|
130
|
-
"""
|
|
131
|
-
Tool definition for OpenAI computer use API.
|
|
132
|
-
|
|
133
|
-
Returns:
|
|
134
|
-
Tool definition dict for use with OpenAI responses API
|
|
135
|
-
"""
|
|
136
|
-
return {
|
|
137
|
-
"type": "computer_use_preview",
|
|
138
|
-
"display_width": self.display_width,
|
|
139
|
-
"display_height": self.display_height,
|
|
140
|
-
"environment": "browser",
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
def screenshot(self) -> str:
|
|
144
|
-
"""
|
|
145
|
-
Take a screenshot and return base64 encoded string.
|
|
146
|
-
|
|
147
|
-
Returns:
|
|
148
|
-
Base64 encoded PNG screenshot
|
|
149
|
-
"""
|
|
150
|
-
self._ensure_started()
|
|
151
|
-
|
|
152
|
-
png_bytes = self._page.screenshot(full_page=False)
|
|
153
|
-
return base64.b64encode(png_bytes).decode("utf-8")
|
|
154
|
-
|
|
155
|
-
def get_current_url(self) -> str:
|
|
156
|
-
"""Get the current page URL."""
|
|
157
|
-
self._ensure_started()
|
|
158
|
-
return self._page.url
|
|
159
|
-
|
|
160
|
-
def execute_computer_action(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
161
|
-
"""
|
|
162
|
-
Execute a computer action and return the result for OpenAI API.
|
|
163
|
-
|
|
164
|
-
Args:
|
|
165
|
-
action: Computer action dict from OpenAI response
|
|
166
|
-
|
|
167
|
-
Returns:
|
|
168
|
-
Result dict for computer_call_output
|
|
169
|
-
"""
|
|
170
|
-
self._ensure_started()
|
|
171
|
-
|
|
172
|
-
action_type = action["type"]
|
|
173
|
-
action_args = {k: v for k, v in action.items() if k != "type"}
|
|
174
|
-
|
|
175
|
-
print(f"Executing: {action_type}({action_args})")
|
|
176
|
-
|
|
177
|
-
# Execute the action
|
|
178
|
-
if hasattr(self, f"_{action_type}"):
|
|
179
|
-
method = getattr(self, f"_{action_type}")
|
|
180
|
-
method(**action_args)
|
|
181
|
-
else:
|
|
182
|
-
raise ValueError(f"Unsupported action type: {action_type}")
|
|
183
|
-
|
|
184
|
-
# Take screenshot after action
|
|
185
|
-
screenshot_base64 = self.screenshot()
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
"type": "input_image",
|
|
189
|
-
"image_url": f"data:image/png;base64,{screenshot_base64}",
|
|
190
|
-
"current_url": self.get_current_url(),
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
# Computer action implementations
|
|
194
|
-
def _click(self, x: int, y: int, button: str = "left") -> None:
|
|
195
|
-
"""Click at coordinates."""
|
|
196
|
-
self._ensure_started()
|
|
197
|
-
self._page.mouse.click(x, y, button=button)
|
|
198
|
-
|
|
199
|
-
def _double_click(self, x: int, y: int) -> None:
|
|
200
|
-
"""Double-click at coordinates."""
|
|
201
|
-
self._ensure_started()
|
|
202
|
-
self._page.mouse.dblclick(x, y)
|
|
203
|
-
|
|
204
|
-
def _scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
|
|
205
|
-
"""Scroll from coordinates."""
|
|
206
|
-
self._ensure_started()
|
|
207
|
-
self._page.mouse.move(x, y)
|
|
208
|
-
self._page.evaluate(f"window.scrollBy({scroll_x}, {scroll_y})")
|
|
209
|
-
|
|
210
|
-
def _type(self, text: str) -> None:
|
|
211
|
-
"""Type text."""
|
|
212
|
-
self._ensure_started()
|
|
213
|
-
self._page.keyboard.type(text)
|
|
214
|
-
|
|
215
|
-
def _keypress(self, keys: List[str]) -> None:
|
|
216
|
-
"""Press key combination."""
|
|
217
|
-
self._ensure_started()
|
|
218
|
-
mapped_keys = [CUA_KEY_TO_PLAYWRIGHT_KEY.get(key.lower(), key) for key in keys]
|
|
219
|
-
for key in mapped_keys:
|
|
220
|
-
self._page.keyboard.down(key)
|
|
221
|
-
for key in reversed(mapped_keys):
|
|
222
|
-
self._page.keyboard.up(key)
|
|
223
|
-
|
|
224
|
-
def _move(self, x: int, y: int) -> None:
|
|
225
|
-
"""Move mouse to coordinates."""
|
|
226
|
-
self._ensure_started()
|
|
227
|
-
self._page.mouse.move(x, y)
|
|
228
|
-
|
|
229
|
-
def _drag(self, path: List[Dict[str, int]]) -> None:
|
|
230
|
-
"""Drag mouse along path."""
|
|
231
|
-
self._ensure_started()
|
|
232
|
-
if not path:
|
|
233
|
-
return
|
|
234
|
-
self._page.mouse.move(path[0]["x"], path[0]["y"])
|
|
235
|
-
self._page.mouse.down()
|
|
236
|
-
for point in path[1:]:
|
|
237
|
-
self._page.mouse.move(point["x"], point["y"])
|
|
238
|
-
self._page.mouse.up()
|
|
239
|
-
|
|
240
|
-
def _wait(self, ms: int = 1000) -> None:
|
|
241
|
-
"""Wait for specified milliseconds."""
|
|
242
|
-
|
|
243
|
-
time.sleep(ms / 1000)
|
|
244
|
-
|
|
245
|
-
# Browser-specific actions
|
|
246
|
-
def _goto(self, url: str) -> None:
|
|
247
|
-
"""Navigate to URL."""
|
|
248
|
-
self._ensure_started()
|
|
249
|
-
try:
|
|
250
|
-
self._page.goto(url)
|
|
251
|
-
except Exception as e:
|
|
252
|
-
print(f"Error navigating to {url}: {e}")
|
|
253
|
-
|
|
254
|
-
def _back(self) -> None:
|
|
255
|
-
"""Go back in browser history."""
|
|
256
|
-
self._ensure_started()
|
|
257
|
-
self._page.go_back()
|
|
258
|
-
|
|
259
|
-
def _forward(self) -> None:
|
|
260
|
-
"""Go forward in browser history."""
|
|
261
|
-
self._ensure_started()
|
|
262
|
-
self._page.go_forward()
|
|
263
|
-
|
|
264
|
-
def _refresh(self) -> None:
|
|
265
|
-
"""Refresh the page."""
|
|
266
|
-
self._ensure_started()
|
|
267
|
-
self._page.reload()
|
|
268
|
-
|
|
269
|
-
# ------------------------------------------------------------------
|
|
270
|
-
# Public aliases (no leading underscore) expected by the Agent &
|
|
271
|
-
# OpenAI computer-use API. They forward directly to the underscored
|
|
272
|
-
# implementations above so the external interface matches the older
|
|
273
|
-
# BasePlaywrightComputer class.
|
|
274
|
-
# ------------------------------------------------------------------
|
|
275
|
-
|
|
276
|
-
# Mouse / keyboard actions
|
|
277
|
-
click = _click
|
|
278
|
-
double_click = _double_click
|
|
279
|
-
scroll = _scroll
|
|
280
|
-
type = _type # noqa: A003 – shadowing built-in for API compatibility
|
|
281
|
-
keypress = _keypress
|
|
282
|
-
move = _move
|
|
283
|
-
drag = _drag
|
|
284
|
-
wait = _wait
|
|
285
|
-
|
|
286
|
-
# Browser navigation actions
|
|
287
|
-
goto = _goto
|
|
288
|
-
back = _back
|
|
289
|
-
forward = _forward
|
|
290
|
-
refresh = _refresh
|
|
File without changes
|
|
File without changes
|
|
File without changes
|