Skip to content

Commit abf1c92

Browse files
guyernestclaude
andcommitted
feat(browser-agent): Add S3 upload verification and screenshot capture functionality
## S3 Upload Verification (Config Tab) - Added "Test S3 Upload" button in Configuration screen - Uploads test file to _connection_tests/ prefix - Verifies upload with HEAD request - Provides helpful error messages for common issues (bucket not found, access denied, wrong region) - Shows upload duration for performance monitoring ## Screenshot Upload Feature - New screenshot step options: upload_to_s3, include_in_result - Immediate S3 upload with verification_screenshots in result - Local fallback when S3 not configured (--local-screenshots flag) - Execution ID tracking for organized S3 paths - screenshot_config at script level for s3_prefix and upload_mode ## Template Updates - BT broadband template now captures verification screenshot before extraction - Added screenshot_config with s3_prefix: "verification" - Screenshot saved as 06_extraction_page.png and included in result ## Files Changed - src-tauri/Cargo.toml: Added aws-sdk-s3 dependency - src-tauri/src/config_commands.rs: Added test_s3_upload command - src-tauri/src/main.rs: Registered test_s3_upload command - ui/src/components/ConfigScreen.tsx: Added S3 test button and result display - python/openai_playwright_executor.py: Screenshot upload implementation - templates/bt_broadband_workflow_template.json: Verification screenshot step 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d8c7d8e commit abf1c92

6 files changed

Lines changed: 417 additions & 8 deletions

File tree

lambda/tools/local-browser-agent/python/openai_playwright_executor.py

