-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
1819 lines (1524 loc) · 73.9 KB
/
Copy pathmain.py
File metadata and controls
1819 lines (1524 loc) · 73.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Zero-Trust Multi-Agent E-commerce Price Monitoring System
This demo showcases CrewAI multi-agent orchestration secured with
predicate-secure SDK for runtime trust enforcement.
Architecture:
- Orchestrator: Gets MULTI-SCOPE root mandate covering browser.* + fs.* in ONE mandate
- Web Scraper Agent: Receives delegated mandate (browser.* scope from parent)
- Analyst Agent: Receives delegated mandate (fs.* scope from same parent)
- Multi-scope mandate enables:
• Unified audit trail (single mandate = single audit entry)
• Cascade revocation (revoking orchestrator mandate revokes all children)
• Simpler code (one mandate to track instead of N separate mandates)
- Cloud tracer uploads execution traces to Predicate Studio (if PREDICATE_API_KEY set)
Chain Delegation Flow (Multi-Scope):
┌─────────────────────────────────────────────────────────────────────────┐
│ POST /v1/authorize (MULTI-SCOPE root mandate) │
│ Orchestrator scopes: [{browser.*, https://...}, {fs.*, workspace}] │
│ mandate_token: eyJhbGci... (depth=0, TTL=300s) │
│ scopes_authorized: [{action: browser.*, ...}, {action: fs.*, ...}] │
└───────────────────────────────┬─────────────────────────────────────────┘
│
┌───────────────────────┴───────────────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────────┐
│ POST /v1/delegate │ │ POST /v1/delegate │
│ parent: SAME mandate │ │ parent: SAME mandate │
│ target: agent:scraper│ │ target: agent:analyst │
│ scope: browser.* │ │ scope: fs.* │
│ https://... │ │ workspace/... │
│ (matches browser.*) │ │ (matches fs.*) │
└───────────────────────┘ └───────────────────────────┘
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────────┐
│ Derived Mandate │ │ Derived Mandate │
│ depth=1, TTL≤300s │ │ depth=1, TTL≤300s │
│ chain_hash: abc123 │ │ chain_hash: def456 │
└───────────────────────┘ └───────────────────────────┘
Key difference: Both child delegations use the SAME parent mandate token.
Child scope is validated against ALL parent scopes (OR semantics).
Usage:
# Start the sidecar first (with optional control plane registration)
predicate-authorityd --policy-file policies/monitoring.yaml run
# Or with control plane for fleet management:
predicate-authorityd \
--policy-file policies/monitoring.yaml \
--mode cloud_connected \
--control-plane-url https://api.predicatesystems.dev \
--predicate-api-key $PREDICATE_API_KEY \
--tenant-id $TENANT_ID \
--project-id $PROJECT_ID \
--sync-enabled \
run
# Run the demo (with chain delegation)
python main.py --products "laptop,monitor" --use-delegation
# Run without delegation (direct authorization)
python main.py --products "laptop,monitor"
# Use different LLM providers
python main.py --products "laptop" --llm deepinfra # DeepInfra cloud (default)
python main.py --products "laptop" --llm ollama # Local Ollama
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import uuid
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
import requests
from crewai import Agent, Crew, Process, Task
from crewai import LLM
from crewai.tools import tool
# Import predicate-secure SDK
from predicate_secure import SecureAgent
# Import Predicate SDK for browser automation and snapshots
try:
from predicate import (
PredicateBrowser, # Sync browser for CrewAI tool compatibility
PredicateDebugger,
url_contains,
exists,
find, # Query function for finding elements in snapshots
snapshot, # Sync snapshot function
)
from predicate.models import ScreenshotConfig, SnapshotOptions
from predicate.tracer_factory import create_tracer
from predicate.trace_event_builder import TraceEventBuilder
from predicate.llm_interaction_handler import LLMInteractionHandler
PREDICATE_SDK_AVAILABLE = True
TRACER_AVAILABLE = True
except ImportError:
PREDICATE_SDK_AVAILABLE = False
TRACER_AVAILABLE = False
PredicateBrowser = None
PredicateDebugger = None
ScreenshotConfig = None
SnapshotOptions = None
create_tracer = None
TraceEventBuilder = None
LLMInteractionHandler = None
url_contains = None
exists = None
find = None
snapshot = None
# =============================================================================
# Browser Configuration
# =============================================================================
# Browser settings for Playwright-based scraping
HEADLESS = True # Run browser in headless mode (no GUI)
SCREENSHOT_FORMAT = "jpeg" # Screenshot format: "jpeg" or "png"
SCREENSHOT_QUALITY = 60 # JPEG quality (1-100)
BROWSER_TIMEOUT_MS = 30000 # Page load timeout in milliseconds
# Global browser instance (initialized in main)
# Using Optional for Python 3.9 compatibility
from typing import Optional, Any, List, Dict
from dataclasses import dataclass, field
_browser_instance: Optional[Any] = None # AsyncPredicateBrowser
_debugger_instance: Optional[Any] = None # PredicateDebugger
_page_instance: Optional[Any] = None # Playwright Page
_tracer_instance: Optional[Any] = None # Tracer for emitting step data
# =============================================================================
# Chain Delegation Client
# =============================================================================
@dataclass
class DelegateResponse:
"""Response from POST /v1/delegate or /v1/authorize endpoint."""
mandate_token: str
mandate_id: str
expires_at: int
delegation_depth: int
delegation_chain_hash: str
# For multi-scope mandates
scopes_authorized: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class DelegationClient:
"""
HTTP client for chain delegation via the predicate-authorityd sidecar.
Implements the delegation flow from the architecture diagram:
Orchestrator (root mandate) → POST /v1/delegate → Derived mandates for agents
Example:
>>> client = DelegationClient("http://127.0.0.1:8787")
>>> # Get root mandate for orchestrator
>>> root = await client.authorize_root("agent:orchestrator", "browser.*", "workspace/**")
>>> # Delegate narrower scope to scraper
>>> scraper_mandate = await client.delegate(
... parent_mandate_token=root.mandate_token,
... target_agent_id="agent:scraper",
... requested_action="browser.navigate",
... requested_resource="https://www.amazon.com/*",
... )
"""
base_url: str = "http://127.0.0.1:8787"
timeout_s: float = 5.0
async def authorize_root(
self,
principal: str,
action: str,
resource: str,
intent_hash: Optional[str] = None,
) -> DelegateResponse:
"""
Get root mandate (depth=0) for the orchestrator (single-scope).
Args:
principal: The orchestrator principal ID (e.g., "agent:orchestrator")
action: Broad action scope (e.g., "browser.*" or "*")
resource: Broad resource scope (e.g., "workspace/**" or "*")
intent_hash: Optional intent hash
Returns:
DelegateResponse with root mandate token
"""
import httpx
request_body = {
"principal": principal,
"action": action,
"resource": resource,
"intent_hash": intent_hash or f"root:{principal}:{action}:{resource}",
"labels": [],
}
async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout_s) as client:
response = await client.post("/v1/authorize", json=request_body)
if response.status_code == 403:
data = response.json()
raise RuntimeError(f"Root authorization denied: {data.get('reason', 'unknown')}")
if not response.is_success:
raise RuntimeError(f"Root authorization failed: {response.status_code} - {response.text}")
data = response.json()
if not data.get("allowed", False):
raise RuntimeError(f"Root authorization denied: {data.get('reason', 'unknown')}")
# Note: /v1/authorize returns mandate_id, but for delegation we need
# the full mandate_token. In the real sidecar, authorize returns the token.
return DelegateResponse(
mandate_token=data.get("mandate_token", data.get("mandate_id", "")),
mandate_id=data.get("mandate_id", ""),
expires_at=data.get("expires_at", 0),
delegation_depth=0,
delegation_chain_hash=data.get("delegation_chain_hash", "root"),
)
async def authorize_root_multi_scope(
self,
principal: str,
scopes: List[Dict[str, str]],
intent_hash: Optional[str] = None,
) -> DelegateResponse:
"""
Get root mandate (depth=0) for the orchestrator with multiple scopes.
This allows a single mandate to cover multiple action/resource pairs,
enabling unified audit trails and cascade revocation.
Args:
principal: The orchestrator principal ID (e.g., "agent:orchestrator")
scopes: List of scope dicts, each with "action" and "resource" keys
e.g., [{"action": "browser.*", "resource": "https://..."},
{"action": "fs.*", "resource": "**/workspace/**"}]
intent_hash: Optional intent hash
Returns:
DelegateResponse with root mandate token covering all scopes
"""
import httpx
request_body = {
"principal": principal,
"scopes": scopes,
"intent_hash": intent_hash or f"root:{principal}:multi-scope",
"labels": [],
}
async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout_s) as client:
response = await client.post("/v1/authorize", json=request_body)
if response.status_code == 403:
data = response.json()
raise RuntimeError(f"Root authorization denied: {data.get('reason', 'unknown')}")
if not response.is_success:
raise RuntimeError(f"Root authorization failed: {response.status_code} - {response.text}")
data = response.json()
if not data.get("allowed", False):
raise RuntimeError(f"Root authorization denied: {data.get('reason', 'unknown')}")
return DelegateResponse(
mandate_token=data.get("mandate_token", data.get("mandate_id", "")),
mandate_id=data.get("mandate_id", ""),
expires_at=data.get("expires_at", 0),
delegation_depth=0,
delegation_chain_hash=data.get("delegation_chain_hash", "root"),
scopes_authorized=data.get("scopes_authorized", []),
)
async def delegate(
self,
parent_mandate_token: str,
target_agent_id: str,
requested_action: str,
requested_resource: str,
intent_hash: Optional[str] = None,
ttl_seconds: Optional[int] = None,
) -> DelegateResponse:
"""
Delegate authority from parent mandate to a child agent.
The sidecar validates:
1. Parent mandate signature is valid
2. Parent mandate is not expired or revoked
3. Requested scope is a subset of parent's scope
4. Delegation depth does not exceed maximum
Args:
parent_mandate_token: The parent's mandate JWT token
target_agent_id: The child agent's principal ID
requested_action: Narrower action scope for the child
requested_resource: Narrower resource scope for the child
intent_hash: Optional intent hash
ttl_seconds: Optional TTL (capped to parent's remaining TTL)
Returns:
DelegateResponse with derived mandate token
"""
import httpx
request_body = {
"parent_mandate_token": parent_mandate_token,
"target_agent_id": target_agent_id,
"requested_action": requested_action,
"requested_resource": requested_resource,
"intent_hash": intent_hash or f"delegate:{target_agent_id}:{requested_action}",
}
if ttl_seconds is not None:
request_body["ttl_seconds"] = ttl_seconds
async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout_s) as client:
response = await client.post("/v1/delegate", json=request_body)
if response.status_code == 403:
data = response.json()
code = data.get("code", "unknown")
message = data.get("message", "Delegation denied")
raise RuntimeError(f"Delegation denied [{code}]: {message}")
if not response.is_success:
raise RuntimeError(f"Delegation failed: {response.status_code} - {response.text}")
data = response.json()
return DelegateResponse(
mandate_token=data["mandate_token"],
mandate_id=data["mandate_id"],
expires_at=data["expires_at"],
delegation_depth=data["delegation_depth"],
delegation_chain_hash=data["delegation_chain_hash"],
)
# Global delegation state (set when --use-delegation is enabled)
_delegation_client: Optional[DelegationClient] = None
_root_mandate: Optional[DelegateResponse] = None
_scraper_mandate: Optional[DelegateResponse] = None
_analyst_mandate: Optional[DelegateResponse] = None
def _build_compact_context(snapshot, goal: Optional[str] = None) -> Optional[str]:
"""
Build compact DOM context from snapshot using LLMInteractionHandler.
Format: [ID] <role> "text" {cues} @ (x,y) size:WxH importance:score [status]
Example: [346] <button> "Add to Cart" {CLICKABLE,color:orange} @ (664,100) size:150x40 importance:811
Args:
snapshot: Snapshot object from PredicateDebugger
goal: Optional goal string for context
Returns:
Compact DOM context string, or None if unavailable
"""
if snapshot is None:
return None
if LLMInteractionHandler is None:
return None
try:
# LLMInteractionHandler.build_context is a static-like method that only needs snapshot
# We create a dummy handler just to use its build_context method
# Note: build_context doesn't actually use the LLM, just formats elements
class _DummyProvider:
pass
handler = LLMInteractionHandler(_DummyProvider())
compact_context = handler.build_context(snapshot, goal)
return compact_context
except Exception as e:
print(f"[warn] compact context build failed: {e}", flush=True)
return None
def _emit_snapshot_trace(
tracer,
snapshot,
step_id: Optional[str],
step_index: Optional[int],
compact_context: Optional[str] = None,
) -> None:
"""
Emit a snapshot trace event with screenshot payload for Studio.
This sends step data including DOM snapshot, screenshot, and compact DOM context
to the tracer, which uploads it to Predicate Studio for debugging and observability.
Args:
tracer: Tracer instance for emitting events
snapshot: Snapshot object with elements and screenshot
step_id: Step ID for correlation
step_index: Step index in sequence
compact_context: Optional compact DOM context string from LLMInteractionHandler
"""
if tracer is None or snapshot is None:
return
if TraceEventBuilder is None:
return
try:
data = TraceEventBuilder.build_snapshot_event(snapshot, step_index=step_index)
screenshot_raw = getattr(snapshot, "screenshot", None)
if screenshot_raw:
# Extract base64 string from data URL if needed
# Format: "data:image/jpeg;base64,{base64_string}"
if screenshot_raw.startswith("data:image"):
screenshot_base64 = (
screenshot_raw.split(",", 1)[1]
if "," in screenshot_raw
else screenshot_raw
)
else:
screenshot_base64 = screenshot_raw
data["screenshot_base64"] = screenshot_base64
data["screenshot_format"] = SCREENSHOT_FORMAT
else:
print("[warn] snapshot has no screenshot", flush=True)
# Add compact DOM context for LLM-friendly element representation
if compact_context:
data["compact_context"] = compact_context
# Also add element count for quick reference
element_count = len(getattr(snapshot, "elements", []))
data["element_count"] = element_count
tracer.emit("snapshot", data=data, step_id=step_id)
except Exception:
# Silently fail like the reference implementation
return
# =============================================================================
# Tracer Configuration (Cloud or Local)
# =============================================================================
class _TraceLogger:
"""Simple logger for tracer messages."""
def info(self, message: str) -> None:
print(f"[trace] {message}", flush=True)
def warning(self, message: str) -> None:
print(f"[trace][warn] {message}", flush=True)
def error(self, message: str) -> None:
print(f"[trace][error] {message}", flush=True)
def create_demo_tracer(
run_id: str,
goal: str,
llm_model: str,
products: list[str],
):
"""
Create a tracer for uploading execution traces to Predicate Studio.
If PREDICATE_API_KEY is set, traces are uploaded to the cloud.
Otherwise, traces are saved locally to workspace/data/traces/.
"""
if not TRACER_AVAILABLE:
print("[trace] predicate SDK not available, skipping tracer setup")
return None
predicate_api_key = os.getenv("PREDICATE_API_KEY")
if predicate_api_key:
print("[trace] PREDICATE_API_KEY found - traces will upload to Predicate Studio")
upload_trace = True
else:
print("[trace] No PREDICATE_API_KEY - traces will be saved locally")
upload_trace = False
tracer = create_tracer(
api_key=predicate_api_key or "local",
run_id=run_id,
upload_trace=upload_trace,
goal=goal,
logger=_TraceLogger(),
agent_type="crewai-ecommerce-demo",
llm_model=llm_model,
start_url=f"products: {', '.join(products)}",
)
return tracer
# =============================================================================
# Browser Lifecycle Management
# =============================================================================
def init_browser_sync(
tracer,
predicate_api_key: Optional[str] = None,
allowed_domains: Optional[list] = None,
) -> tuple:
"""
Initialize sync PredicateBrowser for CrewAI tool compatibility.
Uses sync PredicateBrowser instead of async to work with CrewAI's sync tool decorator.
Returns:
Tuple of (browser, page, None) # No debugger in sync mode for now
"""
global _browser_instance, _page_instance, _tracer_instance
# Store tracer reference for step data emission
_tracer_instance = tracer
if not PREDICATE_SDK_AVAILABLE:
print("[browser] Predicate SDK not available, falling back to requests-based scraping")
return None, None, None
if allowed_domains is None:
allowed_domains = ["amazon.com", "bestbuy.com", "walmart.com", "newegg.com", "target.com"]
# Initialize sync browser
browser = PredicateBrowser(
api_key=predicate_api_key or "local",
headless=HEADLESS,
allowed_domains=allowed_domains,
)
browser.start()
page = browser.page
if page is None:
raise RuntimeError("PredicateBrowser did not create a page.")
_browser_instance = browser
_page_instance = page
print(f"[browser] Initialized PredicateBrowser (sync, headless={HEADLESS})")
return browser, page, None
def close_browser_sync():
"""Close the sync browser instance."""
global _browser_instance, _page_instance
if _browser_instance:
try:
_browser_instance.close()
print("[browser] Browser closed")
except Exception as e:
print(f"[browser] Error closing browser: {e}")
_browser_instance = None
_page_instance = None
_debugger_instance = None
# =============================================================================
# LLM Configuration (DeepInfra or Ollama - not OpenAI)
# =============================================================================
# Supported LLM providers
# Note: Using Llama 3.1 70B as it's more reliable than Qwen for tool-calling
LLM_PROVIDERS = {
"deepinfra": {
"model": "meta-llama/Meta-Llama-3.1-70B-Instruct",
"base_url": "https://api.deepinfra.com/v1/openai",
"env_key": "DEEPINFRA_API_KEY",
"description": "DeepInfra cloud (requires DEEPINFRA_API_KEY)",
},
"ollama": {
"model": "ollama/qwen2.5:7b",
"base_url": None, # Will use OLLAMA_HOST env var or default to localhost
"env_key": None,
"description": "Local Ollama (requires `ollama serve`)",
},
}
def get_llm(provider: str = "auto") -> LLM:
"""
Configure LLM using the specified provider.
Args:
provider: LLM provider choice - "deepinfra", "ollama", or "auto"
"auto" uses DeepInfra if API key is set, otherwise Ollama
Returns:
Configured CrewAI LLM instance with retry logic for API failures
"""
# Auto-detect provider based on available credentials
if provider == "auto":
if os.getenv("DEEPINFRA_API_KEY"):
provider = "deepinfra"
else:
provider = "ollama"
print("[LLM] No DEEPINFRA_API_KEY found, falling back to Ollama")
if provider not in LLM_PROVIDERS:
raise ValueError(f"Unknown LLM provider: {provider}. Choose from: {list(LLM_PROVIDERS.keys())}")
config = LLM_PROVIDERS[provider]
# Resolve base_url - for Ollama, use OLLAMA_HOST env var (set by docker-compose)
base_url = config["base_url"]
if base_url is None and provider == "ollama":
base_url = os.getenv("OLLAMA_HOST", "http://localhost:11434")
print(f"[LLM] Ollama base_url: {base_url}")
# Build LLM kwargs with retry configuration for resilience
# Handles: empty LLM responses, rate limiting, API quota issues, network timeouts
llm_kwargs = {
"model": config["model"],
"base_url": base_url,
"temperature": 0.1,
# Retry configuration for API resilience
"num_retries": 5, # Retry up to 5 times on failure
"timeout": 180, # 3 minute timeout per request (large models can be slow)
"max_tokens": 4096, # Ensure we request enough tokens for response
}
# Add API key if required
if config["env_key"]:
api_key = os.getenv(config["env_key"])
if not api_key:
raise ValueError(
f"LLM provider '{provider}' requires {config['env_key']} environment variable. "
f"Set it with: export {config['env_key']}=your-api-key"
)
llm_kwargs["api_key"] = api_key
return LLM(**llm_kwargs)
# =============================================================================
# Custom Tools
# =============================================================================
def _navigate_with_browser(url: str) -> str:
"""
Navigate to a product page using sync PredicateBrowser.
Uses snapshot() for DOM capture with find() for element extraction.
"""
global _browser_instance, _page_instance, _tracer_instance
if _page_instance is None:
return "ERROR: Browser not initialized. Call init_browser_sync() first."
page = _page_instance
browser = _browser_instance
tracer = _tracer_instance
verification_results = []
try:
# Navigate using sync Playwright page
page.goto(url, wait_until="domcontentloaded", timeout=BROWSER_TIMEOUT_MS)
final_url = page.url
# Take snapshot using sync snapshot() function
if snapshot is not None and browser is not None:
snap = snapshot(browser)
element_count = len(getattr(snap, "elements", []))
print(f"[snapshot] Navigate captured {element_count} elements")
# Build compact DOM context for LLM-friendly element representation
compact_context = _build_compact_context(snap, goal=f"navigate:{url}")
if compact_context:
print(f"[compact] Built compact DOM context: {element_count} elements")
print(f"[compact] --- Compact DOM Context ---")
# Print first 80 lines to keep logs readable
context_lines = compact_context.split('\n')
for line in context_lines[:80]:
print(f"[compact] {line}")
if len(context_lines) > 80:
print(f"[compact] ... ({len(context_lines) - 80} more lines)")
print(f"[compact] --- End Compact DOM Context ---")
# Emit step data to tracer for Studio (includes compact context)
_emit_snapshot_trace(
tracer,
snap,
None, # No step_id in sync mode
0, # step_index
compact_context=compact_context,
)
# Verify using find() on snapshot
if "amazon.com" in url:
url_check = "/dp/" in final_url or "/gp/product/" in final_url
verification_results.append(f"url_contains(/dp/): {'PASS' if url_check else 'FAIL'}")
# Check for product title using find()
title_el = find(snap, "role=heading") if find else None
verification_results.append(f"find(role=heading): {'PASS' if title_el else 'FAIL'}")
# Check for price element using find()
price_el = find(snap, "text~'$'") if find else None
verification_results.append(f"find(text~'$'): {'PASS' if price_el else 'FAIL'}")
elif "bestbuy.com" in url:
url_check = "/site/" in final_url
verification_results.append(f"url_contains(/site/): {'PASS' if url_check else 'FAIL'}")
elif "walmart.com" in url:
url_check = "/ip/" in final_url
verification_results.append(f"url_contains(/ip/): {'PASS' if url_check else 'FAIL'}")
else:
verification_results.append(f"navigation: PASS (final_url={final_url})")
all_passed = all("PASS" in r or "SKIP" in r for r in verification_results)
if all_passed:
return f"SUCCESS: Navigated to {url}\nFinal URL: {final_url}\nVerification:\n" + "\n".join(verification_results)
else:
return f"WARNING: Navigated but verification issues at {url}\nFinal URL: {final_url}\nVerification:\n" + "\n".join(verification_results)
except Exception as e:
return f"ERROR: Browser navigation failed for {url}: {str(e)}"
def _navigate_with_requests(url: str) -> str:
"""
Navigate to a product page using requests (sync fallback).
"""
try:
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
}
response = requests.get(url, headers=headers, timeout=10, allow_redirects=True)
verification_results = []
final_url = response.url
if "amazon.com" in url:
url_check = "/dp/" in final_url or "/gp/product/" in final_url
verification_results.append(f"url_contains(/dp/): {'PASS' if url_check else 'FAIL'}")
elif "bestbuy.com" in url:
url_check = "/site/" in final_url
verification_results.append(f"url_contains(/site/): {'PASS' if url_check else 'FAIL'}")
elif "walmart.com" in url:
url_check = "/ip/" in final_url
verification_results.append(f"url_contains(/ip/): {'PASS' if url_check else 'FAIL'}")
else:
verification_results.append("url_contains: SKIP (no pattern for domain)")
status_ok = response.status_code == 200
verification_results.append(f"http_status(200): {'PASS' if status_ok else f'FAIL ({response.status_code})'}")
html_content = response.text
if "amazon.com" in url:
title_exists = 'id="productTitle"' in html_content or 'id="title"' in html_content
verification_results.append(f"exists(#productTitle): {'PASS' if title_exists else 'FAIL'}")
is_404_page = "Sorry, we couldn't find that page" in html_content or \
"looking for something" in html_content.lower() and "dogs" in html_content.lower()
if is_404_page:
verification_results.append("not_exists(404_page): FAIL - Product not found")
return f"ERROR: Product page not found at {url}\nVerification:\n" + "\n".join(verification_results)
price_exists = 'class="a-price' in html_content or 'id="priceblock' in html_content or \
'a-offscreen' in html_content
verification_results.append(f"exists(.a-price): {'PASS' if price_exists else 'FAIL (may be CAPTCHA)'}")
elif "bestbuy.com" in url:
title_exists = 'class="sku-title"' in html_content or 'class="heading-5"' in html_content
verification_results.append(f"exists(.sku-title): {'PASS' if title_exists else 'FAIL'}")
elif "walmart.com" in url:
title_exists = 'itemprop="name"' in html_content
verification_results.append(f"exists([itemprop=name]): {'PASS' if title_exists else 'FAIL'}")
all_passed = all("PASS" in r or "SKIP" in r for r in verification_results)
if all_passed:
return f"SUCCESS: Navigated to {url}\nFinal URL: {final_url}\nVerification:\n" + "\n".join(verification_results)
else:
return f"WARNING: Navigated but verification issues at {url}\nFinal URL: {final_url}\nVerification:\n" + "\n".join(verification_results)
except requests.Timeout:
return f"ERROR: Request timeout for {url}"
except requests.RequestException as e:
return f"ERROR: Failed to navigate to {url}: {str(e)}"
@tool
def navigate_to_product(url: str) -> str:
"""
Navigate to a product page on an approved e-commerce site.
Args:
url: The product URL to navigate to (must be from approved domain)
Returns:
Status message indicating success or failure with verification details
"""
# Pre-execution: Domain allowlist check
approved_domains = [
"amazon.com",
"bestbuy.com",
"walmart.com",
"newegg.com",
"target.com",
]
domain_match = any(domain in url for domain in approved_domains)
if not domain_match:
return f"ERROR: Domain not in approved list. URL: {url}"
# Use sync PredicateBrowser if initialized, otherwise fall back to requests
if _browser_instance is not None and _page_instance is not None:
try:
return _navigate_with_browser(url)
except Exception as e:
print(f"[navigate] Browser failed, falling back to requests: {e}")
return _navigate_with_requests(url)
else:
return _navigate_with_requests(url)
def _extract_with_browser(url: str) -> str:
"""
Extract price data using sync PredicateBrowser with find() on snapshots.
Uses snapshot() for DOM capture with find() for semantic element queries.
Emits step data to tracer for Predicate Studio observability.
"""
global _browser_instance, _page_instance, _tracer_instance
if _page_instance is None:
return json.dumps({"url": url, "error": "Browser not initialized"}, indent=2)
page = _page_instance
browser = _browser_instance
tracer = _tracer_instance
verification_results = []
extracted_data = {
"url": url,
"final_url": page.url,
"timestamp": datetime.now().isoformat(),
"product_name": None,
"price": None,
"currency": "USD",
"availability": None,
"verification": {},
"snapshot_captured": False,
"extraction_method": "predicate_find",
}
try:
# Take snapshot using sync snapshot() function
if snapshot is not None and browser is not None:
snap = snapshot(browser)
element_count = len(getattr(snap, "elements", []))
extracted_data["element_count"] = element_count
extracted_data["snapshot_captured"] = True
print(f"[snapshot] Extract captured {element_count} elements")
# Build compact DOM context for LLM-friendly element representation
compact_context = _build_compact_context(snap, goal=f"extract:{url}")
if compact_context:
print(f"[compact] Built compact DOM context: {element_count} elements")
extracted_data["compact_element_count"] = element_count
print(f"[compact] --- Compact DOM Context (Extract) ---")
# Print first 80 lines to keep logs readable
context_lines = compact_context.split('\n')
for line in context_lines[:80]:
print(f"[compact] {line}")
if len(context_lines) > 80:
print(f"[compact] ... ({len(context_lines) - 80} more lines)")
print(f"[compact] --- End Compact DOM Context ---")
# Emit step data to tracer for Studio (includes compact context)
_emit_snapshot_trace(
tracer,
snap,
None, # No step_id in sync mode
1, # step_index
compact_context=compact_context,
)
# Extract data using find() on snapshot elements
if "amazon.com" in url and find is not None:
# Find product title using semantic query
title_el = find(snap, "role=heading") or find(snap, "text~'productTitle'")
if title_el:
extracted_data["product_name"] = title_el.text.strip() if title_el.text else None
verification_results.append(f"find(role=heading): PASS (id={title_el.id})")
else:
# Fallback: look for any prominent text element
for el in snap.elements:
if el.role == "heading" or (el.importance and el.importance > 500):
if el.text and len(el.text) > 10:
extracted_data["product_name"] = el.text.strip()
verification_results.append(f"find(importance>500): PASS (id={el.id})")
break
if not extracted_data["product_name"]:
verification_results.append("find(role=heading): FAIL")
# Find price using semantic query - look for text containing $
price_el = find(snap, "text~'$'")
if price_el and price_el.text:
price_match = re.search(r'\$?([\d,]+\.?\d*)', price_el.text)
if price_match:
extracted_data["price"] = float(price_match.group(1).replace(",", ""))
verification_results.append(f"find(text~'$'): PASS (${extracted_data['price']}, id={price_el.id})")
if extracted_data["price"] is None:
# Fallback: scan all elements for price pattern
for el in snap.elements:
if el.text and '$' in el.text:
price_match = re.search(r'\$([\d,]+\.?\d{2})', el.text)
if price_match:
extracted_data["price"] = float(price_match.group(1).replace(",", ""))
verification_results.append(f"find(text contains $): PASS (${extracted_data['price']}, id={el.id})")
break
if extracted_data["price"] is None:
verification_results.append("find(price): FAIL - No price found in snapshot")
# Check availability by scanning element text
for el in snap.elements:
if el.text:
text_lower = el.text.lower()
if "in stock" in text_lower:
extracted_data["availability"] = "In Stock"
verification_results.append(f"find(text~'In Stock'): PASS (id={el.id})")
break
elif "out of stock" in text_lower or "unavailable" in text_lower:
extracted_data["availability"] = "Out of Stock"
verification_results.append(f"find(text~'Out of Stock'): PASS (id={el.id})")
break
if not extracted_data["availability"]:
extracted_data["availability"] = "Unknown"
verification_results.append("find(availability): UNKNOWN")
elif "bestbuy.com" in url and find is not None:
title_el = find(snap, "role=heading")
if title_el and title_el.text:
extracted_data["product_name"] = title_el.text.strip()
verification_results.append(f"find(role=heading): PASS (id={title_el.id})")
price_el = find(snap, "text~'$'")
if price_el and price_el.text:
price_match = re.search(r'\$?([\d,]+\.?\d*)', price_el.text)
if price_match:
extracted_data["price"] = float(price_match.group(1).replace(",", ""))
verification_results.append(f"find(text~'$'): PASS (${extracted_data['price']})")
elif "walmart.com" in url and find is not None:
title_el = find(snap, "role=heading")
if title_el and title_el.text:
extracted_data["product_name"] = title_el.text.strip()
verification_results.append(f"find(role=heading): PASS (id={title_el.id})")
price_el = find(snap, "text~'$'")
if price_el and price_el.text:
price_match = re.search(r'([\d.]+)', price_el.text)
if price_match:
extracted_data["price"] = float(price_match.group(1))
verification_results.append(f"find(text~'$'): PASS (${extracted_data['price']})")
verification_results.append("response_not_empty: PASS")
else:
# No snapshot function, fall back to page.evaluate for extraction
extracted_data["extraction_method"] = "page_evaluate"
if "amazon.com" in url:
title_text = page.evaluate("() => document.querySelector('#productTitle')?.textContent?.trim() || ''")
if title_text:
extracted_data["product_name"] = title_text
verification_results.append("page.evaluate(#productTitle): PASS")
price_text = page.evaluate("() => document.querySelector('.a-price .a-offscreen')?.textContent?.trim() || ''")
if price_text:
price_match = re.search(r'\$?([\d,]+\.?\d*)', price_text)
if price_match:
extracted_data["price"] = float(price_match.group(1).replace(",", ""))