Skip to content

Commit 7e6479f

Browse files
committed
address risks identified in plan doc
1 parent 7006474 commit 7e6479f

17 files changed

+438
-90
lines changed

examples/click_rect_demo.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ def main():
3737
print(" Clicking at center of element's bbox...")
3838
result = click_rect(
3939
browser,
40-
{"x": link.bbox.x, "y": link.bbox.y, "w": link.bbox.width, "h": link.bbox.height},
40+
{
41+
"x": link.bbox.x,
42+
"y": link.bbox.y,
43+
"w": link.bbox.width,
44+
"h": link.bbox.height,
45+
},
4146
)
4247
print(f" Result: success={result.success}, outcome={result.outcome}")
4348
print(f" URL changed: {result.url_changed}\n")

examples/test_local_llm_agent.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ def test_local_llm_basic():
6565
user_prompt = "What is the next step to achieve the goal?"
6666

6767
response = llm.generate(
68-
system_prompt=system_prompt, user_prompt=user_prompt, max_new_tokens=20, temperature=0.0
68+
system_prompt=system_prompt,
69+
user_prompt=user_prompt,
70+
max_new_tokens=20,
71+
temperature=0.0,
6972
)
7073

7174
print(f"Agent Response: {response.content}")

sentience/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from .recorder import Recorder, Trace, TraceStep, record
4747
from .screenshot import screenshot
4848
from .snapshot import snapshot
49-
from .tracer_factory import create_tracer
49+
from .tracer_factory import SENTIENCE_API_URL, create_tracer
5050
from .tracing import JsonlTraceSink, TraceEvent, Tracer, TraceSink
5151

5252
# Utilities (v0.12.0+)
@@ -112,6 +112,7 @@
112112
"CloudTraceSink",
113113
"TraceEvent",
114114
"create_tracer",
115+
"SENTIENCE_API_URL",
115116
# Utilities (v0.12.0+)
116117
"canonical_snapshot_strict",
117118
"canonical_snapshot_loose",

