sbxloop 0.5.6__tar.gz → 0.5.7__tar.gz

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.
Files changed (44) hide show
  1. {sbxloop-0.5.6 → sbxloop-0.5.7}/PKG-INFO +1 -1
  2. sbxloop-0.5.7/src/sbxloop/_vendor/sbxloop_worker-0.5.7-py3-none-any.whl +0 -0
  3. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/cli/app.py +9 -0
  5. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/cli/doctor.py +23 -0
  6. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/cli/tui.py +42 -4
  7. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/config.py +38 -1
  8. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/engine.py +53 -0
  9. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/worker/client.py +22 -1
  10. sbxloop-0.5.6/src/sbxloop/_vendor/sbxloop_worker-0.5.6-py3-none-any.whl +0 -0
  11. {sbxloop-0.5.6 → sbxloop-0.5.7}/.gitignore +0 -0
  12. {sbxloop-0.5.6 → sbxloop-0.5.7}/README.md +0 -0
  13. {sbxloop-0.5.6 → sbxloop-0.5.7}/hatch_build.py +0 -0
  14. {sbxloop-0.5.6 → sbxloop-0.5.7}/pyproject.toml +0 -0
  15. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/__init__.py +0 -0
  16. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/cli/__init__.py +0 -0
  17. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/deliver.py +0 -0
  18. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/__init__.py +0 -0
  19. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/model.py +0 -0
  20. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/phases.py +0 -0
  21. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/__init__.py +0 -0
  22. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/decompose.md +0 -0
  23. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/execute.md +0 -0
  24. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/plan.md +0 -0
  25. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  26. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/validate.md +0 -0
  27. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/store.py +0 -0
  28. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/errors.py +0 -0
  29. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/events.py +0 -0
  30. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/gh/__init__.py +0 -0
  31. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/gh/ops.py +0 -0
  32. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/gh/reporter.py +0 -0
  33. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/ids.py +0 -0
  34. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/py.typed +0 -0
  35. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/__init__.py +0 -0
  36. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/cli.py +0 -0
  37. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/models.py +0 -0
  38. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/pair.py +0 -0
  39. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/parse.py +0 -0
  40. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/provision.py +0 -0
  41. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/prune.py +0 -0
  42. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/sandbox.py +0 -0
  43. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/worker/__init__.py +0 -0
  44. {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/worker/wheel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbxloop
3
- Version: 0.5.6
3
+ Version: 0.5.7
4
4
  Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
5
5
  Project-URL: Homepage, https://github.com/brettbergin/sbxloop
6
6
  Project-URL: Repository, https://github.com/brettbergin/sbxloop
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.6'
22
- __version_tuple__ = version_tuple = (0, 5, 6)
21
+ __version__ = version = '0.5.7'
22
+ __version_tuple__ = version_tuple = (0, 5, 7)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -655,6 +655,15 @@ max_replans_per_task = 1
655
655
  max_tasks = 20
656
656
  max_wall_clock_s = 7200.0
657
657
  per_job_timeout_s = 900.0
658
+
659
+ [limits]
660
+ # Sandbox resource guardrails (percent used; 0 disables). Sampled in-VM on
661
+ # the worker heartbeat and shown as a gauge in the TUI status panel.
662
+ # Crossing disk_abort fails the current task with an explicit
663
+ # "sandbox disk exhausted" error.
664
+ disk_warn = 85.0
665
+ disk_abort = 95.0
666
+ mem_warn = 90.0
658
667
  """
659
668
 
660
669
 
@@ -122,6 +122,29 @@ def collect_checks(
122
122
  )
123
123
  )
124
124
 
125
+ # Host-side resource stats: sbx 0.35.x has no stats command, so
126
+ # resource telemetry samples in-VM on the worker heartbeat. This is
127
+ # the field-check for the day sbx grows one (issue #54) — purely
128
+ # informational either way.
129
+ report("checking for host-side sandbox stats")
130
+ stats_available = False
131
+ try:
132
+ stats_available = cli.run("stats", "--help", check=False).returncode == 0
133
+ except SbxError:
134
+ stats_available = False
135
+ checks.append(
136
+ Check(
137
+ "sandbox stats",
138
+ True,
139
+ "host-side `sbx stats` available — sbxloop still samples in-VM; "
140
+ "consider filing an issue to prefer host-side stats"
141
+ if stats_available
142
+ else "no host-side `sbx stats`; resource telemetry samples in-VM "
143
+ f"on the worker heartbeat (expected on {TESTED_SBX_SERIES}.x)",
144
+ hard=False,
145
+ )
146
+ )
147
+
125
148
  # tokens
126
149
  checks.append(
127
150
  Check(
@@ -42,7 +42,9 @@ TASK_STATE_STYLES = {
42
42
  _LIFECYCLE_PREFIXES = ("run.", "task.", "phase.", "sandbox.", "gh.")
43
43
 
44
44
  # High-volume noise excluded from the transcript (still queryable via
45
- # `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats.
45
+ # `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats,
46
+ # per-beat resource samples (they live in the pinned gauge instead —
47
+ # threshold *crossings* arrive as sandbox.resources_warning and do print).
46
48
  _TRANSCRIPT_SKIP = {
47
49
  EventTypes.AGENT_MESSAGE_DELTA,
48
50
  EventTypes.WORKER_STDOUT,
@@ -51,8 +53,11 @@ _TRANSCRIPT_SKIP = {
51
53
  EventTypes.WORKER_RESULT,
52
54
  EventTypes.WORKER_END,
53
55
  EventTypes.AGENT_USAGE,
56
+ EventTypes.SANDBOX_RESOURCES,
54
57
  }
55
58
 
59
+ RESOURCE_LEVEL_STYLES = {"ok": "dim", "warn": "yellow", "abort": "bold red"}
60
+
56
61
  AGENT_MESSAGE_CLIP = 4000
57
62
  # One-line clip for tool arguments; output tail lines shown on tool failure.
58
63
  TOOL_ARGS_LINE_CLIP = 160
@@ -148,7 +153,7 @@ def render_event(event: Event) -> RenderableType | None:
148
153
  style = "dim"
149
154
  if event.type == HostEventTypes.TASK_STATE:
150
155
  style = TASK_STATE_STYLES.get(str(data.get("state", "")), "dim")
151
- elif "fallback" in event.type or "missing" in event.type:
156
+ elif "fallback" in event.type or "missing" in event.type or "warning" in event.type:
152
157
  style = "yellow"
153
158
  return Text(line, style=style, overflow="fold")
154
159
 
@@ -166,13 +171,18 @@ class Dashboard:
166
171
  self.run_state: str = "starting"
167
172
  self.outcome: str = ""
168
173
  self.tasks: dict[str, dict[str, Any]] = {}
174
+ # Latest resource sample per sandbox role, rendered as one compact
175
+ # gauge line each; only escalates visually past warn/abort levels.
176
+ self.resources: dict[str, dict[str, Any]] = {}
169
177
 
170
178
  # -- event intake ------------------------------------------------------
171
179
 
172
180
  def on_event(self, event: Event) -> None:
173
181
  self.run_id = self.run_id or event.run_id
174
182
  data = event.data
175
- if event.type == HostEventTypes.RUN_START:
183
+ if event.type == EventTypes.SANDBOX_RESOURCES:
184
+ self.resources[str(data.get("role") or "sandbox")] = dict(data)
185
+ elif event.type == HostEventTypes.RUN_START:
176
186
  self.outcome = str(data.get("outcome", ""))
177
187
  elif event.type in (HostEventTypes.RUN_STATE, HostEventTypes.RUN_END):
178
188
  self.run_state = str(data.get("state", self.run_state))
@@ -219,12 +229,29 @@ class Dashboard:
219
229
  if not self.tasks:
220
230
  table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
221
231
 
232
+ gauges = [self._gauge_line(role) for role in sorted(self.resources)]
222
233
  return Panel(
223
- Group(header, outcome, table),
234
+ Group(header, outcome, table, *gauges),
224
235
  border_style="cyan",
225
236
  padding=(0, 1),
226
237
  )
227
238
 
239
+ def _gauge_line(self, role: str) -> Text:
240
+ sample = self.resources[role]
241
+ level = str(sample.get("level", "ok"))
242
+ parts = []
243
+ if sample.get("disk_used_pct") is not None:
244
+ parts.append(f"disk {sample['disk_used_pct']:.0f}%")
245
+ if sample.get("mem_used_pct") is not None:
246
+ parts.append(f"mem {sample['mem_used_pct']:.0f}%")
247
+ if sample.get("load1") is not None:
248
+ parts.append(f"load {sample['load1']}")
249
+ suffix = " ⚠ " + level if level != "ok" else ""
250
+ return Text(
251
+ f"{role}: " + " · ".join(parts) + suffix,
252
+ style=RESOURCE_LEVEL_STYLES.get(level, "dim"),
253
+ )
254
+
228
255
 
229
256
  def format_event(event: Event) -> str:
230
257
  """Dense single-line form (used by `sbxloop logs` and lifecycle lines)."""
@@ -239,6 +266,17 @@ def format_event(event: Event) -> str:
239
266
  value = str(event.data[key]).replace("\n", " ")
240
267
  parts.append(value[:160])
241
268
  break
269
+ if event.data.get("disk_used_pct") is not None:
270
+ # Resource samples: make `sbxloop logs --type-prefix sandbox.resources`
271
+ # answer "what did disk look like before the failure" at a glance.
272
+ summary = [f"disk={event.data['disk_used_pct']}%"]
273
+ if event.data.get("mem_used_pct") is not None:
274
+ summary.append(f"mem={event.data['mem_used_pct']}%")
275
+ if event.data.get("load1") is not None:
276
+ summary.append(f"load={event.data['load1']}")
277
+ if event.data.get("level"):
278
+ summary.append(str(event.data["level"]))
279
+ parts.append(" ".join(summary))
242
280
  if event.data.get("args"):
243
281
  parts.append(_one_line(str(event.data["args"]), 120))
244
282
  if picked != "error" and event.data.get("error"):
@@ -18,7 +18,14 @@ from collections.abc import Mapping
18
18
  from pathlib import Path
19
19
  from typing import Any, Literal
20
20
 
21
- from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
21
+ from pydantic import (
22
+ BaseModel,
23
+ ConfigDict,
24
+ Field,
25
+ ValidationError,
26
+ field_validator,
27
+ model_validator,
28
+ )
22
29
 
23
30
  from sbxloop.errors import ConfigError
24
31
 
@@ -68,6 +75,35 @@ class GithubConfig(_ConfigModel):
68
75
  return self.repo is not None
69
76
 
70
77
 
78
+ class Limits(_ConfigModel):
79
+ """Sandbox resource guardrails, sampled in-VM on the worker heartbeat.
80
+
81
+ Thresholds are percent-used of the workspace filesystem / memory; 0
82
+ disables a guardrail. Crossing a warn threshold emits a prominent
83
+ ``sandbox.resources_warning`` event and escalates the TUI gauge;
84
+ crossing ``disk_abort`` fails the current task with an explicit
85
+ "sandbox disk exhausted" error instead of letting in-VM tooling fail
86
+ confusingly on a full disk.
87
+ """
88
+
89
+ disk_warn: float = 85.0
90
+ disk_abort: float = 95.0
91
+ mem_warn: float = 90.0
92
+
93
+ @model_validator(mode="after")
94
+ def _check_thresholds(self) -> Limits:
95
+ for name in ("disk_warn", "disk_abort", "mem_warn"):
96
+ value = getattr(self, name)
97
+ if value < 0 or value > 100:
98
+ raise ValueError(f"limits.{name} must be a percentage in 0..100, got {value}")
99
+ if 0 < self.disk_abort <= self.disk_warn:
100
+ raise ValueError(
101
+ f"limits.disk_abort ({self.disk_abort}) must be greater than "
102
+ f"limits.disk_warn ({self.disk_warn})"
103
+ )
104
+ return self
105
+
106
+
71
107
  class Budgets(_ConfigModel):
72
108
  max_revisions_per_task: int = 2
73
109
  max_replans_per_task: int = 1
@@ -100,6 +136,7 @@ class Config(_ConfigModel):
100
136
  sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
101
137
  github: GithubConfig = Field(default_factory=GithubConfig)
102
138
  budgets: Budgets = Field(default_factory=Budgets)
139
+ limits: Limits = Field(default_factory=Limits)
103
140
 
104
141
 
105
142
  def _read_toml(path: Path) -> dict[str, Any]:
@@ -18,6 +18,7 @@ import json
18
18
  import logging
19
19
  import time
20
20
  from collections.abc import Callable, Sequence
21
+ from typing import Any
21
22
 
22
23
  from sbxloop.config import Config, load_config, load_dotenv_file
23
24
  from sbxloop.deliver import deliver_workspace
@@ -75,9 +76,13 @@ class LoopEngine:
75
76
  install_workers if install_workers is not None else self.config.install_workers
76
77
  )
77
78
  self.clock = clock
79
+ # Latest sandbox.resources sample per sandbox role, fed by the bus;
80
+ # consulted for the disk guardrail and the harvest-truncation note.
81
+ self._last_resources: dict[str, dict[str, object]] = {}
78
82
  for hook in hooks:
79
83
  self.bus.attach_hook(hook)
80
84
  self.bus.subscribe(self._persist_event)
85
+ self.bus.subscribe(self._track_resources)
81
86
 
82
87
  def _persist_event(self, event: object) -> None:
83
88
  from sbxloop_worker.protocol import Event
@@ -85,6 +90,14 @@ class LoopEngine:
85
90
  assert isinstance(event, Event)
86
91
  self.store.append_event(event)
87
92
 
93
+ def _track_resources(self, event: object) -> None:
94
+ from sbxloop_worker.protocol import Event, EventTypes
95
+
96
+ assert isinstance(event, Event)
97
+ if event.type == EventTypes.SANDBOX_RESOURCES:
98
+ role = str(event.data.get("role") or "agent")
99
+ self._last_resources[role] = dict(event.data)
100
+
88
101
  # -- public API --------------------------------------------------------
89
102
 
90
103
  def start(self, outcome: str, *, run_id: str | None = None) -> RunResult:
@@ -124,6 +137,8 @@ class LoopEngine:
124
137
  self.bus,
125
138
  transport=self.config.worker_transport,
126
139
  python=self.worker_python,
140
+ role="agent",
141
+ limits=self.config.limits,
127
142
  )
128
143
  github = (
129
144
  WorkerClient(
@@ -131,6 +146,8 @@ class LoopEngine:
131
146
  self.bus,
132
147
  transport=self.config.worker_transport,
133
148
  python=self.worker_python,
149
+ role="github",
150
+ limits=self.config.limits,
134
151
  )
135
152
  if pair.github is not None
136
153
  else None
@@ -249,12 +266,28 @@ class LoopEngine:
249
266
  if target is None or not target.is_dir():
250
267
  return
251
268
  count = sum(1 for p in target.rglob("*") if p.is_file())
269
+ extra: dict[str, Any] = {}
270
+ sample = self._last_resources.get("agent")
271
+ if sample and sample.get("level") in ("warn", "abort"):
272
+ # Disk was under pressure at the last sample — harvested
273
+ # artifacts may be truncated or missing.
274
+ extra = {
275
+ "disk_used_pct": sample.get("disk_used_pct"),
276
+ "resources_level": sample.get("level"),
277
+ }
278
+ logger.warning(
279
+ "run %s: sandbox disk was at %s%% at the last sample — "
280
+ "harvested artifacts may be incomplete",
281
+ run_id,
282
+ sample.get("disk_used_pct"),
283
+ )
252
284
  self.bus.emit(
253
285
  HostEventTypes.RUN_ARTIFACTS,
254
286
  run_id,
255
287
  path=str(target),
256
288
  files=count,
257
289
  mounted=pair.mounted,
290
+ **extra,
258
291
  )
259
292
 
260
293
  def _deliver(
@@ -374,6 +407,14 @@ class LoopEngine:
374
407
 
375
408
  while not task.terminal:
376
409
  self._check_cancelled_and_clock(run_id, deadline)
410
+ abort_reason = self._resource_abort_reason()
411
+ if abort_reason:
412
+ # Fail the task with a diagnosis instead of letting the next
413
+ # phase produce garbage in a full sandbox. Dependents are
414
+ # skipped by the normal failed-task machinery.
415
+ task.last_feedback = abort_reason
416
+ self._set_task_state(run_id, task, "failed")
417
+ break
377
418
  if task.state == "planning":
378
419
  self._phase_plan(run_id, phases, task)
379
420
  elif task.state == "executing":
@@ -510,6 +551,18 @@ class LoopEngine:
510
551
 
511
552
  # -- bookkeeping -------------------------------------------------------
512
553
 
554
+ def _resource_abort_reason(self) -> str | None:
555
+ """Non-None when the agent sandbox's last resource sample crossed
556
+ the disk_abort threshold (the worker classifies; the level rides on
557
+ the event)."""
558
+ sample = self._last_resources.get("agent")
559
+ if sample and sample.get("level") == "abort":
560
+ return (
561
+ f"sandbox disk exhausted: {sample.get('disk_used_pct')}% of the workspace "
562
+ f"filesystem is used (limits.disk_abort={self.config.limits.disk_abort}%)"
563
+ )
564
+ return None
565
+
513
566
  def _check_cancelled_and_clock(self, run_id: str, deadline: float) -> None:
514
567
  if self.store.get_run(run_id).state == "cancelled":
515
568
  raise StateError(f"run {run_id} was cancelled")
@@ -27,7 +27,7 @@ from collections import deque
27
27
  from pathlib import Path
28
28
 
29
29
  import sbxloop
30
- from sbxloop.config import WorkerTransport
30
+ from sbxloop.config import Limits, WorkerTransport
31
31
  from sbxloop.errors import SbxError, WorkerError, WorkerTimeoutError
32
32
  from sbxloop.events import EventBus
33
33
  from sbxloop.sbx.models import ExecResult
@@ -61,6 +61,8 @@ class WorkerClient:
61
61
  python: str = DEFAULT_PYTHON,
62
62
  poll_interval: float = 2.0,
63
63
  grace_s: float = 60.0,
64
+ role: str | None = None,
65
+ limits: Limits | None = None,
64
66
  ) -> None:
65
67
  self.sandbox = sandbox
66
68
  self.bus = bus or EventBus()
@@ -68,6 +70,11 @@ class WorkerClient:
68
70
  self.python = python
69
71
  self.poll_interval = poll_interval
70
72
  self.grace_s = grace_s
73
+ # Sandbox role for enriching resource telemetry (the worker doesn't
74
+ # know which sandbox it lives in), and guardrail thresholds to pass
75
+ # through to the worker's heartbeat sampler.
76
+ self.role = role
77
+ self.limits = limits
71
78
 
72
79
  # -- install -----------------------------------------------------------
73
80
 
@@ -266,6 +273,15 @@ class WorkerClient:
266
273
  # process itself chdirs there — agent SDK sessions inherit it.
267
274
  if job.cwd:
268
275
  argv += ["--cwd", job.cwd]
276
+ if self.limits is not None:
277
+ argv += [
278
+ "--disk-warn",
279
+ str(self.limits.disk_warn),
280
+ "--disk-abort",
281
+ str(self.limits.disk_abort),
282
+ "--mem-warn",
283
+ str(self.limits.mem_warn),
284
+ ]
269
285
  # sbx injects secrets through the sandbox session/profile machinery;
270
286
  # a bare exec'd process may not see them. Run the worker under a
271
287
  # login shell so the sandbox environment is fully loaded.
@@ -338,6 +354,11 @@ class WorkerClient:
338
354
  Event.now(EventTypes.WORKER_STDOUT, job.run_id, job_id=job.job_id, line=line)
339
355
  )
340
356
  return
357
+ if self.role is not None and event.type in (
358
+ EventTypes.SANDBOX_RESOURCES,
359
+ EventTypes.SANDBOX_RESOURCES_WARNING,
360
+ ):
361
+ event.data.setdefault("role", self.role)
341
362
  self.bus.publish(event)
342
363
 
343
364
  # -- poll transport ----------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes