browser-agent-python-sdk 0.1.0__py3-none-macosx_15_0_arm64.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.
- browser_agent/__init__.py +18 -0
- browser_agent/errors.py +33 -0
- browser_agent/events.py +73 -0
- browser_agent/models.py +52 -0
- browser_agent/options.py +138 -0
- browser_agent/protocol.py +102 -0
- browser_agent/rpc.py +106 -0
- browser_agent/run.py +192 -0
- browser_agent/runtime.py +110 -0
- browser_agent_python_sdk-0.1.0.dist-info/METADATA +81 -0
- browser_agent_python_sdk-0.1.0.dist-info/RECORD +13 -0
- browser_agent_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- browser_agent_python_sdk-0.1.0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .errors import BrowserAgentError, BrowserAgentErrorCode
|
|
2
|
+
from .events import (
|
|
3
|
+
BrowserAgentEvent, ErrorEvent, RunCompletedEvent, RunStartedEvent,
|
|
4
|
+
TaskResultEvent, UserTakeoverEvent,
|
|
5
|
+
)
|
|
6
|
+
from .models import (
|
|
7
|
+
BrowserAgentCredential, BrowserAgentLogEntry, BrowserAgentResult, BrowserAgentTask,
|
|
8
|
+
BrowserAgentTaskResult, BrowserAgentTaskRunResult, BrowserAgentValidatorResult,
|
|
9
|
+
Provider, ReasoningEffort, UserTakeoverCategory,
|
|
10
|
+
)
|
|
11
|
+
from .run import BrowserAgent, BrowserAgentRun
|
|
12
|
+
|
|
13
|
+
__all__ = """
|
|
14
|
+
BrowserAgent BrowserAgentCredential BrowserAgentError BrowserAgentErrorCode BrowserAgentEvent BrowserAgentLogEntry
|
|
15
|
+
BrowserAgentResult BrowserAgentRun BrowserAgentTask BrowserAgentTaskResult
|
|
16
|
+
BrowserAgentTaskRunResult BrowserAgentValidatorResult ErrorEvent Provider ReasoningEffort
|
|
17
|
+
RunCompletedEvent RunStartedEvent TaskResultEvent UserTakeoverCategory UserTakeoverEvent
|
|
18
|
+
""".split()
|
browser_agent/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
BrowserAgentErrorCode = Literal[
|
|
5
|
+
"CLI_NOT_FOUND", "CLI_VERSION_INCOMPATIBLE", "CHROME_NOT_FOUND", "CONFIG_INVALID",
|
|
6
|
+
"PROCESS_START_FAILED", "PROCESS_EXITED", "PROTOCOL_ERROR", "CANCELLED",
|
|
7
|
+
]
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class BrowserAgentError(Exception):
|
|
10
|
+
code: BrowserAgentErrorCode
|
|
11
|
+
message: str
|
|
12
|
+
details: dict[str, object] | None = None
|
|
13
|
+
cause: object | None = None
|
|
14
|
+
|
|
15
|
+
def __post_init__(self) -> None:
|
|
16
|
+
Exception.__init__(self, self.message)
|
|
17
|
+
def redact(value: str, secrets: list[str], paths: list[str]) -> str:
|
|
18
|
+
result = value
|
|
19
|
+
for secret in secrets:
|
|
20
|
+
if secret:
|
|
21
|
+
result = result.replace(secret, "<redacted>")
|
|
22
|
+
for internal_path in paths:
|
|
23
|
+
if internal_path:
|
|
24
|
+
result = result.replace(internal_path, "<internal>")
|
|
25
|
+
return result
|
|
26
|
+
def normalize_error(
|
|
27
|
+
error: BaseException, code: BrowserAgentErrorCode,
|
|
28
|
+
secrets: list[str], paths: list[str],
|
|
29
|
+
) -> BrowserAgentError:
|
|
30
|
+
if isinstance(error, BrowserAgentError):
|
|
31
|
+
return error
|
|
32
|
+
message = redact(str(error), secrets, paths)
|
|
33
|
+
return BrowserAgentError(code, message, cause=RuntimeError(message))
|
browser_agent/events.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Generic, Literal, TypeAlias, TypeVar
|
|
5
|
+
|
|
6
|
+
from .errors import BrowserAgentError
|
|
7
|
+
from .models import BrowserAgentResult, BrowserAgentTaskResult, UserTakeoverCategory
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class RunStartedEvent:
|
|
10
|
+
run_id: str
|
|
11
|
+
type: Literal["run_started"] = "run_started"
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class UserTakeoverEvent:
|
|
14
|
+
run_id: str
|
|
15
|
+
task_id: str
|
|
16
|
+
reason: str
|
|
17
|
+
category: UserTakeoverCategory
|
|
18
|
+
type: Literal["user_takeover"] = "user_takeover"
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class TaskResultEvent:
|
|
21
|
+
run_id: str
|
|
22
|
+
result: BrowserAgentTaskResult
|
|
23
|
+
type: Literal["task_result"] = "task_result"
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class RunCompletedEvent:
|
|
26
|
+
run_id: str
|
|
27
|
+
result: BrowserAgentResult
|
|
28
|
+
type: Literal["run_completed"] = "run_completed"
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ErrorEvent:
|
|
31
|
+
run_id: str
|
|
32
|
+
error: BrowserAgentError
|
|
33
|
+
type: Literal["error"] = "error"
|
|
34
|
+
BrowserAgentEvent: TypeAlias = (
|
|
35
|
+
RunStartedEvent
|
|
36
|
+
| UserTakeoverEvent
|
|
37
|
+
| TaskResultEvent
|
|
38
|
+
| RunCompletedEvent
|
|
39
|
+
| ErrorEvent
|
|
40
|
+
)
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
class ReplayEvents(Generic[T]):
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self._items: list[T] = []
|
|
45
|
+
self._waiters: set[asyncio.Future[None]] = set()
|
|
46
|
+
self._closed = False
|
|
47
|
+
def publish(self, item: T) -> None:
|
|
48
|
+
if self._closed:
|
|
49
|
+
return
|
|
50
|
+
self._items.append(item)
|
|
51
|
+
self._wake()
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
self._closed = True
|
|
54
|
+
self._wake()
|
|
55
|
+
async def iterate(self) -> AsyncIterator[T]:
|
|
56
|
+
index = 0
|
|
57
|
+
while True:
|
|
58
|
+
while index < len(self._items):
|
|
59
|
+
yield self._items[index]
|
|
60
|
+
index += 1
|
|
61
|
+
if self._closed:
|
|
62
|
+
return
|
|
63
|
+
waiter = asyncio.get_running_loop().create_future()
|
|
64
|
+
self._waiters.add(waiter)
|
|
65
|
+
try:
|
|
66
|
+
await waiter
|
|
67
|
+
finally:
|
|
68
|
+
self._waiters.discard(waiter)
|
|
69
|
+
def _wake(self) -> None:
|
|
70
|
+
for waiter in self._waiters:
|
|
71
|
+
if not waiter.done():
|
|
72
|
+
waiter.set_result(None)
|
|
73
|
+
self._waiters.clear()
|
browser_agent/models.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Literal, TypeAlias
|
|
5
|
+
|
|
6
|
+
Provider: TypeAlias = Literal["openai", "vllm", "together", "anthropic", "google"]
|
|
7
|
+
ReasoningEffort: TypeAlias = Literal[
|
|
8
|
+
"none", "minimal", "low", "medium", "high", "max", "enabled"
|
|
9
|
+
]
|
|
10
|
+
UserTakeoverCategory: TypeAlias = Literal[
|
|
11
|
+
"authentication", "otp", "verification", "payment", "other"
|
|
12
|
+
]
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class BrowserAgentCredential:
|
|
15
|
+
username: str
|
|
16
|
+
password: str
|
|
17
|
+
domain: str
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class BrowserAgentTask:
|
|
20
|
+
task: str
|
|
21
|
+
url: str | None = None
|
|
22
|
+
credentials: Sequence[BrowserAgentCredential] = ()
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class BrowserAgentLogEntry:
|
|
25
|
+
run_id: str
|
|
26
|
+
message: str
|
|
27
|
+
timestamp: datetime
|
|
28
|
+
source: Literal["stderr"] = "stderr"
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class BrowserAgentValidatorResult:
|
|
31
|
+
ran: bool
|
|
32
|
+
success: bool
|
|
33
|
+
summary: str
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class BrowserAgentTaskRunResult:
|
|
36
|
+
run_index: int
|
|
37
|
+
completed: bool
|
|
38
|
+
data: object
|
|
39
|
+
validator: BrowserAgentValidatorResult
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class BrowserAgentTaskResult:
|
|
42
|
+
task_id: str
|
|
43
|
+
status: Literal["completed", "failed"]
|
|
44
|
+
runs: tuple[BrowserAgentTaskRunResult, ...]
|
|
45
|
+
errors: tuple[str, ...]
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class BrowserAgentResult:
|
|
48
|
+
run_id: str
|
|
49
|
+
status: Literal["completed", "failed", "cancelled"]
|
|
50
|
+
tasks: tuple[BrowserAgentTaskResult, ...]
|
|
51
|
+
started_at: datetime
|
|
52
|
+
finished_at: datetime
|
browser_agent/options.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from .errors import BrowserAgentError
|
|
10
|
+
from .models import (
|
|
11
|
+
BrowserAgentCredential, BrowserAgentLogEntry, BrowserAgentTask, Provider,
|
|
12
|
+
ReasoningEffort,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
PROVIDER_ENV: dict[Provider, str] = {
|
|
16
|
+
"openai": "OPENAI_API_KEY",
|
|
17
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
18
|
+
"google": "GOOGLE_API_KEY",
|
|
19
|
+
"together": "TOGETHER_API_KEY",
|
|
20
|
+
"vllm": "VLLM_API_KEY",
|
|
21
|
+
}
|
|
22
|
+
OPENAI = (
|
|
23
|
+
"gpt-5.4", "gpt-5.4-mini", "gpt-5.5",
|
|
24
|
+
"gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol",
|
|
25
|
+
)
|
|
26
|
+
CAPABILITIES = [
|
|
27
|
+
*(("openai", model, False, ("none", "minimal", "low", "medium", "high"), "low")
|
|
28
|
+
for model in OPENAI),
|
|
29
|
+
("together", "zai-org/GLM-5.2", False, ("none", "high", "max"), "high"),
|
|
30
|
+
("vllm", "qwen", True, ("none", "enabled"), "enabled"),
|
|
31
|
+
("vllm", "glm", True, ("none",), "none"),
|
|
32
|
+
]
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ResolvedOptions:
|
|
35
|
+
provider: Provider
|
|
36
|
+
model: str
|
|
37
|
+
reasoning_effort: ReasoningEffort
|
|
38
|
+
api_key: str | None
|
|
39
|
+
api_key_environment: str
|
|
40
|
+
endpoint_url: str | None
|
|
41
|
+
headless: bool
|
|
42
|
+
executable_path: str | None
|
|
43
|
+
download_directory: str
|
|
44
|
+
workspace_directory: str | None
|
|
45
|
+
user_takeover_tool: bool
|
|
46
|
+
max_steps: int
|
|
47
|
+
concurrency: int
|
|
48
|
+
runs_per_task: int
|
|
49
|
+
retry_count: int
|
|
50
|
+
on_log: Callable[[BrowserAgentLogEntry], None] | None
|
|
51
|
+
def invalid(message: str):
|
|
52
|
+
raise BrowserAgentError("CONFIG_INVALID", message)
|
|
53
|
+
def positive(value: int, default: int) -> int:
|
|
54
|
+
value = default if value is None else value
|
|
55
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
56
|
+
invalid("Execution limits must be positive integers.")
|
|
57
|
+
return value
|
|
58
|
+
def reasoning(provider: Provider, model: str, effort: ReasoningEffort | None):
|
|
59
|
+
capability = next(
|
|
60
|
+
(item for item in CAPABILITIES if item[0] == provider and
|
|
61
|
+
(item[1].lower() in model.lower() if item[2] else item[1] == model)),
|
|
62
|
+
None,
|
|
63
|
+
)
|
|
64
|
+
if capability is None and provider in ("openai", "together", "vllm"):
|
|
65
|
+
invalid(f"Unknown model '{model}' for '{provider}'.")
|
|
66
|
+
resolved = effort or (capability[4] if capability else None)
|
|
67
|
+
if resolved is None:
|
|
68
|
+
invalid("reasoning_effort is required for this model.")
|
|
69
|
+
if capability and resolved not in capability[3]:
|
|
70
|
+
invalid(f"Unsupported reasoning_effort '{resolved}' for this model.")
|
|
71
|
+
return resolved
|
|
72
|
+
def resolve_options(**values) -> ResolvedOptions:
|
|
73
|
+
provider, model = values["provider"], values["model"]
|
|
74
|
+
downloads = values["download_directory"]
|
|
75
|
+
if provider not in PROVIDER_ENV:
|
|
76
|
+
invalid(f"Unsupported provider '{provider}'.")
|
|
77
|
+
if not isinstance(model, str) or not model.strip():
|
|
78
|
+
invalid("model must be a non-empty string.")
|
|
79
|
+
if not isinstance(downloads, str) or not downloads.strip():
|
|
80
|
+
invalid("download_directory must be a non-empty string.")
|
|
81
|
+
endpoint = values["endpoint_url"]
|
|
82
|
+
if endpoint:
|
|
83
|
+
parsed = urlparse(endpoint)
|
|
84
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
85
|
+
invalid("endpoint_url must be an absolute HTTP(S) URL.")
|
|
86
|
+
if provider == "vllm" and not endpoint:
|
|
87
|
+
invalid("endpoint_url is required for vllm.")
|
|
88
|
+
environment = PROVIDER_ENV[provider]
|
|
89
|
+
api_key = (values["api_key"] or "").strip() or os.environ.get(environment)
|
|
90
|
+
if provider != "vllm" and not api_key:
|
|
91
|
+
invalid(f"Missing API key for provider '{provider}'.")
|
|
92
|
+
retry = values["retry_count"]
|
|
93
|
+
if isinstance(retry, bool) or not isinstance(retry, int) or retry < 0:
|
|
94
|
+
invalid("retry_count must be an integer greater than or equal to zero.")
|
|
95
|
+
absolute = lambda value: str(Path(value).resolve()) if value else None
|
|
96
|
+
return ResolvedOptions(
|
|
97
|
+
provider, model.strip(), reasoning(provider, model.strip(), values["reasoning_effort"]),
|
|
98
|
+
api_key, environment, endpoint, values["headless"], absolute(values["executable_path"]),
|
|
99
|
+
absolute(downloads) or "", absolute(values["workspace_directory"]),
|
|
100
|
+
values["user_takeover_tool"], positive(values["max_steps"], 50),
|
|
101
|
+
positive(values["concurrency"], 4), positive(values["runs_per_task"], 1),
|
|
102
|
+
retry, values["on_log"],
|
|
103
|
+
)
|
|
104
|
+
def normalize_tasks(value: BrowserAgentTask | Sequence[BrowserAgentTask]):
|
|
105
|
+
tasks = [value] if isinstance(value, BrowserAgentTask) else list(value)
|
|
106
|
+
if not tasks:
|
|
107
|
+
invalid("At least one task is required.")
|
|
108
|
+
for item in tasks:
|
|
109
|
+
if not isinstance(item, BrowserAgentTask) or not item.task.strip():
|
|
110
|
+
invalid("Each task must contain a non-empty task string.")
|
|
111
|
+
if item.url is not None and not item.url.strip():
|
|
112
|
+
invalid("Task URLs must be non-empty strings.")
|
|
113
|
+
if not isinstance(item.credentials, Sequence) or isinstance(item.credentials, str):
|
|
114
|
+
invalid("Task credentials must be a sequence.")
|
|
115
|
+
for credential in item.credentials:
|
|
116
|
+
if not isinstance(credential, BrowserAgentCredential):
|
|
117
|
+
invalid("Each credential must be a BrowserAgentCredential.")
|
|
118
|
+
if not credential.username.strip():
|
|
119
|
+
invalid("Credential usernames must be non-empty strings.")
|
|
120
|
+
if not credential.password:
|
|
121
|
+
invalid("Credential passwords must be non-empty strings.")
|
|
122
|
+
if not credential.domain.strip():
|
|
123
|
+
invalid("Credential domains must be non-empty strings.")
|
|
124
|
+
return [
|
|
125
|
+
BrowserAgentTask(
|
|
126
|
+
item.task.strip(), item.url.strip() if item.url else None,
|
|
127
|
+
tuple(BrowserAgentCredential(
|
|
128
|
+
credential.username.strip(), credential.password, credential.domain.strip()
|
|
129
|
+
) for credential in item.credentials),
|
|
130
|
+
)
|
|
131
|
+
for item in tasks
|
|
132
|
+
]
|
|
133
|
+
def child_environment(options: ResolvedOptions) -> dict[str, str]:
|
|
134
|
+
environment = {key: value for key, value in os.environ.items()
|
|
135
|
+
if key not in PROVIDER_ENV.values()}
|
|
136
|
+
if options.api_key:
|
|
137
|
+
environment[options.api_key_environment] = options.api_key
|
|
138
|
+
return environment
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from .errors import BrowserAgentError, redact
|
|
10
|
+
from .models import BrowserAgentLogEntry, BrowserAgentTask
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class AgentProcess:
|
|
14
|
+
process: asyncio.subprocess.Process
|
|
15
|
+
|
|
16
|
+
async def messages(self) -> AsyncIterator[dict[str, object]]:
|
|
17
|
+
assert self.process.stdout
|
|
18
|
+
while line := await self.process.stdout.readline():
|
|
19
|
+
if not line.strip():
|
|
20
|
+
continue
|
|
21
|
+
try:
|
|
22
|
+
value = json.loads(line)
|
|
23
|
+
except json.JSONDecodeError as error:
|
|
24
|
+
raise BrowserAgentError(
|
|
25
|
+
"PROTOCOL_ERROR", "CLI emitted malformed JSON-RPC."
|
|
26
|
+
) from error
|
|
27
|
+
if not isinstance(value, dict) or value.get("jsonrpc") != "2.0":
|
|
28
|
+
raise BrowserAgentError(
|
|
29
|
+
"PROTOCOL_ERROR", "CLI emitted an invalid JSON-RPC message."
|
|
30
|
+
)
|
|
31
|
+
yield value
|
|
32
|
+
async def logs(self) -> AsyncIterator[str]:
|
|
33
|
+
assert self.process.stderr
|
|
34
|
+
while line := await self.process.stderr.readline():
|
|
35
|
+
yield line.decode(errors="replace").rstrip("\r\n")
|
|
36
|
+
async def start_agent_process(
|
|
37
|
+
executable: str, config_path: str, environment: dict[str, str]
|
|
38
|
+
) -> AgentProcess:
|
|
39
|
+
try:
|
|
40
|
+
process = await asyncio.create_subprocess_exec(
|
|
41
|
+
executable,
|
|
42
|
+
config_path,
|
|
43
|
+
"--rpc",
|
|
44
|
+
stdin=asyncio.subprocess.PIPE,
|
|
45
|
+
stdout=asyncio.subprocess.PIPE,
|
|
46
|
+
stderr=asyncio.subprocess.PIPE,
|
|
47
|
+
env=environment,
|
|
48
|
+
)
|
|
49
|
+
except OSError as error:
|
|
50
|
+
raise BrowserAgentError(
|
|
51
|
+
"PROCESS_START_FAILED", "browser-agent process could not be started."
|
|
52
|
+
) from error
|
|
53
|
+
return AgentProcess(process)
|
|
54
|
+
async def request_run(
|
|
55
|
+
agent: AgentProcess, tasks: list[BrowserAgentTask] | tuple[BrowserAgentTask, ...] = ()
|
|
56
|
+
) -> None:
|
|
57
|
+
assert agent.process.stdin
|
|
58
|
+
rpc_tasks = [
|
|
59
|
+
{"credentials": [
|
|
60
|
+
{"username": value.username, "password": value.password, "domain": value.domain}
|
|
61
|
+
for value in task.credentials
|
|
62
|
+
]} if task.credentials else {}
|
|
63
|
+
for task in tasks
|
|
64
|
+
]
|
|
65
|
+
agent.process.stdin.write(
|
|
66
|
+
(
|
|
67
|
+
json.dumps(
|
|
68
|
+
{
|
|
69
|
+
"jsonrpc": "2.0",
|
|
70
|
+
"id": 1,
|
|
71
|
+
"method": "crafty/run",
|
|
72
|
+
"params": {"tasks": rpc_tasks} if any(task for task in rpc_tasks) else {},
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
+ "\n"
|
|
76
|
+
).encode()
|
|
77
|
+
)
|
|
78
|
+
await agent.process.stdin.drain()
|
|
79
|
+
async def terminate_process(agent: AgentProcess, grace: float = 5.0) -> None:
|
|
80
|
+
if agent.process.returncode is not None:
|
|
81
|
+
return
|
|
82
|
+
agent.process.terminate()
|
|
83
|
+
try:
|
|
84
|
+
await asyncio.wait_for(agent.process.wait(), timeout=grace)
|
|
85
|
+
except TimeoutError:
|
|
86
|
+
agent.process.kill()
|
|
87
|
+
await agent.process.wait()
|
|
88
|
+
async def consume_logs(
|
|
89
|
+
process: AgentProcess,
|
|
90
|
+
run_id: str,
|
|
91
|
+
callback,
|
|
92
|
+
secrets: list[str],
|
|
93
|
+
paths: list[str],
|
|
94
|
+
) -> None:
|
|
95
|
+
async for line in process.logs():
|
|
96
|
+
try:
|
|
97
|
+
if callback:
|
|
98
|
+
callback(BrowserAgentLogEntry(
|
|
99
|
+
run_id, redact(line, secrets, paths), datetime.now(timezone.utc)
|
|
100
|
+
))
|
|
101
|
+
except BaseException:
|
|
102
|
+
pass
|
browser_agent/rpc.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
|
|
5
|
+
from .errors import BrowserAgentError, redact
|
|
6
|
+
from .events import RunStartedEvent, TaskResultEvent, UserTakeoverEvent
|
|
7
|
+
from .models import (
|
|
8
|
+
BrowserAgentTaskResult,
|
|
9
|
+
BrowserAgentTaskRunResult,
|
|
10
|
+
BrowserAgentValidatorResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def record(value):
|
|
14
|
+
if not isinstance(value, dict):
|
|
15
|
+
raise BrowserAgentError(
|
|
16
|
+
"PROTOCOL_ERROR", "CLI emitted invalid task-result parameters."
|
|
17
|
+
)
|
|
18
|
+
return value
|
|
19
|
+
def normalize_result(value) -> BrowserAgentTaskResult:
|
|
20
|
+
source = record(value)
|
|
21
|
+
task_id, status, raw_runs = (
|
|
22
|
+
source.get("task_id"), source.get("status"), source.get("runs")
|
|
23
|
+
)
|
|
24
|
+
if not isinstance(task_id, str) or status not in ("completed", "failed") \
|
|
25
|
+
or not isinstance(raw_runs, list):
|
|
26
|
+
raise BrowserAgentError(
|
|
27
|
+
"PROTOCOL_ERROR", "CLI emitted invalid task-result parameters."
|
|
28
|
+
)
|
|
29
|
+
runs = []
|
|
30
|
+
for value in raw_runs:
|
|
31
|
+
run = record(value)
|
|
32
|
+
validator = record(run.get("validator"))
|
|
33
|
+
if not isinstance(run.get("run_index"), int) \
|
|
34
|
+
or not isinstance(run.get("completed"), bool) \
|
|
35
|
+
or not isinstance(validator.get("ran"), bool) \
|
|
36
|
+
or not isinstance(validator.get("success"), bool) \
|
|
37
|
+
or not isinstance(validator.get("summary"), str):
|
|
38
|
+
raise BrowserAgentError(
|
|
39
|
+
"PROTOCOL_ERROR", "CLI emitted an invalid task run."
|
|
40
|
+
)
|
|
41
|
+
runs.append(BrowserAgentTaskRunResult(
|
|
42
|
+
run["run_index"], run["completed"], run.get("data"),
|
|
43
|
+
BrowserAgentValidatorResult(
|
|
44
|
+
validator["ran"], validator["success"], validator["summary"]
|
|
45
|
+
),
|
|
46
|
+
))
|
|
47
|
+
errors = source.get("errors")
|
|
48
|
+
return BrowserAgentTaskResult(
|
|
49
|
+
task_id, status, tuple(runs),
|
|
50
|
+
tuple(item for item in errors if isinstance(item, str))
|
|
51
|
+
if isinstance(errors, list) else (),
|
|
52
|
+
)
|
|
53
|
+
def order(task_id: str) -> int:
|
|
54
|
+
prefix, separator, suffix = task_id.partition("-")
|
|
55
|
+
return int(suffix) if prefix == "task" and separator and suffix.isdigit() else 2**31
|
|
56
|
+
class RpcState:
|
|
57
|
+
def __init__(self, run_id: str, secrets: list[str], paths: list[str]) -> None:
|
|
58
|
+
self.run_id, self.secrets, self.paths = run_id, secrets, paths
|
|
59
|
+
self._results: dict[str, BrowserAgentTaskResult] = {}
|
|
60
|
+
@property
|
|
61
|
+
def results(self) -> tuple[BrowserAgentTaskResult, ...]:
|
|
62
|
+
return tuple(sorted(self._results.values(), key=lambda item: order(item.task_id)))
|
|
63
|
+
def handle(self, message: dict[str, object]):
|
|
64
|
+
if message.get("id") == 1:
|
|
65
|
+
return self._accept(message)
|
|
66
|
+
method = message.get("method")
|
|
67
|
+
params = message.get("params")
|
|
68
|
+
source = params if isinstance(params, dict) else {}
|
|
69
|
+
if method == "crafty/status":
|
|
70
|
+
category = source.get("category")
|
|
71
|
+
if category not in ("authentication", "otp", "verification", "payment"):
|
|
72
|
+
category = "other"
|
|
73
|
+
return UserTakeoverEvent(
|
|
74
|
+
self.run_id, str(source.get("task_id", "")),
|
|
75
|
+
str(source.get("reason", "")), category
|
|
76
|
+
)
|
|
77
|
+
if method == "crafty/task_result":
|
|
78
|
+
result = normalize_result(params)
|
|
79
|
+
result = replace(result, errors=tuple(
|
|
80
|
+
redact(error, self.secrets, self.paths) for error in result.errors
|
|
81
|
+
))
|
|
82
|
+
self._results[result.task_id] = result
|
|
83
|
+
return TaskResultEvent(self.run_id, result)
|
|
84
|
+
if method == "crafty/all_tasks_completed":
|
|
85
|
+
return "complete"
|
|
86
|
+
if method == "crafty/error":
|
|
87
|
+
raise BrowserAgentError(
|
|
88
|
+
"PROCESS_EXITED",
|
|
89
|
+
redact(str(source.get("message", "browser-agent failed.")),
|
|
90
|
+
self.secrets, self.paths),
|
|
91
|
+
)
|
|
92
|
+
def _accept(self, message: dict[str, object]):
|
|
93
|
+
error = message.get("error")
|
|
94
|
+
if isinstance(error, dict):
|
|
95
|
+
data = error.get("data")
|
|
96
|
+
code = data.get("code") if isinstance(data, dict) else None
|
|
97
|
+
code = code if code in ("CONFIG_INVALID", "CHROME_NOT_FOUND") else "PROTOCOL_ERROR"
|
|
98
|
+
raise BrowserAgentError(
|
|
99
|
+
code,
|
|
100
|
+
redact(str(error.get("message", "CLI rejected the run.")),
|
|
101
|
+
self.secrets, self.paths),
|
|
102
|
+
)
|
|
103
|
+
result = message.get("result")
|
|
104
|
+
if not isinstance(result, dict) or result.get("accepted") is not True:
|
|
105
|
+
raise BrowserAgentError("PROTOCOL_ERROR", "CLI did not accept the run.")
|
|
106
|
+
return RunStartedEvent(self.run_id)
|
browser_agent/run.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from .errors import BrowserAgentError, normalize_error
|
|
10
|
+
from .events import BrowserAgentEvent, ErrorEvent, ReplayEvents, RunCompletedEvent
|
|
11
|
+
from .models import (
|
|
12
|
+
BrowserAgentLogEntry,
|
|
13
|
+
BrowserAgentResult,
|
|
14
|
+
BrowserAgentTask,
|
|
15
|
+
Provider,
|
|
16
|
+
ReasoningEffort,
|
|
17
|
+
)
|
|
18
|
+
from .options import ResolvedOptions, child_environment, normalize_tasks, resolve_options
|
|
19
|
+
from .protocol import AgentProcess, consume_logs, request_run, start_agent_process, terminate_process
|
|
20
|
+
from .rpc import RpcState
|
|
21
|
+
from .runtime import (
|
|
22
|
+
DEFAULT_EXECUTABLE_DEPENDENCIES,
|
|
23
|
+
ExecutableDependencies,
|
|
24
|
+
RuntimeFiles,
|
|
25
|
+
create_runtime_files,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BrowserAgent:
|
|
30
|
+
def __init__(
|
|
31
|
+
self, *, provider: Provider, model: str, download_directory: str,
|
|
32
|
+
reasoning_effort: ReasoningEffort | None = None, api_key: str | None = None,
|
|
33
|
+
endpoint_url: str | None = None, headless: bool = False,
|
|
34
|
+
executable_path: str | None = None, workspace_directory: str | None = None,
|
|
35
|
+
user_takeover_tool: bool = True, max_steps: int = 50, concurrency: int = 4,
|
|
36
|
+
runs_per_task: int = 1, retry_count: int = 2,
|
|
37
|
+
on_log: Callable[[BrowserAgentLogEntry], None] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._options = resolve_options(**locals())
|
|
40
|
+
self._dependencies: ExecutableDependencies = DEFAULT_EXECUTABLE_DEPENDENCIES
|
|
41
|
+
self._executable: asyncio.Future[str] | None = None
|
|
42
|
+
|
|
43
|
+
def run(
|
|
44
|
+
self, value: BrowserAgentTask | Sequence[BrowserAgentTask], *,
|
|
45
|
+
on_event: Callable[[BrowserAgentEvent], None] | None = None,
|
|
46
|
+
) -> BrowserAgentRun:
|
|
47
|
+
tasks = normalize_tasks(value)
|
|
48
|
+
try:
|
|
49
|
+
asyncio.get_running_loop()
|
|
50
|
+
except RuntimeError as error:
|
|
51
|
+
raise BrowserAgentError(
|
|
52
|
+
"PROCESS_START_FAILED",
|
|
53
|
+
"run() must be called inside an active asyncio event loop.",
|
|
54
|
+
) from error
|
|
55
|
+
if self._executable is None:
|
|
56
|
+
self._executable = asyncio.create_task(self._verify())
|
|
57
|
+
return BrowserAgentRun(
|
|
58
|
+
str(uuid.uuid4()), self._options, tasks, self._executable, on_event
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def _verify(self) -> str:
|
|
62
|
+
executable = await self._dependencies.resolve()
|
|
63
|
+
await self._dependencies.verify(executable)
|
|
64
|
+
return executable
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BrowserAgentRun:
|
|
68
|
+
def __init__(
|
|
69
|
+
self, run_id: str, options: ResolvedOptions, tasks: list[BrowserAgentTask],
|
|
70
|
+
executable: asyncio.Future[str],
|
|
71
|
+
on_event: Callable[[BrowserAgentEvent], None] | None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self.id, self._options, self._tasks = run_id, options, tasks
|
|
74
|
+
self._on_event = on_event
|
|
75
|
+
self._stream = ReplayEvents[BrowserAgentEvent]()
|
|
76
|
+
self._process: AgentProcess | None = None
|
|
77
|
+
self._terminal: asyncio.Future[str] | None = None
|
|
78
|
+
self._cancel_requested = False
|
|
79
|
+
self._rpc: RpcState | None = None
|
|
80
|
+
self.result = asyncio.create_task(self._execute(executable))
|
|
81
|
+
|
|
82
|
+
def events(self) -> AsyncIterator[BrowserAgentEvent]:
|
|
83
|
+
return self._stream.iterate()
|
|
84
|
+
|
|
85
|
+
async def cancel(self) -> None:
|
|
86
|
+
self._cancel_requested = True
|
|
87
|
+
self._settle("cancelled")
|
|
88
|
+
if self._process:
|
|
89
|
+
await terminate_process(self._process)
|
|
90
|
+
with suppress(BaseException):
|
|
91
|
+
await self.result
|
|
92
|
+
|
|
93
|
+
def _publish(self, event: BrowserAgentEvent) -> None:
|
|
94
|
+
self._stream.publish(event)
|
|
95
|
+
try:
|
|
96
|
+
if self._on_event:
|
|
97
|
+
self._on_event(event)
|
|
98
|
+
except BaseException:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def _complete(self, status: str, started: datetime) -> BrowserAgentResult:
|
|
102
|
+
result = BrowserAgentResult(
|
|
103
|
+
self.id, status, self._rpc.results if self._rpc else (),
|
|
104
|
+
started, datetime.now(timezone.utc),
|
|
105
|
+
)
|
|
106
|
+
self._publish(RunCompletedEvent(self.id, result))
|
|
107
|
+
self._stream.close()
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
def _settle(self, value: str | BaseException) -> None:
|
|
111
|
+
if not self._terminal or self._terminal.done():
|
|
112
|
+
return
|
|
113
|
+
if isinstance(value, BaseException):
|
|
114
|
+
self._terminal.set_exception(value)
|
|
115
|
+
else:
|
|
116
|
+
self._terminal.set_result(value)
|
|
117
|
+
|
|
118
|
+
async def _pump(self, process: AgentProcess) -> None:
|
|
119
|
+
try:
|
|
120
|
+
async for message in process.messages():
|
|
121
|
+
event = self._rpc.handle(message)
|
|
122
|
+
if event == "complete":
|
|
123
|
+
self._settle("completed")
|
|
124
|
+
elif event:
|
|
125
|
+
self._publish(event)
|
|
126
|
+
except BaseException as error:
|
|
127
|
+
self._settle(error)
|
|
128
|
+
|
|
129
|
+
async def _watch_exit(self, process: AgentProcess) -> None:
|
|
130
|
+
await process.process.wait()
|
|
131
|
+
self._settle(
|
|
132
|
+
"cancelled" if self._cancel_requested else
|
|
133
|
+
BrowserAgentError("PROCESS_EXITED", "browser-agent exited early.")
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
async def _execute(self, executable: asyncio.Future[str]) -> BrowserAgentResult:
|
|
137
|
+
started, files = datetime.now(timezone.utc), None
|
|
138
|
+
secrets = ([self._options.api_key] if self._options.api_key else []) + [
|
|
139
|
+
value
|
|
140
|
+
for task in self._tasks
|
|
141
|
+
for credential in task.credentials
|
|
142
|
+
for value in (credential.username, credential.password, credential.domain)
|
|
143
|
+
]
|
|
144
|
+
try:
|
|
145
|
+
binary = await executable
|
|
146
|
+
if self._cancel_requested:
|
|
147
|
+
return self._complete("cancelled", started)
|
|
148
|
+
files = create_runtime_files(self._options, self._tasks)
|
|
149
|
+
self._process = await start_agent_process(
|
|
150
|
+
binary, files.config_path, child_environment(self._options)
|
|
151
|
+
)
|
|
152
|
+
self._rpc = RpcState(self.id, secrets, files.internal_paths)
|
|
153
|
+
logs = asyncio.create_task(consume_logs(
|
|
154
|
+
self._process, self.id, self._options.on_log, secrets, files.internal_paths
|
|
155
|
+
))
|
|
156
|
+
self._terminal = asyncio.get_running_loop().create_future()
|
|
157
|
+
asyncio.create_task(self._pump(self._process))
|
|
158
|
+
asyncio.create_task(self._watch_exit(self._process))
|
|
159
|
+
await request_run(self._process, self._tasks)
|
|
160
|
+
outcome = await self._terminal
|
|
161
|
+
if outcome == "cancelled":
|
|
162
|
+
await terminate_process(self._process)
|
|
163
|
+
else:
|
|
164
|
+
if await self._process.process.wait() != 0:
|
|
165
|
+
raise BrowserAgentError(
|
|
166
|
+
"PROCESS_EXITED", "browser-agent exited unsuccessfully."
|
|
167
|
+
)
|
|
168
|
+
if len(self._rpc.results) != len(self._tasks):
|
|
169
|
+
raise BrowserAgentError(
|
|
170
|
+
"PROTOCOL_ERROR", "browser-agent completed without all task results."
|
|
171
|
+
)
|
|
172
|
+
await logs
|
|
173
|
+
files.cleanup()
|
|
174
|
+
files = None
|
|
175
|
+
status = "cancelled" if outcome == "cancelled" else (
|
|
176
|
+
"failed" if any(item.status == "failed" for item in self._rpc.results)
|
|
177
|
+
else "completed"
|
|
178
|
+
)
|
|
179
|
+
return self._complete(status, started)
|
|
180
|
+
except BaseException as error:
|
|
181
|
+
if self._process:
|
|
182
|
+
await terminate_process(self._process)
|
|
183
|
+
if files:
|
|
184
|
+
files.cleanup()
|
|
185
|
+
normalized = normalize_error(
|
|
186
|
+
error, "PROCESS_EXITED", secrets, files.internal_paths if files else []
|
|
187
|
+
)
|
|
188
|
+
self._publish(ErrorEvent(self.id, normalized))
|
|
189
|
+
self._stream.close()
|
|
190
|
+
raise normalized
|
|
191
|
+
finally:
|
|
192
|
+
self._terminal = None
|
browser_agent/runtime.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Awaitable, Callable
|
|
13
|
+
|
|
14
|
+
from .errors import BrowserAgentError
|
|
15
|
+
from .models import BrowserAgentTask
|
|
16
|
+
from .options import ResolvedOptions
|
|
17
|
+
|
|
18
|
+
def platform_key(system: str | None = None, machine: str | None = None) -> str:
|
|
19
|
+
system = system or sys.platform
|
|
20
|
+
machine = (machine or platform.machine()).lower()
|
|
21
|
+
architecture = {"aarch64": "arm64", "amd64": "x64", "x86_64": "x64"}.get(machine, machine)
|
|
22
|
+
return f"{system}-{architecture}"
|
|
23
|
+
def bundled_executable(system: str | None = None, machine: str | None = None) -> Path:
|
|
24
|
+
system = system or sys.platform
|
|
25
|
+
suffix = ".exe" if system == "win32" else ""
|
|
26
|
+
return Path(__file__).parent / "bin" / platform_key(system, machine) / f"browser-agent{suffix}"
|
|
27
|
+
async def resolve_executable(executable: Path | None = None) -> str:
|
|
28
|
+
executable = executable or bundled_executable()
|
|
29
|
+
if not executable.is_file() or not os.access(executable, os.X_OK):
|
|
30
|
+
raise BrowserAgentError(
|
|
31
|
+
"CLI_NOT_FOUND",
|
|
32
|
+
f"Bundled browser-agent executable is unavailable for {platform_key()}.",
|
|
33
|
+
)
|
|
34
|
+
return str(executable)
|
|
35
|
+
async def verify_executable(executable: str, timeout: float = 5) -> None:
|
|
36
|
+
try:
|
|
37
|
+
process = await asyncio.create_subprocess_exec(
|
|
38
|
+
executable, "--version-json",
|
|
39
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
40
|
+
)
|
|
41
|
+
stdout, _ = await asyncio.wait_for(process.communicate(), timeout)
|
|
42
|
+
except TimeoutError as error:
|
|
43
|
+
process.kill()
|
|
44
|
+
await process.wait()
|
|
45
|
+
raise BrowserAgentError(
|
|
46
|
+
"CLI_VERSION_INCOMPATIBLE", "Bundled browser-agent version check timed out."
|
|
47
|
+
) from error
|
|
48
|
+
except OSError as error:
|
|
49
|
+
raise BrowserAgentError(
|
|
50
|
+
"CLI_NOT_FOUND", "Bundled browser-agent executable could not be started."
|
|
51
|
+
) from error
|
|
52
|
+
try:
|
|
53
|
+
if process.returncode or json.loads(stdout)["rpcProtocolVersion"] != 1:
|
|
54
|
+
raise ValueError
|
|
55
|
+
except (ValueError, KeyError, json.JSONDecodeError) as error:
|
|
56
|
+
raise BrowserAgentError(
|
|
57
|
+
"CLI_VERSION_INCOMPATIBLE",
|
|
58
|
+
"Bundled browser-agent uses an incompatible RPC protocol.",
|
|
59
|
+
) from error
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class ExecutableDependencies:
|
|
62
|
+
resolve: Callable[[], Awaitable[str]]
|
|
63
|
+
verify: Callable[[str], Awaitable[None]]
|
|
64
|
+
DEFAULT_EXECUTABLE_DEPENDENCIES = ExecutableDependencies(
|
|
65
|
+
resolve_executable, verify_executable
|
|
66
|
+
)
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class RuntimeFiles:
|
|
69
|
+
config_path: str
|
|
70
|
+
internal_paths: list[str]
|
|
71
|
+
def cleanup(self) -> None:
|
|
72
|
+
for owned in self.internal_paths:
|
|
73
|
+
shutil.rmtree(owned, ignore_errors=True)
|
|
74
|
+
def create_runtime_files(options: ResolvedOptions, tasks: list[BrowserAgentTask]):
|
|
75
|
+
owned: list[str] = []
|
|
76
|
+
try:
|
|
77
|
+
runtime = tempfile.mkdtemp(prefix="browser-agent-sdk-")
|
|
78
|
+
owned.append(runtime)
|
|
79
|
+
os.chmod(runtime, 0o700)
|
|
80
|
+
workspace = options.workspace_directory
|
|
81
|
+
if workspace is None:
|
|
82
|
+
workspace = tempfile.mkdtemp(prefix=".browser-agent-workspace-", dir=os.getcwd())
|
|
83
|
+
owned.append(workspace)
|
|
84
|
+
Path(options.download_directory).mkdir(parents=True, exist_ok=True)
|
|
85
|
+
Path(workspace).mkdir(parents=True, exist_ok=True)
|
|
86
|
+
config_path = str(Path(runtime) / "config.yaml")
|
|
87
|
+
config = {
|
|
88
|
+
"provider": options.provider, "model": options.model,
|
|
89
|
+
"reasoning_effort": options.reasoning_effort,
|
|
90
|
+
**({"endpoint_url": options.endpoint_url} if options.endpoint_url else {}),
|
|
91
|
+
"feature_flags": {"user_takeover_tool": options.user_takeover_tool},
|
|
92
|
+
"headless": options.headless,
|
|
93
|
+
**({"executable_path": options.executable_path} if options.executable_path else {}),
|
|
94
|
+
"download_dir": options.download_directory, "file_workspace_root": workspace,
|
|
95
|
+
"max_steps": options.max_steps, "concurrency": options.concurrency,
|
|
96
|
+
"task_runs": options.runs_per_task, "task_run_retry_count": options.retry_count,
|
|
97
|
+
"validator_lifecycle": {"mode": "terminal", "max_failures": 3},
|
|
98
|
+
"wait_between_tasks_ms": 0, "save_steps_context": True, "save_task_logs": False,
|
|
99
|
+
"step_messages_jsonl_path": str(Path(runtime) / "steps.jsonl"),
|
|
100
|
+
"tasks": [{"task": task.task, **({"url": task.url} if task.url else {})}
|
|
101
|
+
for task in tasks],
|
|
102
|
+
}
|
|
103
|
+
descriptor = os.open(config_path, os.O_WRONLY | os.O_CREAT, 0o600)
|
|
104
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
105
|
+
json.dump(config, output)
|
|
106
|
+
return RuntimeFiles(config_path, owned)
|
|
107
|
+
except BaseException:
|
|
108
|
+
for path in owned:
|
|
109
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
110
|
+
raise
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: browser-agent-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Crafty Browser Agent
|
|
5
|
+
Project-URL: Homepage, https://getcrafty.io
|
|
6
|
+
Project-URL: Repository, https://github.com/pb05/operator
|
|
7
|
+
Project-URL: Issues, https://github.com/pb05/operator/issues
|
|
8
|
+
Author: Crafty
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE.md
|
|
11
|
+
Keywords: ai,automation,browser,browser-agent,llm
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: coverage[toml]<8,>=7.6; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Browser Agent Python SDK
|
|
28
|
+
|
|
29
|
+
Dependency-free async Python wrapper for the bundled `browser-agent`
|
|
30
|
+
executable. Requires Python 3.11 or newer.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install browser-agent-python-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import os
|
|
38
|
+
|
|
39
|
+
from browser_agent import BrowserAgent, BrowserAgentCredential, BrowserAgentTask
|
|
40
|
+
|
|
41
|
+
agent = BrowserAgent(
|
|
42
|
+
provider="openai",
|
|
43
|
+
model="gpt-5.4",
|
|
44
|
+
download_directory="./downloads",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
run = agent.run(
|
|
48
|
+
BrowserAgentTask(
|
|
49
|
+
"Open my account",
|
|
50
|
+
"https://example.com",
|
|
51
|
+
credentials=(
|
|
52
|
+
BrowserAgentCredential(
|
|
53
|
+
username="person@example.com",
|
|
54
|
+
password=os.environ["EXAMPLE_PASSWORD"],
|
|
55
|
+
domain="https://example.com",
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
async for event in run.events():
|
|
61
|
+
print(event)
|
|
62
|
+
result = await run.result
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Call `run()` inside an active asyncio loop. API keys may be passed as `api_key`
|
|
66
|
+
or inherited from the matching provider environment variable. They are passed
|
|
67
|
+
only to the child process.
|
|
68
|
+
|
|
69
|
+
Task credentials are sent only over the child process's local stdin stream,
|
|
70
|
+
encrypted immediately by the CLI, excluded from generated configuration, and
|
|
71
|
+
redacted from SDK logs and errors.
|
|
72
|
+
|
|
73
|
+
Development:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
uv run --extra test coverage run -m unittest discover -s tests
|
|
77
|
+
uv run --extra test coverage report
|
|
78
|
+
uv build --wheel
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Coverage fails below 100% statements or branches.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
browser_agent/__init__.py,sha256=8O7WmsPpL9Drg9UK_BKJ-41xQA6nFP08P-21mZPhyxg,888
|
|
2
|
+
browser_agent/errors.py,sha256=ZoXXGRfFpA_KBbbyIAXODoC1T5jmvEZIp-lKhfwDuxg,1207
|
|
3
|
+
browser_agent/events.py,sha256=IUsIajBe4uhH3A8ckEDT63UpLhGUhQNEknctXJ8YV20,2244
|
|
4
|
+
browser_agent/models.py,sha256=U_t6gJi0cbvF68izYR5JEXC9Bh--yhx8U4RtJ_P8w6g,1594
|
|
5
|
+
browser_agent/options.py,sha256=MUbn2JXxYWC6ueuTTuspCWXUPRKhcKWH4fRFG7RrvVg,6161
|
|
6
|
+
browser_agent/protocol.py,sha256=4lkGGSxWGSm_3vEmSVPL2Sez3zHL1Kdl4IT33ngK5Rw,3462
|
|
7
|
+
browser_agent/rpc.py,sha256=5cr_r6xnzNjNGis43MAqREc8QlWF3RqJwvW5CzvUWhc,4626
|
|
8
|
+
browser_agent/run.py,sha256=iLzO8gjHH4nSaxT_vqto065Y2BqPhVdirFu-OLMTPZg,7702
|
|
9
|
+
browser_agent/runtime.py,sha256=1FDwO2bmSv1CRAxuzIvnlPtixie7mzzOGANSKfArcmM,4949
|
|
10
|
+
browser_agent_python_sdk-0.1.0.dist-info/METADATA,sha256=2QJ9E0R2NVze-6LtbcFTEyOQP4VtW6Fycr9pKQlpIMo,2438
|
|
11
|
+
browser_agent_python_sdk-0.1.0.dist-info/WHEEL,sha256=NIjYS0v0Um9i20VOZcStv8f2DIdr7FbyXbarsfXbfSk,102
|
|
12
|
+
browser_agent_python_sdk-0.1.0.dist-info/licenses/LICENSE.md,sha256=VVWZ9N-YU8mvzb1nJBnK4WKbHwijl5PSPz0F7XozB7o,1071
|
|
13
|
+
browser_agent_python_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Crafty
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the “Software”), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
9
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
10
|
+
so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|