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.
@@ -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()
@@ -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))
@@ -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()
@@ -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
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-macosx_15_0_arm64
@@ -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.