leashbyte 0.1.1__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.
leashbyte/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """LeashByte API package."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,488 @@
1
+ """Claim and execute bounded observe-act-observe agent runs."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ import time
7
+ import uuid
8
+ from contextlib import suppress
9
+ from datetime import datetime
10
+ from urllib.parse import urlsplit
11
+
12
+ from .config import Settings
13
+ from .errors import LeashByteError
14
+ from .internal import internal_request, response_json
15
+ from .model_adapter import ClaudeModel, LocalModel, observation_message
16
+ from .run_models import AgentAction
17
+ from .run_store import RunStore, json_value, utcnow
18
+ from .vault import https_page_origin, placeholder_parts
19
+
20
+ logger = logging.getLogger("leashbyte.worker")
21
+
22
+
23
+ def run_origin(raw_url: str) -> str:
24
+ """Return an exact HTTP or HTTPS origin for run policy."""
25
+ try:
26
+ parts = urlsplit(raw_url)
27
+ if (
28
+ parts.scheme.lower() not in {"http", "https"}
29
+ or not parts.hostname
30
+ or parts.username is not None
31
+ or parts.password is not None
32
+ ):
33
+ raise ValueError
34
+ host = parts.hostname.rstrip(".").encode("idna").decode("ascii").lower()
35
+ port = parts.port or (443 if parts.scheme.lower() == "https" else 80)
36
+ except (ValueError, UnicodeError) as error:
37
+ raise LeashByteError(422, "invalid_origin", "A run origin is not valid.") from error
38
+ rendered_host = f"[{host}]" if ":" in host else host
39
+ default_port = 443 if parts.scheme.lower() == "https" else 80
40
+ suffix = "" if port == default_port else f":{port}"
41
+ return f"{parts.scheme.lower()}://{rendered_host}{suffix}"
42
+
43
+
44
+ def contains_secret_shaped_text(value: str | None) -> bool:
45
+ """Block common secret markers even when a model uses the wrong action."""
46
+ return bool(
47
+ value
48
+ and re.search(r"(?i)(password|secret|credential|api[_-]?key|bearer\s|token\s*[=:])", value)
49
+ )
50
+
51
+
52
+ def validate_action(action: AgentAction, observation: dict, policy: dict, navigations: int) -> None:
53
+ """Reject stale, malformed, or out-of-policy model output before browser use."""
54
+ if action.snapshot_id != observation.get("snapshot_id"):
55
+ raise LeashByteError(409, "stale_snapshot", "The model used a stale snapshot.")
56
+ name = action.action
57
+ if name == "navigate":
58
+ if not action.url:
59
+ raise LeashByteError(422, "invalid_action", "Navigation needs a URL.")
60
+ if run_origin(action.url) not in policy["allowed_origins"]:
61
+ raise LeashByteError(403, "origin_not_allowed", "The run policy denies this origin.")
62
+ if navigations >= policy["limits"]["navigations"]:
63
+ raise LeashByteError(409, "navigation_limit", "The run reached its navigation limit.")
64
+ elif name in {"click", "type", "fill_secret", "select"}:
65
+ marks = {node.get("mark") for node in observation.get("nodes", [])}
66
+ if action.mark not in marks:
67
+ raise LeashByteError(422, "invalid_mark", "The model used an unknown mark.")
68
+ if contains_secret_shaped_text(action.text) or contains_secret_shaped_text(action.url):
69
+ raise LeashByteError(
70
+ 403, "secret_value_rejected", "Use a credential placeholder for secret values."
71
+ )
72
+ if any(contains_secret_shaped_text(value) for value in (action.values or [])):
73
+ raise LeashByteError(
74
+ 403, "secret_value_rejected", "Use a credential placeholder for secret values."
75
+ )
76
+ if name == "type":
77
+ if action.text is None or "{{credential:" in action.text:
78
+ raise LeashByteError(422, "invalid_action", "Use public text for a type action.")
79
+ if name == "select" and not action.values:
80
+ raise LeashByteError(422, "invalid_action", "Select needs at least one value.")
81
+ if name == "fill_secret":
82
+ if not action.placeholder:
83
+ raise LeashByteError(422, "invalid_action", "Secret fill needs a placeholder.")
84
+ alias, _ = placeholder_parts(action.placeholder)
85
+ if alias not in policy["credential_aliases"]:
86
+ raise LeashByteError(403, "credential_not_granted", "The run policy denies this alias.")
87
+ if action.placeholder not in observation.get("credential_placeholders", []):
88
+ raise LeashByteError(
89
+ 403, "origin_not_allowed", "This origin cannot use the credential."
90
+ )
91
+ if name == "done" and not action.result:
92
+ raise LeashByteError(422, "invalid_action", "A completed run needs a result.")
93
+ if name == "fail" and not action.reason:
94
+ raise LeashByteError(422, "invalid_action", "A failed model action needs a reason.")
95
+
96
+
97
+ class AgentWorker:
98
+ def __init__(self, settings: Settings, store: RunStore) -> None:
99
+ self.settings = settings
100
+ self.store = store
101
+
102
+ async def browser(self, method: str, path: str, payload: dict | None = None):
103
+ response = await internal_request(
104
+ self.settings.browser_pool_base_url,
105
+ self.settings.require_service_key(),
106
+ method,
107
+ path,
108
+ payload,
109
+ timeout=self.settings.browser_navigation_timeout_seconds + 30,
110
+ )
111
+ return response_json(response)
112
+
113
+ @staticmethod
114
+ async def within_deadline(awaitable, deadline: float):
115
+ """Stop a model or browser call when the run reaches its time limit."""
116
+ try:
117
+ return await asyncio.wait_for(
118
+ awaitable, timeout=max(0.001, deadline - time.monotonic())
119
+ )
120
+ except TimeoutError as error:
121
+ raise LeashByteError(409, "time_limit", "The run reached its time limit.") from error
122
+
123
+ @staticmethod
124
+ def output_token_allowance(observation: dict, remaining_tokens: int) -> int:
125
+ """Reserve room for input and bound one structured action response."""
126
+ if remaining_tokens < 100:
127
+ raise LeashByteError(409, "token_limit", "The run reached its token limit.")
128
+ estimated_input = (len(observation_message(observation).encode("utf-8")) + 2) // 3
129
+ if estimated_input + 100 > remaining_tokens:
130
+ raise LeashByteError(409, "token_limit", "The run reached its token limit.")
131
+ return min(2048, remaining_tokens - estimated_input)
132
+
133
+ async def credentials(self) -> list[dict]:
134
+ response = await internal_request(
135
+ self.settings.vault_base_url,
136
+ self.settings.require_vault_manage_key(),
137
+ "GET",
138
+ "/internal/credentials",
139
+ )
140
+ body = response_json(response)
141
+ if not isinstance(body, dict) or not isinstance(body.get("credentials"), list):
142
+ raise LeashByteError(
143
+ 502, "invalid_service_response", "The vault returned invalid data."
144
+ )
145
+ return body["credentials"]
146
+
147
+ def model_for(self, run: dict):
148
+ if run["provider"] == "anthropic":
149
+ return ClaudeModel(
150
+ self.settings.require_anthropic_api_key(),
151
+ run["model"],
152
+ self.settings.model_timeout_seconds,
153
+ )
154
+ return LocalModel(
155
+ self.settings.local_model_base_url,
156
+ run["model"],
157
+ self.settings.optional_local_model_api_key(),
158
+ self.settings.model_timeout_seconds,
159
+ )
160
+
161
+ @staticmethod
162
+ def observation(raw: dict, run: dict, policy: dict, credentials: list[dict]) -> dict:
163
+ origin = run_origin(raw["url"])
164
+ if origin not in policy["allowed_origins"]:
165
+ raise LeashByteError(403, "origin_not_allowed", "The page left the allowed origins.")
166
+ placeholders = []
167
+ try:
168
+ credential_origin = https_page_origin(raw["url"])
169
+ except LeashByteError:
170
+ credential_origin = ""
171
+ for credential in credentials:
172
+ if (
173
+ credential["alias"] in policy["credential_aliases"]
174
+ and credential_origin in credential["allowed_origins"]
175
+ ):
176
+ placeholders.extend(
177
+ [
178
+ "{{credential:" + credential["alias"] + ".username}}",
179
+ "{{credential:" + credential["alias"] + ".password}}",
180
+ ]
181
+ )
182
+ return {
183
+ "goal": run["goal"],
184
+ "limits": policy["limits"],
185
+ "allowed_origins": policy["allowed_origins"],
186
+ "snapshot_id": raw["snapshot_id"],
187
+ "origin": origin,
188
+ "url": raw["url"],
189
+ "title": raw["title"],
190
+ "nodes": raw["nodes"],
191
+ "credential_placeholders": placeholders,
192
+ }
193
+
194
+ async def screenshot(self, run_id: str, session_id: str, number: int, limit: int) -> None:
195
+ response = await internal_request(
196
+ self.settings.browser_pool_base_url,
197
+ self.settings.require_service_key(),
198
+ "GET",
199
+ f"/internal/sessions/{session_id}/screenshot",
200
+ )
201
+ await self.store.add_artifact(
202
+ run_id, f"step-{number:04d}.png", "image/png", response.content, limit
203
+ )
204
+
205
+ async def execute(self, claimed: dict) -> None:
206
+ run_id = claimed["id"]
207
+ raw = await self.store.raw(run_id)
208
+ policy = raw["policy"]
209
+ if not policy["allowed_origins"]:
210
+ policy["allowed_origins"] = [run_origin(raw["start_url"])]
211
+ else:
212
+ policy["allowed_origins"] = sorted(
213
+ {run_origin(item) for item in policy["allowed_origins"]}
214
+ )
215
+ if run_origin(raw["start_url"]) not in policy["allowed_origins"]:
216
+ raise LeashByteError(
217
+ 422, "origin_not_allowed", "The start URL must use an allowed origin."
218
+ )
219
+ policy["limits"] = {key: int(value) for key, value in policy["limits"].items()}
220
+ usage = dict(raw["usage"])
221
+ session_id = None
222
+ steps = 0
223
+ deadline = time.monotonic() + policy["limits"]["elapsed_seconds"]
224
+ terminal_state = "failed"
225
+ result = None
226
+ error_message = None
227
+ cancellation_reason = None
228
+ final_observation = None
229
+ try:
230
+ credentials = await self.credentials()
231
+ model = self.model_for(raw)
232
+ created = await self.within_deadline(
233
+ self.browser(
234
+ "POST",
235
+ "/internal/sessions",
236
+ {
237
+ "profile": raw["profile"],
238
+ "start_url": raw["start_url"],
239
+ "lease_seconds": min(policy["limits"]["elapsed_seconds"] + 30, 3600),
240
+ },
241
+ ),
242
+ deadline,
243
+ )
244
+ session_id = created["id"]
245
+ lease_expires = datetime.fromisoformat(created["expires_at"]).timestamp()
246
+ deadline = min(deadline, time.monotonic() + max(0.0, lease_expires - time.time()))
247
+ await self.store.set_session(run_id, session_id)
248
+ current = await self.within_deadline(
249
+ self.browser("GET", f"/internal/sessions/{session_id}/snapshot"), deadline
250
+ )
251
+ while True:
252
+ await self.check_control(run_id, deadline, usage, policy)
253
+ observed = self.observation(current, raw, policy, credentials)
254
+ previous = await self.store.steps(run_id)
255
+ observed["previous_actions"] = [
256
+ {
257
+ "action": step["action"],
258
+ "source_url": step["source_url"],
259
+ "result": (
260
+ {
261
+ "url": step["after_observation"]["url"],
262
+ "title": step["after_observation"]["title"],
263
+ }
264
+ if step["after_observation"] is not None
265
+ else None
266
+ ),
267
+ "error": step["error"],
268
+ }
269
+ for step in previous[-5:]
270
+ ]
271
+ remaining_tokens = policy["limits"]["tokens"] - usage["total_tokens"]
272
+ requested_output = self.output_token_allowance(observed, remaining_tokens)
273
+ decision = await self.within_deadline(
274
+ model.decide(observed, requested_output), deadline
275
+ )
276
+ for key in usage:
277
+ if key in decision.usage:
278
+ usage[key] += decision.usage[key]
279
+ await self.store.update_usage(run_id, usage)
280
+ steps += 1
281
+ action = decision.action
282
+ step_recorded = False
283
+ try:
284
+ validate_action(action, observed, policy, usage["navigations"])
285
+ await self.check_control(run_id, deadline, usage, policy)
286
+ if usage["total_tokens"] > policy["limits"]["tokens"]:
287
+ raise LeashByteError(409, "token_limit", "The run reached its token limit.")
288
+ previous_url = current["url"]
289
+ if action.action in {"done", "fail"}:
290
+ current = await self.within_deadline(
291
+ self.browser("GET", f"/internal/sessions/{session_id}/snapshot"),
292
+ deadline,
293
+ )
294
+ elif action.action == "wait":
295
+ await self.within_deadline(asyncio.sleep(action.wait_ms / 1000), deadline)
296
+ current = await self.within_deadline(
297
+ self.browser("GET", f"/internal/sessions/{session_id}/snapshot"),
298
+ deadline,
299
+ )
300
+ else:
301
+ current = await self.within_deadline(
302
+ self.browser(
303
+ "POST",
304
+ f"/internal/sessions/{session_id}/actions",
305
+ action.browser_payload(),
306
+ ),
307
+ deadline,
308
+ )
309
+ if current["url"] != previous_url:
310
+ usage["navigations"] += 1
311
+ await self.store.update_usage(run_id, usage)
312
+ if usage["navigations"] > policy["limits"]["navigations"]:
313
+ raise LeashByteError(
314
+ 409,
315
+ "navigation_limit",
316
+ "The run reached its navigation limit.",
317
+ )
318
+ final_observation = self.observation(current, raw, policy, credentials)
319
+ await self.store.add_step(
320
+ run_id,
321
+ steps,
322
+ action.model_dump(mode="json", exclude_none=True),
323
+ observed,
324
+ final_observation,
325
+ )
326
+ step_recorded = True
327
+ try:
328
+ await self.within_deadline(
329
+ self.screenshot(
330
+ run_id, session_id, steps, policy["limits"]["artifact_bytes"]
331
+ ),
332
+ deadline,
333
+ )
334
+ except LeashByteError as screenshot_error:
335
+ if screenshot_error.code != "screenshot_redaction_active":
336
+ raise
337
+ if action.action == "done":
338
+ terminal_state = "succeeded"
339
+ result = {
340
+ "summary": action.result,
341
+ "source_urls": sorted(
342
+ {
343
+ step["source_url"]
344
+ for step in await self.store.steps(run_id)
345
+ if step["source_url"]
346
+ }
347
+ ),
348
+ }
349
+ break
350
+ if action.action == "fail":
351
+ terminal_state = "failed"
352
+ error_message = action.reason[:300]
353
+ break
354
+ if steps >= policy["limits"]["steps"]:
355
+ raise LeashByteError(409, "step_limit", "The run reached its step limit.")
356
+ except LeashByteError as action_error:
357
+ if not step_recorded:
358
+ await self.store.add_step(
359
+ run_id,
360
+ steps,
361
+ action.model_dump(mode="json", exclude_none=True),
362
+ observed,
363
+ None,
364
+ action_error.message[:300],
365
+ )
366
+ raise
367
+ except LeashByteError as run_error:
368
+ error_message = run_error.message[:300]
369
+ if run_error.code.endswith("_limit"):
370
+ terminal_state = "limit_reached"
371
+ elif run_error.code == "run_cancelled":
372
+ terminal_state = "cancelled"
373
+ cancellation_reason = run_error.message[:300]
374
+ else:
375
+ terminal_state = "failed"
376
+ except Exception as run_error:
377
+ logger.error("Run failed run_id=%s type=%s", run_id, type(run_error).__name__)
378
+ error_message = "The run could not be completed."
379
+ terminal_state = "failed"
380
+ finally:
381
+ if session_id is not None:
382
+ with suppress(LeashByteError):
383
+ await self.browser("DELETE", f"/internal/sessions/{session_id}")
384
+ try:
385
+ await self.write_final_artifacts(run_id, result, error_message, policy)
386
+ except Exception as artifact_error:
387
+ logger.error(
388
+ "Final artifact failed run_id=%s type=%s",
389
+ run_id,
390
+ type(artifact_error).__name__,
391
+ )
392
+ result = None
393
+ if (
394
+ isinstance(artifact_error, LeashByteError)
395
+ and artifact_error.code == "artifact_limit"
396
+ ):
397
+ terminal_state = "limit_reached"
398
+ error_message = artifact_error.message
399
+ else:
400
+ terminal_state = "failed"
401
+ error_message = "The run artifacts could not be stored."
402
+ await self.store.finish(
403
+ run_id, terminal_state, result, error_message, cancellation_reason
404
+ )
405
+
406
+ async def check_control(self, run_id: str, deadline: float, usage: dict, policy: dict) -> None:
407
+ raw = await self.store.raw(run_id)
408
+ if raw["cancel_requested"]:
409
+ raise LeashByteError(
410
+ 409, "run_cancelled", raw["cancellation_reason"] or "Cancelled by the user."
411
+ )
412
+ if time.monotonic() >= deadline:
413
+ raise LeashByteError(409, "time_limit", "The run reached its time limit.")
414
+ if usage["total_tokens"] >= policy["limits"]["tokens"]:
415
+ raise LeashByteError(409, "token_limit", "The run reached its token limit.")
416
+ await self.store.heartbeat(run_id)
417
+
418
+ async def write_final_artifacts(
419
+ self, run_id: str, result: dict | None, error: str | None, policy: dict
420
+ ) -> None:
421
+ steps = await self.store.steps(run_id)
422
+ events = [
423
+ {
424
+ "number": step["number"],
425
+ "action": step["action"],
426
+ "source_url": step["source_url"],
427
+ "error": step["error"],
428
+ }
429
+ for step in steps
430
+ ]
431
+ await self.store.add_artifact(
432
+ run_id,
433
+ "run-log.json",
434
+ "application/json",
435
+ json_value({"events": events, "error": error}).encode("utf-8"),
436
+ policy["limits"]["artifact_bytes"],
437
+ )
438
+ if result is not None:
439
+ await self.store.add_artifact(
440
+ run_id,
441
+ "result.json",
442
+ "application/json",
443
+ json_value(result).encode("utf-8"),
444
+ policy["limits"]["artifact_bytes"],
445
+ )
446
+
447
+ async def run(self) -> None:
448
+ await self.store.initialize()
449
+ pool = await self.store.connect()
450
+ async with pool.acquire() as connection:
451
+ interrupted = await connection.fetch(
452
+ "SELECT session_id FROM runs WHERE state='running' AND session_id IS NOT NULL"
453
+ )
454
+ for row in interrupted:
455
+ with suppress(LeashByteError):
456
+ await self.browser("DELETE", f"/internal/sessions/{row['session_id']}")
457
+ await connection.execute(
458
+ """UPDATE runs SET state='failed', error='The worker restarted.',
459
+ finished_at=$1 WHERE state='running'""",
460
+ utcnow(),
461
+ )
462
+ logger.info("Agent worker ready")
463
+ while True:
464
+ try:
465
+ claimed = await self.store.claim()
466
+ if claimed is None:
467
+ await asyncio.sleep(self.settings.agent_poll_seconds)
468
+ continue
469
+ try:
470
+ await self.execute(claimed)
471
+ except LeashByteError as error:
472
+ await self.store.finish(claimed["id"], "failed", error=error.message[:300])
473
+ except Exception as error:
474
+ logger.error(
475
+ "Worker setup failed run_id=%s type=%s",
476
+ claimed["id"],
477
+ type(error).__name__,
478
+ )
479
+ await self.store.finish(
480
+ claimed["id"], "failed", error="The run could not be started."
481
+ )
482
+ except Exception as error:
483
+ logger.error("Worker iteration failed type=%s", type(error).__name__)
484
+ await asyncio.sleep(self.settings.agent_poll_seconds)
485
+
486
+
487
+ def new_run_id() -> str:
488
+ return uuid.uuid4().hex