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 +26 -0
- sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
- sbxloop/cli/__init__.py +1 -0
- sbxloop/cli/app.py +365 -0
- sbxloop/cli/doctor.py +172 -0
- sbxloop/cli/tui.py +206 -0
- sbxloop/config.py +205 -0
- sbxloop/engine/__init__.py +26 -0
- sbxloop/engine/engine.py +383 -0
- sbxloop/engine/model.py +151 -0
- sbxloop/engine/phases.py +223 -0
- sbxloop/engine/prompts/__init__.py +31 -0
- sbxloop/engine/prompts/decompose.md +41 -0
- sbxloop/engine/prompts/execute.md +31 -0
- sbxloop/engine/prompts/plan.md +35 -0
- sbxloop/engine/prompts/scrutinize.md +43 -0
- sbxloop/engine/prompts/validate.md +38 -0
- sbxloop/engine/store.py +257 -0
- sbxloop/errors.py +73 -0
- sbxloop/events.py +87 -0
- sbxloop/gh/__init__.py +6 -0
- sbxloop/gh/ops.py +140 -0
- sbxloop/gh/reporter.py +75 -0
- sbxloop/ids.py +35 -0
- sbxloop/py.typed +0 -0
- sbxloop/sbx/__init__.py +6 -0
- sbxloop/sbx/cli.py +236 -0
- sbxloop/sbx/models.py +83 -0
- sbxloop/sbx/pair.py +125 -0
- sbxloop/sbx/parse.py +67 -0
- sbxloop/sbx/provision.py +361 -0
- sbxloop/sbx/sandbox.py +75 -0
- sbxloop/worker/__init__.py +6 -0
- sbxloop/worker/client.py +397 -0
- sbxloop/worker/wheel.py +73 -0
- sbxloop-0.2.0.dist-info/METADATA +33 -0
- sbxloop-0.2.0.dist-info/RECORD +39 -0
- sbxloop-0.2.0.dist-info/WHEEL +4 -0
- sbxloop-0.2.0.dist-info/entry_points.txt +2 -0
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
|
+
]
|