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/cli/tui.py ADDED
@@ -0,0 +1,206 @@
1
+ """Rich rendering for runs: a chat-style live transcript and plain logging.
2
+
3
+ Agent output is conversational (markdown, fenced code blocks), so the live
4
+ view renders it as a chat thread — markdown panels with syntax-highlighted
5
+ code — rather than truncated log lines. Host lifecycle events stay compact
6
+ one-liners. ``format_event`` remains the dense single-line form used by
7
+ ``sbxloop logs``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ from collections import deque
14
+ from typing import Any
15
+
16
+ from rich.console import Console, Group, RenderableType
17
+ from rich.markdown import Markdown
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ from sbxloop.events import Event, HostEventTypes
23
+ from sbxloop_worker.protocol import EventTypes
24
+
25
+ TASK_STATE_STYLES = {
26
+ "pending": "dim",
27
+ "planning": "yellow",
28
+ "executing": "cyan",
29
+ "scrutinizing": "magenta",
30
+ "verifying": "blue",
31
+ "validating": "magenta",
32
+ "done": "green",
33
+ "failed": "red",
34
+ "skipped": "dim red",
35
+ }
36
+
37
+ # Compact one-liner lifecycle events (chat "system" messages).
38
+ _LIFECYCLE_PREFIXES = ("run.", "task.", "phase.", "sandbox.", "gh.")
39
+
40
+ # High-volume noise excluded from the transcript (still queryable via
41
+ # `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats.
42
+ _TRANSCRIPT_SKIP = {
43
+ EventTypes.AGENT_MESSAGE_DELTA,
44
+ EventTypes.WORKER_STDOUT,
45
+ EventTypes.WORKER_HEARTBEAT,
46
+ EventTypes.WORKER_START,
47
+ EventTypes.WORKER_RESULT,
48
+ EventTypes.WORKER_END,
49
+ EventTypes.AGENT_USAGE,
50
+ }
51
+
52
+ AGENT_MESSAGE_CLIP = 4000
53
+
54
+
55
+ def _stamp(event: Event) -> str:
56
+ return datetime.datetime.fromtimestamp(event.ts).strftime("%H:%M:%S")
57
+
58
+
59
+ def render_event(event: Event) -> RenderableType | None:
60
+ """One transcript entry for an event, or None when it should be skipped."""
61
+ if event.type in _TRANSCRIPT_SKIP:
62
+ return None
63
+ data = event.data
64
+
65
+ if event.type == EventTypes.AGENT_MESSAGE:
66
+ content = str(data.get("content", "")).strip()
67
+ if not content:
68
+ return None
69
+ if len(content) > AGENT_MESSAGE_CLIP:
70
+ content = content[:AGENT_MESSAGE_CLIP] + "\n\n*…truncated — see `sbxloop logs`*"
71
+ return Panel(
72
+ Markdown(content),
73
+ title=f"[bold cyan]agent[/] [dim]{_stamp(event)}[/]",
74
+ title_align="left",
75
+ border_style="cyan",
76
+ padding=(0, 1),
77
+ )
78
+
79
+ if event.type == EventTypes.WORKER_ERROR:
80
+ message = str(data.get("message", "")) or str(data.get("error_type", "error"))
81
+ return Panel(
82
+ Text(message, style="red", overflow="fold"),
83
+ title=f"[bold red]error[/] [dim]{_stamp(event)}[/]",
84
+ title_align="left",
85
+ border_style="red",
86
+ padding=(0, 1),
87
+ )
88
+
89
+ if event.type == EventTypes.AGENT_TOOL_START:
90
+ tool = data.get("tool") or "tool"
91
+ return Text(f"{_stamp(event)} ⚙ {tool}", style="yellow", overflow="fold")
92
+
93
+ if event.type == EventTypes.AGENT_TOOL_END:
94
+ return None # start lines carry enough signal for the transcript
95
+
96
+ if event.type.startswith(_LIFECYCLE_PREFIXES):
97
+ line = format_event(event)
98
+ style = "dim"
99
+ if event.type == HostEventTypes.TASK_STATE:
100
+ style = TASK_STATE_STYLES.get(str(data.get("state", "")), "dim")
101
+ elif "fallback" in event.type or "missing" in event.type:
102
+ style = "yellow"
103
+ return Text(line, style=style, overflow="fold")
104
+
105
+ return None
106
+
107
+
108
+ class Dashboard:
109
+ """Accumulates run state from the event stream and renders it."""
110
+
111
+ def __init__(self, max_entries: int = 6) -> None:
112
+ self.run_id: str | None = None
113
+ self.run_state: str = "starting"
114
+ self.outcome: str = ""
115
+ self.tasks: dict[str, dict[str, Any]] = {}
116
+ self.transcript: deque[RenderableType] = deque(maxlen=max_entries)
117
+
118
+ # -- event intake ------------------------------------------------------
119
+
120
+ def on_event(self, event: Event) -> None:
121
+ self.run_id = self.run_id or event.run_id
122
+ data = event.data
123
+ if event.type == HostEventTypes.RUN_START:
124
+ self.outcome = str(data.get("outcome", ""))
125
+ elif event.type in (HostEventTypes.RUN_STATE, HostEventTypes.RUN_END):
126
+ self.run_state = str(data.get("state", self.run_state))
127
+ elif event.type in (HostEventTypes.TASK_START, HostEventTypes.TASK_STATE):
128
+ task_id = str(data.get("task_id"))
129
+ entry = self.tasks.setdefault(task_id, {"title": "", "state": "pending"})
130
+ if data.get("title"):
131
+ entry["title"] = str(data["title"])
132
+ if data.get("state"):
133
+ entry["state"] = str(data["state"])
134
+ entry["revisions"] = data.get("revisions", entry.get("revisions", 0))
135
+ entry["replans"] = data.get("replans", entry.get("replans", 0))
136
+ elif event.type == HostEventTypes.TASK_END:
137
+ task_id = str(data.get("task_id"))
138
+ entry = self.tasks.setdefault(task_id, {"title": "", "state": "pending"})
139
+ if data.get("title"):
140
+ entry["title"] = str(data["title"])
141
+ entry["state"] = str(data.get("state", entry["state"]))
142
+ rendered = render_event(event)
143
+ if rendered is not None:
144
+ self.transcript.append(rendered)
145
+
146
+ # -- rendering ---------------------------------------------------------
147
+
148
+ def renderable(self) -> RenderableType:
149
+ header = Text.assemble(
150
+ ("run ", "bold"),
151
+ (self.run_id or "…", "bold cyan"),
152
+ (" state: ", ""),
153
+ (self.run_state, "bold yellow"),
154
+ )
155
+ outcome = Text(self.outcome[:200], style="italic dim")
156
+
157
+ table = Table(expand=True, box=None, pad_edge=False)
158
+ table.add_column("task", style="bold", no_wrap=True)
159
+ table.add_column("title", ratio=2)
160
+ table.add_column("state", no_wrap=True)
161
+ table.add_column("rev/replan", no_wrap=True, justify="right")
162
+ for task_id, entry in self.tasks.items():
163
+ state = str(entry.get("state", "?"))
164
+ table.add_row(
165
+ task_id,
166
+ str(entry.get("title", "")),
167
+ Text(state, style=TASK_STATE_STYLES.get(state, "")),
168
+ f"{entry.get('revisions', 0)}/{entry.get('replans', 0)}",
169
+ )
170
+ if not self.tasks:
171
+ table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
172
+
173
+ transcript: RenderableType
174
+ if self.transcript:
175
+ transcript = Group(*self.transcript)
176
+ else:
177
+ transcript = Text("waiting for events…", style="dim")
178
+ return Group(
179
+ Panel(Group(header, outcome), border_style="cyan"),
180
+ table,
181
+ transcript,
182
+ )
183
+
184
+
185
+ def format_event(event: Event) -> str:
186
+ """Dense single-line form (used by `sbxloop logs` and lifecycle lines)."""
187
+ parts = [_stamp(event), event.type]
188
+ if event.data.get("task_id"):
189
+ parts.append(f"[{event.data['task_id']}]")
190
+ for key in ("state", "content", "tool", "op", "line", "message", "outcome"):
191
+ if event.data.get(key):
192
+ value = str(event.data[key]).replace("\n", " ")
193
+ parts.append(value[:160])
194
+ break
195
+ return " ".join(parts)
196
+
197
+
198
+ def plain_printer(console: Console) -> Any:
199
+ """--no-tui mode: print the same chat-style entries sequentially."""
200
+
201
+ def print_event(event: Event) -> None:
202
+ rendered = render_event(event)
203
+ if rendered is not None:
204
+ console.print(rendered)
205
+
206
+ return print_event
sbxloop/config.py ADDED
@@ -0,0 +1,205 @@
1
+ """Configuration loading with layered precedence.
2
+
3
+ Precedence (highest wins): ``SBXLOOP_*`` environment variables >
4
+ ``./sbxloop.toml`` > ``pyproject.toml [tool.sbxloop]`` > built-in defaults.
5
+
6
+ Nested keys use ``__`` in environment variables, e.g.
7
+ ``SBXLOOP_BUDGETS__MAX_TASKS=5``. Values are parsed as TOML scalars where
8
+ possible (so ``true``, ``42``, ``1.5`` get real types) and fall back to
9
+ strings.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tomllib
16
+ from collections.abc import Mapping
17
+ from pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
21
+
22
+ from sbxloop.errors import ConfigError
23
+
24
+ ENV_PREFIX = "SBXLOOP_"
25
+
26
+ # SBXLOOP_-prefixed variables consumed by the *worker process* rather than
27
+ # host configuration; the env config layer must not treat them as settings.
28
+ RESERVED_ENV_KEYS = frozenset({"worker_backend", "echo_script"})
29
+
30
+ WorkerTransport = Literal["stream", "poll"]
31
+ SecretStrategy = Literal["proxy", "plain-env"]
32
+
33
+
34
+ class _ConfigModel(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+
38
+ class SandboxConfig(_ConfigModel):
39
+ template: str | None = None
40
+ workspace: Path | None = None
41
+ extra_allow_domains: list[str] = Field(default_factory=list)
42
+
43
+
44
+ class GithubConfig(_ConfigModel):
45
+ report_repo: str | None = None
46
+
47
+
48
+ class Budgets(_ConfigModel):
49
+ max_revisions_per_task: int = 2
50
+ max_replans_per_task: int = 1
51
+ max_tasks: int = 20
52
+ max_wall_clock_s: float = 7200.0
53
+ per_job_timeout_s: float = 900.0
54
+
55
+
56
+ class Config(_ConfigModel):
57
+ model: str = "auto"
58
+ # sbx --app-name. Empty (the default) shares the user's normal sbx
59
+ # application state, so their `sbx login` and `sbx policy init balanced`
60
+ # apply directly. Setting a name isolates sbxloop state, but the isolated
61
+ # app-state needs its own `sbx --app-name <name> login` and policy init.
62
+ app_name: str = ""
63
+ state_dir: Path = Path(".sbxloop")
64
+ keep_sandboxes: bool = False
65
+ worker_transport: WorkerTransport = "stream"
66
+ secret_strategy: SecretStrategy = "proxy"
67
+ # Advanced: in-sandbox interpreter for the worker, and whether to run the
68
+ # install flow. Overridden in tests/e2e via SBXLOOP_WORKER_PYTHON /
69
+ # SBXLOOP_INSTALL_WORKERS.
70
+ worker_python: str = "/home/agent/.sbxloop/venv/bin/python"
71
+ install_workers: bool = True
72
+ sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
73
+ github: GithubConfig = Field(default_factory=GithubConfig)
74
+ budgets: Budgets = Field(default_factory=Budgets)
75
+
76
+
77
+ def _read_toml(path: Path) -> dict[str, Any]:
78
+ try:
79
+ with path.open("rb") as f:
80
+ return tomllib.load(f)
81
+ except tomllib.TOMLDecodeError as exc:
82
+ raise ConfigError(f"invalid TOML in {path}: {exc}") from exc
83
+
84
+
85
+ def _pyproject_layer(cwd: Path) -> dict[str, Any]:
86
+ path = cwd / "pyproject.toml"
87
+ if not path.is_file():
88
+ return {}
89
+ data = _read_toml(path)
90
+ tool = data.get("tool", {})
91
+ if not isinstance(tool, dict):
92
+ return {}
93
+ section = tool.get("sbxloop", {})
94
+ return section if isinstance(section, dict) else {}
95
+
96
+
97
+ def _sbxloop_toml_layer(cwd: Path) -> dict[str, Any]:
98
+ path = cwd / "sbxloop.toml"
99
+ if not path.is_file():
100
+ return {}
101
+ return _read_toml(path)
102
+
103
+
104
+ def _parse_env_value(raw: str) -> Any:
105
+ """Parse an env var as a TOML scalar; fall back to the raw string."""
106
+ try:
107
+ return tomllib.loads(f"v = {raw}")["v"]
108
+ except tomllib.TOMLDecodeError:
109
+ return raw
110
+
111
+
112
+ def _env_layer(env: Mapping[str, str]) -> dict[str, Any]:
113
+ layer: dict[str, Any] = {}
114
+ for key, raw in env.items():
115
+ if not key.startswith(ENV_PREFIX):
116
+ continue
117
+ path = key[len(ENV_PREFIX) :].lower().split("__")
118
+ if not all(path) or path[0] in RESERVED_ENV_KEYS:
119
+ continue
120
+ node = layer
121
+ for part in path[:-1]:
122
+ node = node.setdefault(part, {})
123
+ if not isinstance(node, dict):
124
+ raise ConfigError(f"conflicting env overrides at {key}")
125
+ node[path[-1]] = _parse_env_value(raw)
126
+ return layer
127
+
128
+
129
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
130
+ merged = dict(base)
131
+ for key, value in override.items():
132
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
133
+ merged[key] = _deep_merge(merged[key], value)
134
+ else:
135
+ merged[key] = value
136
+ return merged
137
+
138
+
139
+ def _flatten(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
140
+ flat: dict[str, Any] = {}
141
+ for key, value in data.items():
142
+ dotted = f"{prefix}{key}"
143
+ if isinstance(value, dict):
144
+ flat.update(_flatten(value, f"{dotted}."))
145
+ else:
146
+ flat[dotted] = value
147
+ return flat
148
+
149
+
150
+ def load_dotenv_file(cwd: Path | None = None) -> Path | None:
151
+ """Load ``<cwd>/.env`` into the process environment, if present.
152
+
153
+ Real environment variables always win (``override=False``), so a ``.env``
154
+ file is a convenience layer for the two PATs and ``SBXLOOP_*`` settings —
155
+ never a way to silently shadow explicit exports. Returns the loaded path,
156
+ or None when there is no file.
157
+ """
158
+ from dotenv import load_dotenv
159
+
160
+ path = (cwd or Path.cwd()) / ".env"
161
+ if not path.is_file():
162
+ return None
163
+ load_dotenv(path, override=False)
164
+ return path
165
+
166
+
167
+ def load_config_with_sources(
168
+ cwd: Path | None = None,
169
+ env: Mapping[str, str] | None = None,
170
+ ) -> tuple[Config, dict[str, str]]:
171
+ """Load config and report, per dotted key, which layer supplied it."""
172
+ cwd = cwd or Path.cwd()
173
+ if env is None:
174
+ # Only consult .env when reading the real environment; explicit env
175
+ # mappings (tests, embedders) stay hermetic.
176
+ load_dotenv_file(cwd)
177
+ env = os.environ if env is None else env
178
+
179
+ layers: list[tuple[str, dict[str, Any]]] = [
180
+ ("pyproject.toml", _pyproject_layer(cwd)),
181
+ ("sbxloop.toml", _sbxloop_toml_layer(cwd)),
182
+ ("env", _env_layer(env)),
183
+ ]
184
+
185
+ merged: dict[str, Any] = {}
186
+ sources: dict[str, str] = {}
187
+ for name, layer in layers:
188
+ merged = _deep_merge(merged, layer)
189
+ for dotted in _flatten(layer):
190
+ sources[dotted] = name
191
+
192
+ try:
193
+ config = Config.model_validate(merged)
194
+ except ValidationError as exc:
195
+ raise ConfigError(f"invalid sbxloop configuration: {exc}") from exc
196
+
197
+ for dotted in _flatten(config.model_dump()):
198
+ sources.setdefault(dotted, "default")
199
+ return config, sources
200
+
201
+
202
+ def load_config(cwd: Path | None = None, env: Mapping[str, str] | None = None) -> Config:
203
+ """Load the effective sbxloop configuration for ``cwd``."""
204
+ config, _ = load_config_with_sources(cwd=cwd, env=env)
205
+ return config
@@ -0,0 +1,26 @@
1
+ """The sbxloop loop engine."""
2
+
3
+ from sbxloop.engine.engine import LoopEngine, run_outcome
4
+ from sbxloop.engine.model import (
5
+ PlanModel,
6
+ RunRecord,
7
+ RunResult,
8
+ TaskGraph,
9
+ TaskRecord,
10
+ TaskSpec,
11
+ Verdict,
12
+ )
13
+ from sbxloop.engine.store import StateStore
14
+
15
+ __all__ = [
16
+ "LoopEngine",
17
+ "PlanModel",
18
+ "RunRecord",
19
+ "RunResult",
20
+ "StateStore",
21
+ "TaskGraph",
22
+ "TaskRecord",
23
+ "TaskSpec",
24
+ "Verdict",
25
+ "run_outcome",
26
+ ]