Lines changed: 177 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,19 @@ def __init__(
150150
# Execution state
151151
self.variables = {} # For template variable substitution
152152
self.screenshots = []
153+
self.verification_screenshots = [] # Screenshots marked for inclusion in result
153154
self.escalation_engine: Optional[ProgressiveEscalationEngine] = None
154155
# Internal counter for externally-driven workflow steps
155156
self._workflow_step_counter = 0
156157

157158
# Default delay between actions (can be overridden per-step or at script level)
158159
self.default_delay = 0
159160

161+
# Screenshot upload configuration
162+
self.s3_prefix = "verification" # Default S3 prefix for screenshots
163+
self.execution_id = None # Will be set from variables or generated
164+
self.local_screenshot_dir = None # For local testing mode
165+
160166
def _compute_delay(self, delay_config: Union[int, Dict[str, int], None], script_default: int = 0) -> int:
161167
"""
162168
Compute delay in milliseconds from various config formats.
@@ -336,6 +342,82 @@ async def _find_form_field(self, label: str, field_type: str = "input") -> Any:
336342

337343
raise Exception(f"Could not find form field with label '{label}' using any strategy")
338344

345+
async def _upload_single_screenshot(self, filename: str, screenshot_bytes: bytes, step: Dict[str, Any] = None) -> str:
346+
"""
347+
Upload a single screenshot to S3 and return the URI.
348+
349+
Args:
350+
filename: Name of the screenshot file
351+
screenshot_bytes: PNG image data
352+
step: Optional step config for custom S3 prefix
353+
354+
Returns:
355+
S3 URI of the uploaded screenshot
356+
"""
357+
if not self.s3_bucket:
358+
raise Exception("S3 bucket not configured for screenshot upload")
359+
360+
s3_client = self.boto_session.client('s3')
361+
362+
# Get S3 prefix from step config or use default
363+
s3_prefix = self.s3_prefix
364+
if step and step.get("s3_prefix"):
365+
s3_prefix = step["s3_prefix"]
366+
367+
# Generate execution ID if not set
368+
if not self.execution_id:
369+
import time
370+
self.execution_id = f"exec_{int(time.time())}"
371+
372+
# Build S3 key
373+
s3_key = f"{s3_prefix}/{self.execution_id}/{filename}"
374+
375+
print(f" 📤 Uploading screenshot to s3://{self.s3_bucket}/{s3_key}", file=sys.stderr)
376+
377+
s3_client.put_object(
378+
Bucket=self.s3_bucket,
379+
Key=s3_key,
380+
Body=screenshot_bytes,
381+
ContentType='image/png',
382+
)
383+
384+
s3_uri = f"s3://{self.s3_bucket}/{s3_key}"
385+
print(f" ✓ Screenshot uploaded: {s3_uri}", file=sys.stderr)
386+
387+
return s3_uri
388+
389+
async def _save_screenshot_locally(self, filename: str, screenshot_bytes: bytes) -> str:
390+
"""
391+
Save a screenshot to the local filesystem (for testing without S3).
392+
393+
Args:
394+
filename: Name of the screenshot file
395+
screenshot_bytes: PNG image data
396+
397+
Returns:
398+
Local file path of the saved screenshot
399+
"""
400+
from pathlib import Path
401+
402+
# Use configured directory or default to ./screenshots
403+
local_dir = Path(self.local_screenshot_dir) if self.local_screenshot_dir else Path("./screenshots")
404+
local_dir.mkdir(parents=True, exist_ok=True)
405+
406+
# Generate execution ID subdirectory
407+
if not self.execution_id:
408+
import time
409+
self.execution_id = f"exec_{int(time.time())}"
410+
411+
exec_dir = local_dir / self.execution_id
412+
exec_dir.mkdir(parents=True, exist_ok=True)
413+
414+
local_path = exec_dir / filename
415+
local_path.write_bytes(screenshot_bytes)
416+
417+
print(f" 💾 Screenshot saved locally: {local_path}", file=sys.stderr)
418+
419+
return str(local_path)
420+
339421
async def execute_step(self, step: Dict[str, Any]) -> Dict[str, Any]:
340422
"""Public step executor used by WorkflowExecutor.
341423
@@ -443,6 +525,22 @@ async def execute_script(self, script: Dict[str, Any]) -> Dict[str, Any]:
443525
# Can be overridden per-step with "delay" field
444526
self.default_delay = script.get("default_delay", 0)
445527

528+
# Screenshot configuration
529+
screenshot_config = script.get("screenshot_config", {})
530+
self.s3_prefix = screenshot_config.get("s3_prefix", "verification")
531+
self.local_screenshot_dir = screenshot_config.get("local_dir", None)
532+
533+
# Set execution ID from variables, script, or generate one
534+
import time
535+
self.execution_id = (
536+
self.variables.get("execution_id") or
537+
script.get("execution_id") or
538+
f"exec_{name.replace(' ', '_').lower()}_{int(time.time())}"
539+
)
540+
541+
# Reset verification screenshots for this execution
542+
self.verification_screenshots = []
543+
446544
print(f"\n{'='*60}", file=sys.stderr)
447545
print(f"OpenAI Playwright Executor", file=sys.stderr)
448546
print(f"{'='*60}", file=sys.stderr)
@@ -451,6 +549,9 @@ async def execute_script(self, script: Dict[str, Any]) -> Dict[str, Any]:
451549
print(f"LLM: {self.llm_provider} ({self.llm_model})", file=sys.stderr)
452550
print(f"Starting Page: {starting_page}", file=sys.stderr)
453551
print(f"Total Steps: {len(steps)}", file=sys.stderr)
552+
print(f"Execution ID: {self.execution_id}", file=sys.stderr)
553+
if self.s3_bucket:
554+
print(f"Screenshots: s3://{self.s3_bucket}/{self.s3_prefix}/{self.execution_id}/", file=sys.stderr)
454555
print(f"{'='*60}\n", file=sys.stderr)
455556

456557
result = {
@@ -635,10 +736,21 @@ async def _execute_linear_steps(self, steps: List[Dict[str, Any]], result: Dict[
635736
result["error"] = error_msg
636737
break
637738

638-
# Upload screenshots to S3 if configured
739+
# Upload screenshots to S3 if configured (batch upload for non-immediate screenshots)
639740
if self.s3_bucket and self.screenshots:
640741
result["screenshots"] = await self._upload_screenshots(name)
641742

743+
# Include verification screenshots in result (for user verification)
744+
if self.verification_screenshots:
745+
result["verification_screenshots"] = self.verification_screenshots
746+
print(f"\n📸 Verification screenshots: {len(self.verification_screenshots)}", file=sys.stderr)
747+
for vs in self.verification_screenshots:
748+
loc = vs.get("s3_uri") or vs.get("local_path", "unknown")
749+
print(f" • {vs['filename']}: {loc}", file=sys.stderr)
750+
751+
# Add execution metadata
752+
result["execution_id"] = self.execution_id
753+
642754
print(f"\n{'='*60}", file=sys.stderr)
643755
print(f"Execution {'✓ Complete' if result['success'] else '✗ Failed'}", file=sys.stderr)
644756
print(f"Steps executed: {result['steps_executed']}/{result['steps_total']}", file=sys.stderr)
@@ -1212,22 +1324,76 @@ async def _step_wait_for_load_state(self, step: Dict[str, Any], step_num: int) -
12121324
}
12131325

12141326
async def _step_screenshot(self, step: Dict[str, Any], step_num: int) -> Dict[str, Any]:
1215-
"""Take screenshot"""
1216-
screenshot_bytes = await self.page.screenshot(full_page=True)
1327+
"""
1328+
Take screenshot with optional S3 upload.
1329+
1330+
Supports:
1331+
- save_to: Filename for the screenshot (default: step_{num}.png)
1332+
- upload_to_s3: Immediately upload to S3 (default: False)
1333+
- include_in_result: Include S3 URI in final result for verification (default: False)
1334+
- s3_prefix: Custom S3 prefix (default: uses self.s3_prefix)
1335+
- full_page: Take full page screenshot (default: True)
1336+
"""
1337+
full_page = step.get("full_page", True)
1338+
screenshot_bytes = await self.page.screenshot(full_page=full_page)
12171339

12181340
filename = step.get("save_to", f"step_{step_num}.png")
1219-
self.screenshots.append({
1341+
upload_to_s3 = step.get("upload_to_s3", False)
1342+
include_in_result = step.get("include_in_result", False)
1343+
1344+
# Store screenshot data in memory
1345+
screenshot_data = {
12201346
"filename": filename,
12211347
"data": screenshot_bytes,
12221348
"step": step_num,
1223-
})
1349+
"upload_to_s3": upload_to_s3,
1350+
"include_in_result": include_in_result,
1351+
}
1352+
self.screenshots.append(screenshot_data)
12241353

1225-
return {
1354+
result = {
12261355
"success": True,
12271356
"action": "screenshot",
12281357
"filename": filename,
12291358
}
12301359

1360+
# Immediate upload if requested
1361+
if upload_to_s3:
1362+
if self.s3_bucket:
1363+
try:
1364+
s3_uri = await self._upload_single_screenshot(filename, screenshot_bytes, step)
1365+
result["screenshot_s3_uri"] = s3_uri
1366+
1367+
# Track for inclusion in final result
1368+
if include_in_result:
1369+
self.verification_screenshots.append({
1370+
"filename": filename,
1371+
"s3_uri": s3_uri,
1372+
"step": step_num,
1373+
"step_name": step.get("description", f"Step {step_num}"),
1374+
})
1375+
except Exception as e:
1376+
print(f" ⚠ Failed to upload screenshot: {e}", file=sys.stderr)
1377+
result["upload_error"] = str(e)
1378+
else:
1379+
# Local fallback when S3 not configured
1380+
try:
1381+
local_path = await self._save_screenshot_locally(filename, screenshot_bytes)
1382+
result["local_path"] = local_path
1383+
1384+
if include_in_result:
1385+
self.verification_screenshots.append({
1386+
"filename": filename,
1387+
"local_path": local_path,
1388+
"step": step_num,
1389+
"step_name": step.get("description", f"Step {step_num}"),
1390+
})
1391+
except Exception as e:
1392+
print(f" ⚠ Failed to save screenshot locally: {e}", file=sys.stderr)
1393+
result["save_error"] = str(e)
1394+
1395+
return result
1396+
12311397
async def _step_extract(self, step: Dict[str, Any], step_num: int) -> Dict[str, Any]:
12321398
"""Extract data using LLM vision and optionally execute action"""
12331399
method = step.get("method", "vision")
@@ -1559,6 +1725,7 @@ async def main():
15591725
parser.add_argument("--llm-model", default="gpt-4o-mini", help="LLM model name")
15601726
parser.add_argument("--aws-profile", default="browser-agent", help="AWS profile")
15611727
parser.add_argument("--s3-bucket", help="S3 bucket for screenshots")
1728+
parser.add_argument("--local-screenshots", help="Local directory for screenshots (for testing without S3)")
15621729
parser.add_argument("--headless", action="store_true", help="Run in headless mode")
15631730
parser.add_argument("--browser-channel", help="Browser channel (chrome, msedge, firefox)")
15641731
parser.add_argument("--user-data-dir", help="User data directory for profile")
@@ -1580,6 +1747,10 @@ async def main():
15801747
user_data_dir=Path(args.user_data_dir) if args.user_data_dir else None,
15811748
)
15821749

1750+
# Set local screenshot directory for testing
1751+
if args.local_screenshots:
1752+
executor.local_screenshot_dir = args.local_screenshots
1753+
15831754
# Execute script
15841755
result = await executor.execute_script(script)
15851756

lambda/tools/local-browser-agent/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ tokio = { version = "1.35", features = ["full"] }
3232
# AWS SDK - use defaults like local-agent (includes proper TLS support)
3333
aws-config = "1.5"
3434
aws-sdk-sfn = "1.12"
35+
aws-sdk-s3 = "1.12"
3536
anyhow = "1.0"
3637
thiserror = "1.0"
3738
log = "0.4"

0 commit comments

Comments
 (0)