agent-loop-tool 0.1.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.
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Provider
4
+ from . import register
5
+
6
+
7
+ @register
8
+ class AntigravityProvider(Provider):
9
+ name = "antigravity"
10
+ binary = "agy"
11
+ auto_mode_env = "ALLOW_AUTO_MODE_ANTIGRAVITY"
12
+
13
+ def build_argv(self, prompt: str) -> list[str]:
14
+ argv = [self.binary, "-p", prompt]
15
+ if self.auto_mode_enabled:
16
+ argv.append("--dangerously-skip-permissions")
17
+ argv.extend(self._model_argv())
18
+ return argv
19
+
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import threading
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+ from .. import audit
12
+
13
+
14
+ class ProviderError(RuntimeError):
15
+ pass
16
+
17
+
18
+ #: Grace period for the output pump to drain after the child exits.
19
+ _PUMP_JOIN_SECONDS = 5
20
+
21
+
22
+ @dataclass
23
+ class CapturedResult:
24
+ """Result of a non-streaming, output-capturing provider invocation.
25
+
26
+ Produced only by `Provider.run_captured`, which is used by `agent-loop
27
+ init` to read a provider's full response. The developer/reviewer loop
28
+ keeps using `Provider.run`, which streams straight to the terminal.
29
+ """
30
+
31
+ returncode: int
32
+ stdout: str
33
+ stderr: str
34
+ timed_out: bool = False
35
+
36
+
37
+ class Provider(ABC):
38
+ name: str
39
+ binary: str
40
+ auto_mode_env: str
41
+ #: Flag this CLI uses to pin a model; overridable per provider.
42
+ model_flag: str = "--model"
43
+
44
+ def __init__(self, model: Optional[str] = None) -> None:
45
+ #: When None, the underlying CLI resolves its own default model.
46
+ self.model = model or None
47
+
48
+ @abstractmethod
49
+ def build_argv(self, prompt: str) -> list[str]:
50
+ ...
51
+
52
+ def _model_argv(self) -> list[str]:
53
+ """Argv fragment that pins the model, or empty to defer to the CLI."""
54
+ return [self.model_flag, self.model] if self.model else []
55
+
56
+ @property
57
+ def auto_mode_enabled(self) -> bool:
58
+ return os.environ.get(self.auto_mode_env, "0") == "1"
59
+
60
+ def preflight(self) -> None:
61
+ if shutil.which(self.binary) is None:
62
+ raise ProviderError(
63
+ f"Missing CLI for provider '{self.name}': '{self.binary}' not on PATH."
64
+ )
65
+
66
+ def run(self, prompt: str, timeout: int, audit_level: str = "full") -> int:
67
+ """Stream the agent's output to the terminal *and* the run log.
68
+
69
+ The child cannot simply inherit our stdout: `safety.tee_stdout_to`
70
+ rebinds `sys.stdout` at the Python level, while a subprocess writes to
71
+ the inherited file descriptor 1 directly. Anything the agent printed
72
+ would reach the terminal and bypass the log entirely — which is most of
73
+ what an audit trail is for. So its output is piped back here and
74
+ re-emitted through `print`, which does go through the tee.
75
+
76
+ A pump thread forwards lines as they arrive so long runs stay live
77
+ rather than surfacing only once the agent exits. `audit_level` (see
78
+ `agent_loop.audit`) controls what that pump actually does with each
79
+ line: pass it through untouched (``full``), scrub known secret shapes
80
+ first (``redacted``), or drop it entirely (``off``) so nothing the
81
+ agent printed reaches the committed log.
82
+ """
83
+ audit.validate(audit_level)
84
+ argv = self.build_argv(prompt)
85
+ proc = subprocess.Popen(
86
+ argv,
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.STDOUT,
89
+ text=True,
90
+ bufsize=1,
91
+ errors="replace",
92
+ )
93
+
94
+ redactor = audit.StreamRedactor() if audit_level == "redacted" else None
95
+
96
+ def _pump() -> None:
97
+ assert proc.stdout is not None
98
+ for line in proc.stdout:
99
+ if audit_level == "off":
100
+ continue
101
+ if redactor is not None:
102
+ line = redactor.feed_line(line)
103
+ if not line:
104
+ continue
105
+ print(line, end="", flush=True)
106
+
107
+ pump = threading.Thread(target=_pump, daemon=True)
108
+ pump.start()
109
+ try:
110
+ proc.wait(timeout=timeout)
111
+ except subprocess.TimeoutExpired:
112
+ proc.kill()
113
+ proc.wait()
114
+ pump.join(timeout=_PUMP_JOIN_SECONDS)
115
+ print(
116
+ f"[agent-loop] {self.name} timed out after {timeout}s",
117
+ flush=True,
118
+ )
119
+ return 124
120
+
121
+ pump.join(timeout=_PUMP_JOIN_SECONDS)
122
+ return proc.returncode
123
+
124
+ def run_captured(self, prompt: str, timeout: int) -> CapturedResult:
125
+ """Run the provider non-interactively, capturing stdout/stderr.
126
+
127
+ Unlike `run`, nothing is streamed to the terminal — this is for
128
+ callers (namely `agent-loop init`) that need to parse the provider's
129
+ full response rather than watch it work.
130
+ """
131
+ argv = self.build_argv(prompt)
132
+ try:
133
+ result = subprocess.run(
134
+ argv, timeout=timeout, capture_output=True, text=True
135
+ )
136
+ return CapturedResult(
137
+ returncode=result.returncode,
138
+ stdout=result.stdout,
139
+ stderr=result.stderr,
140
+ )
141
+ except subprocess.TimeoutExpired as e:
142
+ return CapturedResult(
143
+ returncode=124,
144
+ stdout=e.stdout or "",
145
+ stderr=e.stderr or "",
146
+ timed_out=True,
147
+ )
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Provider
4
+ from . import register
5
+
6
+
7
+ @register
8
+ class ClaudeProvider(Provider):
9
+ name = "claude"
10
+ binary = "claude"
11
+ auto_mode_env = "ALLOW_AUTO_MODE_CLAUDE"
12
+
13
+ def build_argv(self, prompt: str) -> list[str]:
14
+ argv = [self.binary, "-p", prompt]
15
+ if self.auto_mode_enabled:
16
+ argv.append("--dangerously-skip-permissions")
17
+ argv.extend(["--output-format", "text"])
18
+ argv.extend(self._model_argv())
19
+ return argv
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Provider
4
+ from . import register
5
+
6
+
7
+ @register
8
+ class CodexProvider(Provider):
9
+ name = "codex"
10
+ binary = "codex"
11
+ auto_mode_env = "ALLOW_AUTO_MODE_CODEX"
12
+ model_flag = "-m"
13
+
14
+ def build_argv(self, prompt: str) -> list[str]:
15
+ flag = (
16
+ "--dangerously-bypass-approvals-and-sandbox"
17
+ if self.auto_mode_enabled
18
+ else "--full-auto"
19
+ )
20
+ # Model must precede the positional prompt so `exec` parses it as a flag.
21
+ return [self.binary, "exec", flag, *self._model_argv(), prompt]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Provider
4
+ from . import register
5
+
6
+
7
+ @register
8
+ class GrokProvider(Provider):
9
+ name = "grok"
10
+ binary = "grok"
11
+ auto_mode_env = "ALLOW_AUTO_MODE_GROK"
12
+
13
+ def build_argv(self, prompt: str) -> list[str]:
14
+ argv = [self.binary, "-p", prompt]
15
+ if self.auto_mode_enabled:
16
+ argv.append("--always-approve")
17
+ argv.extend(self._model_argv())
18
+ return argv
agent_loop/safety.py ADDED
@@ -0,0 +1,105 @@
1
+ """Lockfile, log directory, and auto-mode-flag gating."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ from contextlib import contextmanager
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from .git_ops import GitError, repo_root
11
+ from .providers.base import Provider
12
+
13
+
14
+ class LockHeld(RuntimeError):
15
+ pass
16
+
17
+
18
+ class AutoModeGateError(RuntimeError):
19
+ pass
20
+
21
+
22
+ @contextmanager
23
+ def lockfile(path: Path):
24
+ try:
25
+ path.mkdir(parents=False, exist_ok=False)
26
+ except FileExistsError as e:
27
+ raise LockHeld(
28
+ f"Another loop appears to be running (lock: {path}). "
29
+ "Remove the lock only if you are sure no loop process is active."
30
+ ) from e
31
+ try:
32
+ yield
33
+ finally:
34
+ try:
35
+ path.rmdir()
36
+ except OSError:
37
+ pass
38
+
39
+
40
+ def open_log_file(log_dir: Path) -> Path:
41
+ log_dir.mkdir(parents=True, exist_ok=True)
42
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
43
+ return log_dir / f"loop_{stamp}.log"
44
+
45
+
46
+ def require_auto_mode_gates(providers: list[Provider]) -> None:
47
+ """Refuse to launch if any assigned provider's write-mode env isn't set.
48
+
49
+ Mirrors the bash script's refusal for prompt-only Claude — every coding-agent
50
+ CLI in v1 expects yolo/auto mode for unattended runs.
51
+ """
52
+ blocked = [p for p in providers if not p.auto_mode_enabled]
53
+ if not blocked:
54
+ return
55
+
56
+ lines = [
57
+ "These providers are assigned to a role that writes files, but their",
58
+ "write-mode env gate is not set:",
59
+ "",
60
+ ]
61
+ for p in blocked:
62
+ lines.append(f" {p.name:8s} set {p.auto_mode_env}=1")
63
+ lines.extend(
64
+ [
65
+ "",
66
+ "Unattended runs would otherwise block on a permission prompt.",
67
+ "Re-run with the missing env vars exported.",
68
+ ]
69
+ )
70
+ raise AutoModeGateError("\n".join(lines))
71
+
72
+
73
+ def tee_stdout_to(log_path: Path) -> None:
74
+ """Mirror stdout+stderr to a log file in addition to the terminal."""
75
+ log_path.parent.mkdir(parents=True, exist_ok=True)
76
+ log_fh = open(log_path, "a", buffering=1)
77
+
78
+ class _Tee:
79
+ def __init__(self, *streams):
80
+ self.streams = streams
81
+
82
+ def write(self, data):
83
+ for s in self.streams:
84
+ s.write(data)
85
+
86
+ def flush(self):
87
+ for s in self.streams:
88
+ s.flush()
89
+
90
+ sys.stdout = _Tee(sys.stdout, log_fh)
91
+ sys.stderr = _Tee(sys.stderr, log_fh)
92
+
93
+
94
+ def default_log_dir() -> Path:
95
+ configured = os.environ.get("LOG_DIR")
96
+ if configured:
97
+ return Path(configured)
98
+ try:
99
+ return repo_root() / "logs"
100
+ except GitError:
101
+ return Path("logs")
102
+
103
+
104
+ def default_lock_path() -> Path:
105
+ return Path(os.environ.get("LOCK_DIR", "/tmp/agent_loop.lock"))
agent_loop/state.py ADDED
@@ -0,0 +1,247 @@
1
+ """Load, validate, mutate, and save plan_checkpoints.json."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Iterable, Optional
8
+
9
+ ALLOWED_STATUSES = ("pending", "built", "approved")
10
+ PROTECTED_BRANCHES = ("main", "master")
11
+ REQUIRED_TOP_LEVEL = ("plan_file", "branch", "checkpoints")
12
+ REQUIRED_CHECKPOINT_FIELDS = (
13
+ "id",
14
+ "name",
15
+ "status",
16
+ "scope",
17
+ "exit_criteria",
18
+ "attempts",
19
+ "review_notes",
20
+ )
21
+ COMMAND_FIELDS = ("build_cmd", "test_cmd", "lint_cmd")
22
+ MODEL_ROLES = ("developer", "reviewer")
23
+
24
+
25
+ class InvalidPlanState(ValueError):
26
+ """Raised when plan_checkpoints.json fails schema validation."""
27
+
28
+
29
+ class ProtectedBranchError(InvalidPlanState):
30
+ """Raised when a payload targets a protected branch (e.g. main, master)."""
31
+
32
+
33
+ class DuplicateCheckpointError(InvalidPlanState):
34
+ """Raised when a payload contains two checkpoints with the same id."""
35
+
36
+
37
+ class CheckpointNotFound(KeyError):
38
+ def __init__(self, cid: str):
39
+ super().__init__(cid)
40
+ self.cid = cid
41
+
42
+ def __str__(self) -> str:
43
+ return f"Unknown checkpoint: {self.cid}"
44
+
45
+
46
+ @dataclass
47
+ class PlanState:
48
+ path: Path
49
+ plan_file: str
50
+ branch: str
51
+ checkpoints: list[dict]
52
+ project: dict = field(default_factory=dict)
53
+ models: dict = field(default_factory=dict)
54
+
55
+ # ------------------------------------------------------------------
56
+ # Convenience accessors for the project block
57
+ # ------------------------------------------------------------------
58
+
59
+ @property
60
+ def build_cmd(self) -> Optional[str]:
61
+ return self.project.get("build_cmd")
62
+
63
+ @property
64
+ def test_cmd(self) -> Optional[str]:
65
+ return self.project.get("test_cmd")
66
+
67
+ @property
68
+ def lint_cmd(self) -> Optional[str]:
69
+ return self.project.get("lint_cmd")
70
+
71
+ @property
72
+ def verify_in_review(self) -> bool:
73
+ return bool(self.project.get("verify_in_review", False))
74
+
75
+ def command_for(self, cid: str, field_name: str) -> Optional[str]:
76
+ if field_name not in COMMAND_FIELDS:
77
+ raise ValueError(f"Unknown command field: {field_name}")
78
+ cp = self.get(cid)
79
+ return cp.get(field_name) or self.project.get(field_name)
80
+
81
+ def model_for(self, role: str) -> Optional[str]:
82
+ """Model pinned for a role in the plan, or None to defer to the CLI."""
83
+ if role not in MODEL_ROLES:
84
+ raise ValueError(f"Unknown model role: {role}")
85
+ return self.models.get(role)
86
+
87
+ # ------------------------------------------------------------------
88
+ # Checkpoint queries / mutations
89
+ # ------------------------------------------------------------------
90
+
91
+ def get(self, cid: str) -> dict:
92
+ for cp in self.checkpoints:
93
+ if cp["id"] == cid:
94
+ return cp
95
+ raise CheckpointNotFound(cid)
96
+
97
+ def ids(self) -> list[str]:
98
+ return [cp["id"] for cp in self.checkpoints]
99
+
100
+ def pending_checkpoints(self) -> Iterable[dict]:
101
+ return [cp for cp in self.checkpoints if cp["status"] != "approved"]
102
+
103
+ def set_field(self, cid: str, field_name: str, value: Any) -> None:
104
+ cp = self.get(cid)
105
+ if field_name == "status" and value not in ALLOWED_STATUSES:
106
+ raise InvalidPlanState(
107
+ f"checkpoint {cid}: invalid status {value!r}; allowed: {ALLOWED_STATUSES}"
108
+ )
109
+ cp[field_name] = value
110
+
111
+ # ------------------------------------------------------------------
112
+ # Persistence
113
+ # ------------------------------------------------------------------
114
+
115
+ def save(self) -> None:
116
+ payload: dict[str, Any] = {
117
+ "plan_file": self.plan_file,
118
+ "branch": self.branch,
119
+ }
120
+ if self.project:
121
+ payload["project"] = self.project
122
+ if self.models:
123
+ payload["models"] = self.models
124
+ payload["checkpoints"] = self.checkpoints
125
+
126
+ with self.path.open("w") as f:
127
+ json.dump(payload, f, indent=2)
128
+ f.write("\n")
129
+
130
+
131
+ def load_state(path: Path | str) -> PlanState:
132
+ path = Path(path)
133
+ if not path.is_file():
134
+ raise InvalidPlanState(f"State file not found: {path}")
135
+ try:
136
+ raw = json.loads(path.read_text())
137
+ except json.JSONDecodeError as e:
138
+ raise InvalidPlanState(f"State file is not valid JSON: {e}") from e
139
+
140
+ validate_payload(raw)
141
+ return PlanState(
142
+ path=path,
143
+ plan_file=raw["plan_file"],
144
+ branch=raw["branch"],
145
+ checkpoints=raw["checkpoints"],
146
+ # `or {}` (not a get-default) so an explicit `null` becomes {} too.
147
+ project=raw.get("project") or {},
148
+ models=raw.get("models") or {},
149
+ )
150
+
151
+
152
+ def validate_payload(raw: Any) -> None:
153
+ """Schema-check a plan payload (dict, not yet written to disk).
154
+
155
+ Reused by `load_state` and by plan generation (see `plan_init.py`) so an
156
+ in-memory generated payload can be validated before it is ever written.
157
+ """
158
+ if not isinstance(raw, dict):
159
+ raise InvalidPlanState("Top-level must be a JSON object")
160
+
161
+ for field_name in REQUIRED_TOP_LEVEL:
162
+ if field_name not in raw:
163
+ raise InvalidPlanState(f"Missing top-level field: {field_name}")
164
+
165
+ branch = raw["branch"]
166
+ if not isinstance(branch, str) or not branch:
167
+ raise InvalidPlanState("'branch' must be a non-empty string")
168
+ if branch in PROTECTED_BRANCHES:
169
+ raise ProtectedBranchError(f"Refusing protected target branch: {branch}")
170
+
171
+ if not isinstance(raw["plan_file"], str) or not raw["plan_file"]:
172
+ raise InvalidPlanState("'plan_file' must be a non-empty string")
173
+
174
+ checkpoints = raw["checkpoints"]
175
+ if not isinstance(checkpoints, list) or not checkpoints:
176
+ raise InvalidPlanState("'checkpoints' must be a non-empty list")
177
+
178
+ seen_ids: set[str] = set()
179
+ for idx, cp in enumerate(checkpoints):
180
+ if not isinstance(cp, dict):
181
+ raise InvalidPlanState(f"checkpoint #{idx} must be a JSON object")
182
+ for f in REQUIRED_CHECKPOINT_FIELDS:
183
+ if f not in cp:
184
+ raise InvalidPlanState(
185
+ f"checkpoint #{idx}: missing required field '{f}'"
186
+ )
187
+
188
+ # Identify the checkpoint by index until 'id' is known to be a usable
189
+ # string; every message below can then name it.
190
+ if not isinstance(cp["id"], str) or not cp["id"]:
191
+ raise InvalidPlanState(f"checkpoint #{idx}: 'id' must be a non-empty string")
192
+ for f in ("name", "scope"):
193
+ if not isinstance(cp[f], str) or not cp[f]:
194
+ raise InvalidPlanState(
195
+ f"checkpoint {cp['id']}: '{f}' must be a non-empty string"
196
+ )
197
+
198
+ if cp["status"] not in ALLOWED_STATUSES:
199
+ raise InvalidPlanState(
200
+ f"checkpoint {cp['id']}: invalid status {cp['status']!r}; "
201
+ f"allowed: {ALLOWED_STATUSES}"
202
+ )
203
+ # Non-empty: a checkpoint with no criteria gives the review gate
204
+ # nothing objective to check, so it can only ever be waved through.
205
+ if not isinstance(cp["exit_criteria"], list) or not cp["exit_criteria"]:
206
+ raise InvalidPlanState(
207
+ f"checkpoint {cp['id']}: 'exit_criteria' must be a non-empty list"
208
+ )
209
+ for c_idx, criterion in enumerate(cp["exit_criteria"]):
210
+ if not isinstance(criterion, str) or not criterion.strip():
211
+ raise InvalidPlanState(
212
+ f"checkpoint {cp['id']}: 'exit_criteria[{c_idx}]' must be a "
213
+ f"non-empty string"
214
+ )
215
+ # bool is an int subclass, so reject it explicitly. Negatives are
216
+ # rejected too: the loop counts up from 'attempts' toward
217
+ # max_review_attempts, so a negative one buys extra review rounds.
218
+ if (
219
+ not isinstance(cp["attempts"], int)
220
+ or isinstance(cp["attempts"], bool)
221
+ or cp["attempts"] < 0
222
+ ):
223
+ raise InvalidPlanState(
224
+ f"checkpoint {cp['id']}: 'attempts' must be a non-negative integer"
225
+ )
226
+ if not isinstance(cp["review_notes"], str):
227
+ raise InvalidPlanState(f"checkpoint {cp['id']}: 'review_notes' must be a string")
228
+
229
+ if cp["id"] in seen_ids:
230
+ raise DuplicateCheckpointError(f"duplicate checkpoint id: {cp['id']}")
231
+ seen_ids.add(cp["id"])
232
+
233
+ project = raw.get("project")
234
+ if project is not None and not isinstance(project, dict):
235
+ raise InvalidPlanState("'project' must be a JSON object when present")
236
+
237
+ models = raw.get("models")
238
+ if models is not None:
239
+ if not isinstance(models, dict):
240
+ raise InvalidPlanState("'models' must be a JSON object when present")
241
+ for role, model in models.items():
242
+ if role not in MODEL_ROLES:
243
+ raise InvalidPlanState(
244
+ f"'models' has unknown role {role!r}; allowed: {MODEL_ROLES}"
245
+ )
246
+ if not isinstance(model, str) or not model:
247
+ raise InvalidPlanState(f"'models.{role}' must be a non-empty string")