sentience/actions.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
33
"""
44

55
import time
6-
from typing import Any, Dict, Optional
76

87
from .browser import SentienceBrowser
98
from .models import ActionResult, BBox, Snapshot
109
from .snapshot import snapshot
1110

1211

13-
def click(
12+
def click( # noqa: C901
1413
browser: SentienceBrowser,
1514
element_id: int,
1615
use_mouse: bool = True,
@@ -141,7 +140,10 @@ def click(
141140
error=(
142141
None
143142
if success
144-
else {"code": "click_failed", "reason": "Element not found or not clickable"}
143+
else {
144+
"code": "click_failed",
145+
"reason": "Element not found or not clickable",
146+
}
145147
),
146148
)
147149

@@ -371,7 +373,10 @@ def click_rect(
371373
success=False,
372374
duration_ms=0,
373375
outcome="error",
374-
error={"code": "invalid_rect", "reason": "Rectangle width and height must be positive"},
376+
error={
377+
"code": "invalid_rect",
378+
"reason": "Rectangle width and height must be positive",
379+
},
375380
)
376381

377382
start_time = time.time()
@@ -426,6 +431,9 @@ def click_rect(
426431
error=(
427432
None
428433
if success
429-
else {"code": "click_failed", "reason": error_msg if not success else "Click failed"}
434+
else {
435+
"code": "click_failed",
436+
"reason": error_msg if not success else "Click failed",
437+
}
430438
),
431439
)

sentience/agent.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import re
77
import time
8-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
8+
from typing import TYPE_CHECKING, Any, Optional
99

1010
from .actions import click, press, type_text
1111
from .base_agent import BaseAgent
@@ -93,8 +93,11 @@ def __init__(
9393
# Step counter for tracing
9494
self._step_count = 0
9595

96-
def act(
97-
self, goal: str, max_retries: int = 2, snapshot_options: SnapshotOptions | None = None
96+
def act( # noqa: C901
97+
self,
98+
goal: str,
99+
max_retries: int = 2,
100+
snapshot_options: SnapshotOptions | None = None,
98101
) -> AgentActionResult:
99102
"""
100103
Execute a high-level goal using observe → think → act loop
@@ -116,9 +119,9 @@ def act(
116119
42
117120
"""
118121
if self.verbose:
119-
print(f"\n{'='*70}")
122+
print(f"\n{'=' * 70}")
120123
print(f"🤖 Agent Goal: {goal}")
121-
print(f"{'='*70}")
124+
print(f"{'=' * 70}")
122125

123126
# Generate step ID for tracing
124127
self._step_count += 1
@@ -460,7 +463,9 @@ def _execute_action(self, action_str: str, snap: Snapshot) -> dict[str, Any]:
460463

461464
# Parse TYPE(42, "hello world")
462465
elif match := re.match(
463-
r'TYPE\s*\(\s*(\d+)\s*,\s*["\']([^"\']*)["\']\s*\)', action_str, re.IGNORECASE
466+
r'TYPE\s*\(\s*(\d+)\s*,\s*["\']([^"\']*)["\']\s*\)',
467+
action_str,
468+
re.IGNORECASE,
464469
):
465470
element_id = int(match.group(1))
466471
text = match.group(2)
@@ -486,7 +491,11 @@ def _execute_action(self, action_str: str, snap: Snapshot) -> dict[str, Any]:
486491

487492
# Parse FINISH()
488493
elif re.match(r"FINISH\s*\(\s*\)", action_str, re.IGNORECASE):
489-
return {"success": True, "action": "finish", "message": "Task marked as complete"}
494+
return {
495+
"success": True,
496+
"action": "finish",
497+
"message": "Task marked as complete",
498+
}
490499

491500
else:
492501
raise ValueError(

sentience/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ def main():
104104
"--snapshots", action="store_true", help="Capture snapshots at each step"
105105
)
106106
record_parser.add_argument(
107-
"--mask", action="append", help="Pattern to mask in recorded text (e.g., password)"
107+
"--mask",
108+
action="append",
109+
help="Pattern to mask in recorded text (e.g., password)",
108110
)
109111
record_parser.set_defaults(func=cmd_record)
110112

sentience/cloud_tracing.py

Lines changed: 77 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,32 @@
77
import gzip
88
import json
99
import os
10-
import tempfile
10+
import threading
11+
from collections.abc import Callable
12+
from pathlib import Path
1113
from typing import Any
1214

1315
import requests
1416

15-
from sentience.tracing import TraceEvent, TraceSink
17+
from sentience.tracing import TraceSink
1618

1719

1820
class CloudTraceSink(TraceSink):
1921
"""
2022
Enterprise Cloud Sink: "Local Write, Batch Upload" pattern.
2123
2224
Architecture:
23-
1. **Local Buffer**: Writes to temp file (zero latency, non-blocking)
25+
1. **Local Buffer**: Writes to persistent cache directory (zero latency, non-blocking)
2426
2. **Pre-signed URL**: Uses secure pre-signed PUT URL from backend API
2527
3. **Batch Upload**: Uploads complete file on close() or at intervals
2628
4. **Zero Credential Exposure**: Never embeds DigitalOcean credentials in SDK
29+
5. **Crash Recovery**: Traces survive process crashes (stored in ~/.sentience/traces/pending/)
2730
2831
This design ensures:
2932
- Fast agent performance (microseconds per emit, not milliseconds)
3033
- Security (credentials stay on backend)
3134
- Reliability (network issues don't crash the agent)
35+
- Data durability (traces survive crashes and can be recovered)
3236
3337
Tiered Access:
3438
- Free Tier: Falls back to JsonlTraceSink (local-only)
@@ -39,36 +43,40 @@ class CloudTraceSink(TraceSink):
3943
>>> from sentience.tracing import Tracer
4044
>>> # Get upload URL from API
4145
>>> upload_url = "https://sentience.nyc3.digitaloceanspaces.com/..."
42-
>>> sink = CloudTraceSink(upload_url)
46+
>>> sink = CloudTraceSink(upload_url, run_id="demo")
4347
>>> tracer = Tracer(run_id="demo", sink=sink)
4448
>>> tracer.emit_run_start("SentienceAgent")
4549
>>> tracer.close() # Uploads to cloud
50+
>>> # Or non-blocking:
51+
>>> tracer.close(blocking=False) # Returns immediately
4652
"""
4753

48-
def __init__(self, upload_url: str):
54+
def __init__(self, upload_url: str, run_id: str):
4955
"""
5056
Initialize cloud trace sink.
5157
5258
Args:
5359
upload_url: Pre-signed PUT URL from Sentience API
5460
(e.g., "https://sentience.nyc3.digitaloceanspaces.com/...")
61+
run_id: Unique identifier for this agent run (used for persistent cache)
5562
"""
5663
self.upload_url = upload_url
64+
self.run_id = run_id
5765

58-
# Create temporary file for buffering
59-
# delete=False so we can read it back before uploading
60-
self._temp_file = tempfile.NamedTemporaryFile(
61-
mode="w+",
62-
encoding="utf-8",
63-
suffix=".jsonl",
64-
delete=False,
65-
)
66-
self._path = self._temp_file.name
66+
# Use persistent cache directory instead of temp file
67+
# This ensures traces survive process crashes
68+
cache_dir = Path.home() / ".sentience" / "traces" / "pending"
69+
cache_dir.mkdir(parents=True, exist_ok=True)
70+
71+
# Persistent file (survives process crash)
72+
self._path = cache_dir / f"{run_id}.jsonl"
73+
self._trace_file = open(self._path, "w", encoding="utf-8")
6774
self._closed = False
75+
self._upload_successful = False
6876

6977
def emit(self, event: dict[str, Any]) -> None:
7078
"""
71-
Write event to local temp file (Fast, non-blocking).
79+
Write event to local persistent file (Fast, non-blocking).
7280
7381
Performance: ~10 microseconds per write vs ~50ms for HTTP request
7482
@@ -79,31 +87,68 @@ def emit(self, event: dict[str, Any]) -> None:
7987
raise RuntimeError("CloudTraceSink is closed")
8088

8189
json_str = json.dumps(event, ensure_ascii=False)
82-
self._temp_file.write(json_str + "\n")
83-
self._temp_file.flush() # Ensure written to disk
84-
85-
def close(self) -> None:
90+
self._trace_file.write(json_str + "\n")
91+
self._trace_file.flush() # Ensure written to disk
92+
93+
def close(
94+
self,
95+
blocking: bool = True,
96+
on_progress: Callable[[int, int], None] | None = None,
97+
) -> None:
8698
"""
8799
Upload buffered trace to cloud via pre-signed URL.
88100
101+
Args:
102+
blocking: If False, returns immediately and uploads in background thread
103+
on_progress: Optional callback(uploaded_bytes, total_bytes) for progress updates
104+
89105
This is the only network call - happens once at the end.
90106
"""
91107
if self._closed:
92108
return
93109

94110
self._closed = True
95111

112+
# Close file first
113+
self._trace_file.close()
114+
115+
if not blocking:
116+
# Fire-and-forget background upload
117+
thread = threading.Thread(
118+
target=self._do_upload,
119+
args=(on_progress,),
120+
daemon=True,
121+
)
122+
thread.start()
123+
return # Return immediately
124+
125+
# Blocking mode
126+
self._do_upload(on_progress)
127+
128+
def _do_upload(self, on_progress: Callable[[int, int], None] | None = None) -> None:
129+
"""
130+
Internal upload method with progress tracking.
131+
132+
Args:
133+
on_progress: Optional callback(uploaded_bytes, total_bytes) for progress updates
134+
"""
96135
try:
97-
# 1. Close temp file
98-
self._temp_file.close()
136+
# Read file size for progress
137+
file_size = os.path.getsize(self._path)
138+
139+
if on_progress:
140+
on_progress(0, file_size)
99141

100-
# 2. Compress for upload
142+
# Read and compress
101143
with open(self._path, "rb") as f:
102144
trace_data = f.read()
103145

104146
compressed_data = gzip.compress(trace_data)
105147

106-
# 3. Upload to DigitalOcean Spaces via pre-signed URL
148+
if on_progress:
149+
on_progress(len(compressed_data), file_size)
150+
151+
# Upload to DigitalOcean Spaces via pre-signed URL
107152
print(f"📤 [Sentience] Uploading trace to cloud ({len(compressed_data)} bytes)...")
108153

109154
response = requests.put(
@@ -117,27 +162,26 @@ def close(self) -> None:
117162
)
118163

119164
if response.status_code == 200:
165+
self._upload_successful = True
120166
print("✅ [Sentience] Trace uploaded successfully")
167+
# Delete file only on successful upload
168+
if os.path.exists(self._path):
169+
try:
170+
os.remove(self._path)
171+
except Exception:
172+
pass # Ignore cleanup errors
121173
else:
174+
self._upload_successful = False
122175
print(f"❌ [Sentience] Upload failed: HTTP {response.status_code}")
123176
print(f" Response: {response.text}")
124177
print(f" Local trace preserved at: {self._path}")
125178

126179
except Exception as e:
180+
self._upload_successful = False
127181
print(f"❌ [Sentience] Error uploading trace: {e}")
128182
print(f" Local trace preserved at: {self._path}")
129183
# Don't raise - preserve trace locally even if upload fails
130184

131-
finally:
132-
# 4. Cleanup temp file (only if upload succeeded)
133-
if os.path.exists(self._path):
134-
try:
135-
# Only delete if upload was successful
136-
if hasattr(self, "_upload_successful") and self._upload_successful:
137-
os.remove(self._path)
138-
except Exception:
139-
pass # Ignore cleanup errors
140-
141185
def __enter__(self):
142186
"""Context manager support."""
143187
return self

0 commit comments

Comments
 (0)