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 +3 -0
- leashbyte/agent_worker.py +488 -0
- leashbyte/browser.py +627 -0
- leashbyte/browser_service.py +241 -0
- leashbyte/config.py +186 -0
- leashbyte/errors.py +11 -0
- leashbyte/fetch.py +333 -0
- leashbyte/internal.py +58 -0
- leashbyte/main.py +470 -0
- leashbyte/mcp/__init__.py +1 -0
- leashbyte/mcp/server.py +290 -0
- leashbyte/model_adapter.py +206 -0
- leashbyte/model_relay.py +84 -0
- leashbyte/models.py +142 -0
- leashbyte/proxy.py +179 -0
- leashbyte/run_models.py +164 -0
- leashbyte/run_store.py +493 -0
- leashbyte/run_viewer.py +42 -0
- leashbyte/search.py +103 -0
- leashbyte/url_guard.py +137 -0
- leashbyte/vault.py +210 -0
- leashbyte/vault_service.py +136 -0
- leashbyte/worker_service.py +19 -0
- leashbyte-0.1.1.dist-info/METADATA +456 -0
- leashbyte-0.1.1.dist-info/RECORD +29 -0
- leashbyte-0.1.1.dist-info/WHEEL +4 -0
- leashbyte-0.1.1.dist-info/entry_points.txt +2 -0
- leashbyte-0.1.1.dist-info/licenses/LICENSE +203 -0
- leashbyte-0.1.1.dist-info/licenses/NOTICE +29 -0
leashbyte/__init__.py
ADDED
|
@@ -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
|