sbxloop 0.2.0__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.
sbxloop/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """sbxloop — agentic loop orchestration on Docker Sandboxes (sbx).
2
+
3
+ Every sbxloop operation runs as a pair of isolated microVM sandboxes: an agent
4
+ sandbox holding only ``COPILOT_GITHUB_TOKEN`` for the GitHub Copilot SDK
5
+ agentic layer, and a GitHub-ops sandbox holding only ``GH_TOKEN`` for
6
+ user-facing GitHub interactions. The balanced network policy is the default.
7
+ """
8
+
9
+ __version__ = "0.2.0"
10
+
11
+ from sbxloop.config import Budgets, Config, load_config
12
+ from sbxloop.engine import LoopEngine, RunResult, run_outcome
13
+ from sbxloop.events import Event, EventBus, Hook
14
+
15
+ __all__ = [
16
+ "Budgets",
17
+ "Config",
18
+ "Event",
19
+ "EventBus",
20
+ "Hook",
21
+ "LoopEngine",
22
+ "RunResult",
23
+ "__version__",
24
+ "load_config",
25
+ "run_outcome",
26
+ ]
@@ -0,0 +1 @@
1
+ """The sbxloop command-line interface."""
sbxloop/cli/app.py ADDED
@@ -0,0 +1,365 @@
1
+ """The sbxloop CLI: run agentic loops on Docker Sandboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.live import Live
13
+ from rich.table import Table
14
+
15
+ import sbxloop
16
+ from sbxloop.cli.doctor import run_doctor
17
+ from sbxloop.cli.tui import Dashboard, format_event, plain_printer
18
+ from sbxloop.config import Config, load_config, load_config_with_sources, load_dotenv_file
19
+ from sbxloop.engine.engine import LoopEngine
20
+ from sbxloop.engine.model import RunResult
21
+ from sbxloop.engine.store import StateStore
22
+ from sbxloop.errors import SdxloopError
23
+ from sbxloop.sbx.cli import SbxCLI
24
+ from sbxloop.sbx.provision import sandbox_name
25
+
26
+ app = typer.Typer(
27
+ name="sbxloop",
28
+ help="Agentic loop orchestration on Docker Sandboxes with isolated credentials.",
29
+ no_args_is_help=True,
30
+ pretty_exceptions_show_locals=False,
31
+ )
32
+ sandbox_app = typer.Typer(help="Manage sbxloop sandboxes.", no_args_is_help=True)
33
+ config_app = typer.Typer(help="Inspect configuration.", no_args_is_help=True)
34
+ app.add_typer(sandbox_app, name="sandbox")
35
+ app.add_typer(config_app, name="config")
36
+
37
+ console = Console()
38
+
39
+
40
+ def _version_callback(value: bool) -> None:
41
+ if value:
42
+ console.print(f"sbxloop {sbxloop.__version__}")
43
+ raise typer.Exit()
44
+
45
+
46
+ @app.callback()
47
+ def _main_callback(
48
+ version: Annotated[
49
+ bool, typer.Option("--version", callback=_version_callback, is_eager=True)
50
+ ] = False,
51
+ ) -> None:
52
+ """sbxloop — agentic loops on Docker Sandboxes."""
53
+ # Every command sees ./.env (tokens + SBXLOOP_* settings); real
54
+ # environment variables always take precedence.
55
+ load_dotenv_file()
56
+
57
+
58
+ def _config_with_overrides(**overrides: Any) -> Config:
59
+ config = load_config()
60
+ updates = {k: v for k, v in overrides.items() if v is not None}
61
+ return config.model_copy(update=updates) if updates else config
62
+
63
+
64
+ def _store(config: Config) -> StateStore:
65
+ return StateStore(config.state_dir / "state.db")
66
+
67
+
68
+ def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
69
+ """Run start/resume with either the live dashboard or plain event logs."""
70
+ if not tui:
71
+ engine.bus.subscribe(plain_printer(console))
72
+ return action() # type: ignore[no-any-return]
73
+
74
+ dashboard = Dashboard()
75
+ engine.bus.subscribe(dashboard.on_event)
76
+ outcome: dict[str, Any] = {}
77
+
78
+ def target() -> None:
79
+ try:
80
+ outcome["result"] = action()
81
+ except BaseException as exc:
82
+ outcome["error"] = exc
83
+
84
+ thread = threading.Thread(target=target, daemon=True)
85
+ with Live(dashboard.renderable(), console=console, refresh_per_second=8) as live:
86
+ thread.start()
87
+ while thread.is_alive():
88
+ live.update(dashboard.renderable())
89
+ time.sleep(0.15)
90
+ live.update(dashboard.renderable())
91
+ if "error" in outcome:
92
+ raise outcome["error"]
93
+ return outcome["result"] # type: ignore[no-any-return]
94
+
95
+
96
+ def _finish(result: RunResult) -> None:
97
+ style = "green" if result.succeeded else "red"
98
+ console.print(f"\nrun [bold cyan]{result.run_id}[/] finished: [bold {style}]{result.state}[/]")
99
+ for task in result.tasks:
100
+ console.print(f" {task.spec.id}: {task.state} ({task.spec.title})")
101
+ raise typer.Exit(0 if result.succeeded else 1)
102
+
103
+
104
+ @app.command()
105
+ def run(
106
+ outcome: Annotated[str, typer.Argument(help="The outcome to achieve.")],
107
+ repo: Annotated[
108
+ str | None, typer.Option("--repo", help="owner/repo for GitHub progress reporting.")
109
+ ] = None,
110
+ model: Annotated[str | None, typer.Option("--model", help="Copilot model id.")] = None,
111
+ keep_sandboxes: Annotated[
112
+ bool, typer.Option("--keep-sandboxes", help="Do not remove sandboxes at the end.")
113
+ ] = False,
114
+ tui: Annotated[bool, typer.Option("--tui/--no-tui", help="Live dashboard.")] = True,
115
+ ) -> None:
116
+ """Run an agentic loop for OUTCOME in a fresh sandbox pair."""
117
+ config = _config_with_overrides(model=model, keep_sandboxes=keep_sandboxes or None)
118
+ if repo:
119
+ config = config.model_copy(
120
+ update={"github": config.github.model_copy(update={"report_repo": repo})}
121
+ )
122
+ engine = LoopEngine(config)
123
+ try:
124
+ result = _drive_with_ui(engine, tui=tui, action=lambda: engine.start(outcome))
125
+ except SdxloopError as exc:
126
+ console.print(f"[bold red]run failed:[/] {exc}")
127
+ raise typer.Exit(2) from exc
128
+ _finish(result)
129
+
130
+
131
+ @app.command()
132
+ def resume(
133
+ run_id: Annotated[str, typer.Argument(help="Run id to resume.")],
134
+ tui: Annotated[bool, typer.Option("--tui/--no-tui")] = True,
135
+ ) -> None:
136
+ """Resume an unfinished run (fresh sandboxes, persisted state)."""
137
+ engine = LoopEngine(load_config())
138
+ try:
139
+ result = _drive_with_ui(engine, tui=tui, action=lambda: engine.resume(run_id))
140
+ except SdxloopError as exc:
141
+ console.print(f"[bold red]resume failed:[/] {exc}")
142
+ raise typer.Exit(2) from exc
143
+ _finish(result)
144
+
145
+
146
+ @app.command()
147
+ def cancel(run_id: Annotated[str, typer.Argument()]) -> None:
148
+ """Mark a run cancelled (takes effect at the next phase boundary)."""
149
+ config = load_config()
150
+ engine = LoopEngine(config)
151
+ try:
152
+ engine.cancel(run_id)
153
+ except SdxloopError as exc:
154
+ console.print(f"[bold red]{exc}[/]")
155
+ raise typer.Exit(2) from exc
156
+ console.print(f"run {run_id} cancelled")
157
+
158
+
159
+ @app.command()
160
+ def status(
161
+ run_id: Annotated[str | None, typer.Argument(help="Run id for details.")] = None,
162
+ ) -> None:
163
+ """List runs, or show one run's tasks and phase history."""
164
+ config = load_config()
165
+ store = _store(config)
166
+ if run_id is None:
167
+ table = Table(title="sbxloop runs")
168
+ table.add_column("run")
169
+ table.add_column("state")
170
+ table.add_column("outcome", max_width=60)
171
+ table.add_column("updated")
172
+ for record in store.list_runs():
173
+ table.add_row(
174
+ record.run_id,
175
+ record.state,
176
+ record.outcome[:60],
177
+ time.strftime("%Y-%m-%d %H:%M", time.localtime(record.updated_at)),
178
+ )
179
+ console.print(table)
180
+ return
181
+
182
+ try:
183
+ record = store.get_run(run_id)
184
+ except SdxloopError as exc:
185
+ console.print(f"[bold red]{exc}[/]")
186
+ raise typer.Exit(2) from exc
187
+ console.print(f"run [bold cyan]{record.run_id}[/] state: [bold]{record.state}[/]")
188
+ console.print(f"outcome: {record.outcome}")
189
+ table = Table(title="tasks")
190
+ for column in ("task", "title", "state", "revisions", "replans"):
191
+ table.add_column(column)
192
+ for task in store.get_tasks(run_id):
193
+ table.add_row(
194
+ task.spec.id,
195
+ task.spec.title,
196
+ task.state,
197
+ str(task.revisions),
198
+ str(task.replans),
199
+ )
200
+ console.print(table)
201
+ attempts = store.phase_attempts(run_id)
202
+ console.print(f"{len(attempts)} phase attempts recorded")
203
+
204
+
205
+ @app.command()
206
+ def logs(
207
+ run_id: Annotated[str, typer.Argument()],
208
+ follow: Annotated[bool, typer.Option("--follow", "-f")] = False,
209
+ type_prefix: Annotated[
210
+ str | None, typer.Option("--type", help="Filter by event type prefix.")
211
+ ] = None,
212
+ task: Annotated[str | None, typer.Option("--task", help="Filter by task id.")] = None,
213
+ ) -> None:
214
+ """Replay (or tail) a run's event stream from the state store."""
215
+ config = load_config()
216
+ store = _store(config)
217
+ store.get_run(run_id) # validates existence
218
+ last_seq = 0
219
+ while True:
220
+ for seq, event in store.events(run_id, after_seq=last_seq, type_prefix=type_prefix):
221
+ last_seq = seq
222
+ if task and event.data.get("task_id") != task:
223
+ continue
224
+ console.print(format_event(event), highlight=False)
225
+ if not follow:
226
+ break
227
+ if store.get_run(run_id).state in ("completed", "failed", "cancelled"):
228
+ break
229
+ time.sleep(0.5)
230
+
231
+
232
+ @sandbox_app.command("ls")
233
+ def sandbox_ls() -> None:
234
+ """List sbxloop-managed sandboxes."""
235
+ config = load_config()
236
+ cli = SbxCLI(app_name=config.app_name or None)
237
+ table = Table(title="sbxloop sandboxes")
238
+ for column in ("name", "agent", "status", "workspace"):
239
+ table.add_column(column)
240
+ for info in cli.ls():
241
+ if info.name.startswith("sbxloop-"):
242
+ table.add_row(info.name, info.agent or "", info.status or "", info.workspace or "")
243
+ console.print(table)
244
+
245
+
246
+ @sandbox_app.command("rm")
247
+ def sandbox_rm(
248
+ name: Annotated[str | None, typer.Argument(help="Sandbox name.")] = None,
249
+ run_id: Annotated[str | None, typer.Option("--run", help="Remove a run's pair.")] = None,
250
+ all_: Annotated[bool, typer.Option("--all", help="Remove all sbxloop sandboxes.")] = False,
251
+ ) -> None:
252
+ """Remove sbxloop sandboxes by name, by run, or all of them."""
253
+ config = load_config()
254
+ cli = SbxCLI(app_name=config.app_name or None)
255
+ targets: list[str] = []
256
+ if name:
257
+ targets.append(name)
258
+ if run_id:
259
+ targets += [sandbox_name(run_id, "agent"), sandbox_name(run_id, "github")]
260
+ if all_:
261
+ targets += [i.name for i in cli.ls() if i.name.startswith("sbxloop-")]
262
+ if not targets:
263
+ console.print("nothing to remove: pass a NAME, --run, or --all")
264
+ raise typer.Exit(2)
265
+ for target in dict.fromkeys(targets):
266
+ try:
267
+ cli.rm(target)
268
+ console.print(f"removed {target}")
269
+ except SdxloopError as exc:
270
+ console.print(f"[yellow]skip {target}:[/] {exc}")
271
+
272
+
273
+ @config_app.command("show")
274
+ def config_show() -> None:
275
+ """Show the resolved configuration and where each value came from."""
276
+ try:
277
+ config, sources = load_config_with_sources()
278
+ except SdxloopError as exc:
279
+ console.print(f"[bold red]{exc}[/]")
280
+ raise typer.Exit(2) from exc
281
+ table = Table(title="sbxloop configuration")
282
+ table.add_column("key")
283
+ table.add_column("value")
284
+ table.add_column("source")
285
+ flat: dict[str, Any] = {}
286
+
287
+ def flatten(prefix: str, data: dict[str, Any]) -> None:
288
+ for key, value in data.items():
289
+ dotted = f"{prefix}{key}"
290
+ if isinstance(value, dict):
291
+ flatten(f"{dotted}.", value)
292
+ else:
293
+ flat[dotted] = value
294
+
295
+ flatten("", config.model_dump(mode="json"))
296
+ for dotted in sorted(flat):
297
+ table.add_row(dotted, repr(flat[dotted]), sources.get(dotted, "default"))
298
+ console.print(table)
299
+
300
+
301
+ @app.command()
302
+ def init(
303
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
304
+ ) -> None:
305
+ """Write a commented sbxloop.toml with the default configuration."""
306
+ path = Path("sbxloop.toml")
307
+ if path.exists() and not force:
308
+ console.print("sbxloop.toml already exists (use --force to overwrite)")
309
+ raise typer.Exit(2)
310
+ path.write_text(DEFAULT_CONFIG_TOML)
311
+ console.print(f"wrote {path}")
312
+
313
+
314
+ @app.command()
315
+ def doctor() -> None:
316
+ """Check that this host is ready to run sbxloop."""
317
+ ok = run_doctor(console)
318
+ raise typer.Exit(0 if ok else 1)
319
+
320
+
321
+ DEFAULT_CONFIG_TOML = """\
322
+ # sbxloop configuration. Every key is optional; these are the defaults.
323
+ # Precedence: SBXLOOP_* env vars > this file > pyproject [tool.sbxloop].
324
+
325
+ # Copilot model for agent sessions ("auto" lets the SDK choose).
326
+ model = "auto"
327
+ # Optional sbx --app-name isolating sbxloop's sandboxes/policies/secrets.
328
+ # Empty shares your normal sbx state (your login + balanced policy apply).
329
+ # If set, that isolated state needs its own `sbx --app-name <name> login`
330
+ # and `sbx --app-name <name> policy init balanced`.
331
+ app_name = ""
332
+ # Where run state (SQLite) and per-run workspaces live.
333
+ state_dir = ".sbxloop"
334
+ # Keep sandboxes around after a run (for debugging).
335
+ keep_sandboxes = false
336
+ # Worker transport: "stream" (default) or "poll".
337
+ worker_transport = "stream"
338
+ # Secret injection: "proxy" (sbx keychain proxy; recommended) or "plain-env".
339
+ secret_strategy = "proxy"
340
+
341
+ [sandbox]
342
+ # Custom sandbox template reference (defaults to the sbx shell template).
343
+ # template = "docker.io/you/your-template:v1"
344
+ # Extra network allow rules applied to both sandboxes.
345
+ extra_allow_domains = []
346
+
347
+ [github]
348
+ # Report run progress to a GitHub repo ("owner/repo"); empty disables.
349
+ # report_repo = "you/your-repo"
350
+
351
+ [budgets]
352
+ max_revisions_per_task = 2
353
+ max_replans_per_task = 1
354
+ max_tasks = 20
355
+ max_wall_clock_s = 7200.0
356
+ per_job_timeout_s = 900.0
357
+ """
358
+
359
+
360
+ def main() -> None:
361
+ app()
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()
sbxloop/cli/doctor.py ADDED
@@ -0,0 +1,172 @@
1
+ """sbxloop doctor: host readiness checks with remediation hints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from sbxloop.config import load_config
12
+ from sbxloop.errors import SbxError, SbxNotFoundError
13
+ from sbxloop.sbx.cli import SbxCLI
14
+ from sbxloop.sbx.provision import (
15
+ AGENT_TOKEN_HOSTS,
16
+ COPILOT_TOKEN_ENV,
17
+ GH_TOKEN_ENVS,
18
+ )
19
+ from sbxloop.worker.wheel import resolve_worker_wheel
20
+
21
+ TESTED_SBX_SERIES = "0.35"
22
+
23
+
24
+ @dataclass
25
+ class Check:
26
+ name: str
27
+ ok: bool
28
+ detail: str
29
+ hard: bool = True
30
+
31
+
32
+ ProgressFn = Callable[[str], None]
33
+
34
+
35
+ def collect_checks(
36
+ env: dict[str, str],
37
+ cli: SbxCLI | None = None,
38
+ progress: ProgressFn | None = None,
39
+ ) -> list[Check]:
40
+ config = load_config(env=env)
41
+ cli = cli or SbxCLI(app_name=config.app_name or None)
42
+ checks: list[Check] = []
43
+ report = progress or (lambda _message: None)
44
+
45
+ # sbx binary + version. The very first sbx invocation may trigger
46
+ # Docker's interactive browser login if this sbx application state has
47
+ # never authenticated - say so instead of appearing hung.
48
+ report("checking sbx binary (Docker may open a browser window for authentication on first use)")
49
+ try:
50
+ version = cli.version()
51
+ except SbxNotFoundError:
52
+ checks.append(
53
+ Check(
54
+ "sbx binary",
55
+ False,
56
+ "sbx not found on PATH — install Docker Sandboxes: "
57
+ "https://docs.docker.com/ai/sandboxes/",
58
+ )
59
+ )
60
+ version = None
61
+ else:
62
+ detail = f"found sbx {version or '(unknown version)'}"
63
+ if version and not version.startswith(TESTED_SBX_SERIES):
64
+ detail += f" (sbxloop is tested against {TESTED_SBX_SERIES}.x; parsing may drift)"
65
+ checks.append(Check("sbx binary", True, detail))
66
+
67
+ if version is not None:
68
+ # login / daemon reachable
69
+ report("checking sbx login")
70
+ try:
71
+ cli.ls()
72
+ checks.append(Check("sbx login", True, "sbx ls succeeded"))
73
+ except SbxError as exc:
74
+ login_cmd = (
75
+ f"sbx --app-name {config.app_name} login" if config.app_name else "sbx login"
76
+ )
77
+ checks.append(Check("sbx login", False, f"sbx ls failed ({exc}); run `{login_cmd}`"))
78
+
79
+ # network policy reachable for the copilot hosts
80
+ for host in AGENT_TOKEN_HOSTS:
81
+ report(f"checking network policy for {host}")
82
+ allowed = False
83
+ try:
84
+ allowed = cli.policy_check(host)
85
+ except SbxError:
86
+ allowed = False
87
+ checks.append(
88
+ Check(
89
+ f"policy: {host}",
90
+ allowed,
91
+ "reachable"
92
+ if allowed
93
+ else (
94
+ "blocked — run `sbx policy init balanced` and/or "
95
+ f"`sbx policy allow network {host}`"
96
+ ),
97
+ hard=False, # per-sandbox allows are applied at provision time
98
+ )
99
+ )
100
+
101
+ # tokens
102
+ checks.append(
103
+ Check(
104
+ COPILOT_TOKEN_ENV,
105
+ bool(env.get(COPILOT_TOKEN_ENV)),
106
+ "set"
107
+ if env.get(COPILOT_TOKEN_ENV)
108
+ else 'not set — create a fine-grained PAT with the "Copilot Requests" '
109
+ f"permission and export {COPILOT_TOKEN_ENV}",
110
+ )
111
+ )
112
+ gh_set = any(env.get(name) for name in GH_TOKEN_ENVS)
113
+ checks.append(
114
+ Check(
115
+ "/".join(GH_TOKEN_ENVS),
116
+ gh_set,
117
+ "set"
118
+ if gh_set
119
+ else "not set — create a fine-grained PAT (issues:write, contents:read, ...) "
120
+ "and export GH_TOKEN",
121
+ )
122
+ )
123
+
124
+ # worker wheel
125
+ wheel = resolve_worker_wheel()
126
+ checks.append(
127
+ Check(
128
+ "worker wheel",
129
+ True,
130
+ f"resolved: {wheel.name}" if wheel else "no local wheel; will install from PyPI",
131
+ hard=False,
132
+ )
133
+ )
134
+
135
+ # state dir writable
136
+ try:
137
+ config.state_dir.mkdir(parents=True, exist_ok=True)
138
+ probe = config.state_dir / ".doctor-probe"
139
+ probe.write_text("ok")
140
+ probe.unlink()
141
+ checks.append(Check("state dir", True, str(config.state_dir.resolve())))
142
+ except OSError as exc:
143
+ checks.append(Check("state dir", False, f"not writable: {exc}"))
144
+
145
+ return checks
146
+
147
+
148
+ def _clean(detail: str, limit: int = 300) -> str:
149
+ """Collapse whitespace/newlines so multi-line sbx stderr can't shatter
150
+ the table layout; long tails are elided."""
151
+ flat = " ".join(detail.split())
152
+ return flat if len(flat) <= limit else flat[: limit - 1] + "\u2026"
153
+
154
+
155
+ def run_doctor(console: Console, env: dict[str, str] | None = None) -> bool:
156
+ import os
157
+
158
+ checks = collect_checks(
159
+ dict(os.environ) if env is None else env,
160
+ progress=lambda message: console.print(f"[dim]\u2026 {message}[/dim]", highlight=False),
161
+ )
162
+ table = Table(title="sbxloop doctor")
163
+ table.add_column("check", no_wrap=True)
164
+ table.add_column("status", no_wrap=True)
165
+ table.add_column("detail", overflow="fold")
166
+ for check in checks:
167
+ status = (
168
+ "[green]ok[/]" if check.ok else ("[red]FAIL[/]" if check.hard else "[yellow]warn[/]")
169
+ )
170
+ table.add_row(check.name, status, _clean(check.detail))
171
+ console.print(table)
172
+ return all(check.ok or not check.hard for check in checks)