verifyloop 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.
verifyloop/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """VerifyLoop — Plan → Execute → Verify → Recover agent framework.
2
+
3
+ The verify step uses a trained verification model, not just a prompt.
4
+ """
5
+
6
+ from verifyloop.models import (
7
+ AgentRun,
8
+ ExecuteStep,
9
+ PlanStep,
10
+ RecoverStep,
11
+ Step,
12
+ Substep,
13
+ VerifyStep,
14
+ )
15
+ from verifyloop.pipeline import AgentPipeline, PipelineConfig
16
+ from verifyloop.executor import Executor
17
+ from verifyloop.planner import PlanGenerator
18
+ from verifyloop.verifier import Verifier, VerifierConfig
19
+ from verifyloop.recoverer import Recoverer
20
+ from verifyloop.memory import InMemoryStore, FileStore
21
+
22
+ __all__ = [
23
+ "AgentPipeline",
24
+ "PipelineConfig",
25
+ "Executor",
26
+ "PlanGenerator",
27
+ "Verifier",
28
+ "VerifierConfig",
29
+ "Recoverer",
30
+ "InMemoryStore",
31
+ "FileStore",
32
+ "Step",
33
+ "PlanStep",
34
+ "ExecuteStep",
35
+ "VerifyStep",
36
+ "RecoverStep",
37
+ "Substep",
38
+ "AgentRun",
39
+ ]
40
+
41
+ __version__ = "0.1.0"
verifyloop/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ """CLI interface for VerifyLoop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.live import Live
14
+ from rich.panel import Panel
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn
16
+ from rich.table import Table
17
+
18
+ from verifyloop.models import AgentRun, PipelineConfig, RunStatus
19
+ from verifyloop.pipeline import AgentPipeline
20
+
21
+ console = Console()
22
+
23
+
24
+ def format_step_table(run: AgentRun) -> Table:
25
+ table = Table(title="VerifyLoop Execution", show_lines=True)
26
+ table.add_column("#", style="dim", width=4)
27
+ table.add_column("Phase", style="bold")
28
+ table.add_column("Content", max_width=60)
29
+ table.add_column("Confidence", justify="right")
30
+
31
+ phase_colors = {
32
+ "plan": "cyan",
33
+ "execute": "green",
34
+ "verify": "yellow",
35
+ "recover": "red",
36
+ }
37
+
38
+ for i, step in enumerate(run.steps, 1):
39
+ color = phase_colors.get(step.step_type.value, "white")
40
+ table.add_row(
41
+ str(i),
42
+ f"[{color}]{step.step_type.value}[/{color}]",
43
+ step.content[:120] + ("..." if len(step.content) > 120 else ""),
44
+ f"{step.confidence:.0%}",
45
+ )
46
+ return table
47
+
48
+
49
+ async def run_pipeline(
50
+ task: str,
51
+ context: str = "",
52
+ model: str = "gpt-4o",
53
+ verify_model: str = "reason-critic-7b",
54
+ max_iterations: int = 5,
55
+ working_dir: str = ".",
56
+ dry_run: bool = False,
57
+ interactive: bool = False,
58
+ sandbox: bool = False,
59
+ ) -> AgentRun:
60
+ config = PipelineConfig(
61
+ model=model,
62
+ verify_model=verify_model,
63
+ max_iterations=max_iterations,
64
+ working_dir=working_dir,
65
+ dry_run=dry_run,
66
+ interactive=interactive,
67
+ sandbox=sandbox,
68
+ )
69
+ pipeline = AgentPipeline(config)
70
+
71
+ events_log: list[dict[str, Any]] = []
72
+
73
+ async def on_event(event: str, data: dict[str, Any]) -> None:
74
+ events_log.append({"event": event, **data})
75
+ if event == "phase_start":
76
+ phase = data.get("phase", "")
77
+ color_map = {"plan": "cyan", "execute": "green", "verify": "yellow", "recover": "red"}
78
+ color = color_map.get(phase, "white")
79
+ console.print(f"\n[bold {color}]═══ {phase.upper()} ═══[/]")
80
+ elif event == "step_complete":
81
+ status = "✓" if data.get("success") else "✗"
82
+ console.print(f" {status} {data.get('tool', '')} (iteration {data.get('iteration', '')})")
83
+ elif event == "phase_complete":
84
+ phase = data.get("phase", "")
85
+ if phase == "verify":
86
+ passed = data.get("passed", False)
87
+ confidence = data.get("confidence", 0)
88
+ icon = "✓" if passed else "✗"
89
+ console.print(f" {icon} Verification: confidence={confidence:.0%}")
90
+ elif event == "recovery_attempt":
91
+ console.print(
92
+ f" ⟳ Recovery #{data.get('attempt', '')}: {data.get('description', '')}"
93
+ )
94
+
95
+ pipeline.on_event(on_event)
96
+
97
+ with Progress(
98
+ SpinnerColumn(),
99
+ TextColumn("[progress.description]{task.description}"),
100
+ console=console,
101
+ ) as progress:
102
+ task_display = progress.add_task("Running VerifyLoop...", total=None)
103
+ result = await pipeline.run(task, context, max_iterations)
104
+ progress.update(task_display, completed=1)
105
+
106
+ console.print()
107
+ console.print(format_step_table(result))
108
+
109
+ status_style = "bold green" if result.status == RunStatus.COMPLETED else "bold red"
110
+ console.print(f"\n[{status_style}]Status: {result.status.value}[/{status_style}]")
111
+ console.print(f"Duration: {result.duration_seconds:.2f}s")
112
+ console.print(f"Iterations: {result.iteration}")
113
+ console.print(
114
+ f"Tokens: {result.token_usage.prompt_tokens} prompt + "
115
+ f"{result.token_usage.completion_tokens} completion = "
116
+ f"{result.token_usage.total_tokens} total"
117
+ )
118
+
119
+ return result
120
+
121
+
122
+ @click.group()
123
+ def cli() -> None:
124
+ """VerifyLoop — Plan → Execute → Verify → Recover agent framework."""
125
+ pass
126
+
127
+
128
+ @cli.command()
129
+ @click.argument("task", required=False)
130
+ @click.option("--task-file", type=click.Path(exists=True), help="Path to JSON task file")
131
+ @click.option("--context", default="", help="Additional context for the task")
132
+ @click.option("--model", default="gpt-4o", help="LLM model for planning")
133
+ @click.option("--verify-model", default="reason-critic-7b", help="Verification model")
134
+ @click.option("--max-iterations", default=5, type=int, help="Maximum plan-execute-verify loops")
135
+ @click.option("--working-dir", default=".", help="Working directory")
136
+ @click.option("--dry-run", is_flag=True, help="Plan only, don't execute")
137
+ @click.option("--interactive", is_flag=True, help="Confirm each step before execution")
138
+ @click.option("--sandbox", is_flag=True, help="Run bash commands in Docker sandbox")
139
+ @click.option("--output", type=click.Path(), help="Save results to JSON file")
140
+ def run(
141
+ task: str | None,
142
+ task_file: str | None,
143
+ context: str,
144
+ model: str,
145
+ verify_model: str,
146
+ max_iterations: int,
147
+ working_dir: str,
148
+ dry_run: bool,
149
+ interactive: bool,
150
+ sandbox: bool,
151
+ output: str | None,
152
+ ) -> None:
153
+ """Run a task through the Plan → Execute → Verify → Recover pipeline."""
154
+ if task_file:
155
+ path = Path(task_file)
156
+ data = json.loads(path.read_text())
157
+ task = data.get("task", "")
158
+ context = data.get("context", context)
159
+ model = data.get("model", model)
160
+ verify_model = data.get("verify_model", verify_model)
161
+ max_iterations = data.get("max_iterations", max_iterations)
162
+ elif not task:
163
+ console.print("[bold red]Error:[/] Provide a task string or --task-file")
164
+ sys.exit(1)
165
+
166
+ result = asyncio.run(run_pipeline(
167
+ task=task,
168
+ context=context,
169
+ model=model,
170
+ verify_model=verify_model,
171
+ max_iterations=max_iterations,
172
+ working_dir=working_dir,
173
+ dry_run=dry_run,
174
+ interactive=interactive,
175
+ sandbox=sandbox,
176
+ ))
177
+
178
+ if output:
179
+ output_path = Path(output)
180
+ output_path.parent.mkdir(parents=True, exist_ok=True)
181
+ output_path.write_text(result.model_dump_json(indent=2))
182
+ console.print(f"\n[dim]Results saved to {output}[/]")
183
+
184
+
185
+ if __name__ == "__main__":
186
+ cli()
verifyloop/executor.py ADDED
@@ -0,0 +1,330 @@
1
+ """Execute phase: run tools with structured results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import subprocess
8
+ import tempfile
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from verifyloop.models import ExecuteStep
16
+
17
+
18
+ class Executor:
19
+ def __init__(
20
+ self,
21
+ working_dir: str = ".",
22
+ sandbox: bool = False,
23
+ sandbox_image: str = "python:3.11-slim",
24
+ timeout: int = 120,
25
+ ) -> None:
26
+ self.working_dir = Path(working_dir).resolve()
27
+ self.sandbox = sandbox
28
+ self.sandbox_image = sandbox_image
29
+ self.timeout = timeout
30
+ self._file_history: dict[str, list[str]] = {}
31
+
32
+ async def execute(self, tool: str, arguments: dict[str, Any]) -> ExecuteStep:
33
+ handlers: dict[str, Any] = {
34
+ "bash": self.bash,
35
+ "edit": self.edit,
36
+ "read": self.read,
37
+ "write": self.write,
38
+ "web_search": self.web_search,
39
+ "web_fetch": self.web_fetch,
40
+ }
41
+ handler = handlers.get(tool)
42
+ if handler is None:
43
+ return ExecuteStep(
44
+ tool=tool,
45
+ arguments=arguments,
46
+ result=f"Unknown tool: {tool}",
47
+ success=False,
48
+ error=f"Unknown tool: {tool}",
49
+ )
50
+ try:
51
+ return await handler(**arguments)
52
+ except Exception as exc:
53
+ return ExecuteStep(
54
+ tool=tool,
55
+ arguments=arguments,
56
+ result="",
57
+ success=False,
58
+ error=str(exc),
59
+ )
60
+
61
+ async def bash(
62
+ self,
63
+ command: str,
64
+ working_dir: str | None = None,
65
+ timeout: int | None = None,
66
+ ) -> ExecuteStep:
67
+ start = time.monotonic()
68
+ cwd = str(Path(working_dir).resolve()) if working_dir else str(self.working_dir)
69
+ exec_timeout = timeout or self.timeout
70
+
71
+ try:
72
+ if self.sandbox:
73
+ result = await self._bash_docker(command, cwd, exec_timeout)
74
+ else:
75
+ proc = await asyncio.create_subprocess_shell(
76
+ command,
77
+ stdout=asyncio.subprocess.PIPE,
78
+ stderr=asyncio.subprocess.PIPE,
79
+ cwd=cwd,
80
+ )
81
+ stdout, stderr = await asyncio.wait_for(
82
+ proc.communicate(), timeout=exec_timeout
83
+ )
84
+ result = ExecuteStep(
85
+ tool="bash",
86
+ arguments={"command": command, "working_dir": cwd},
87
+ result=stdout.decode(errors="replace"),
88
+ success=proc.returncode == 0,
89
+ duration_seconds=time.monotonic() - start,
90
+ exit_code=proc.returncode,
91
+ error=stderr.decode(errors="replace") if proc.returncode else None,
92
+ )
93
+
94
+ result.duration_seconds = time.monotonic() - start
95
+ return result
96
+
97
+ except asyncio.TimeoutError:
98
+ return ExecuteStep(
99
+ tool="bash",
100
+ arguments={"command": command},
101
+ result="",
102
+ success=False,
103
+ duration_seconds=time.monotonic() - start,
104
+ error=f"Command timed out after {exec_timeout}s",
105
+ )
106
+ except Exception as exc:
107
+ return ExecuteStep(
108
+ tool="bash",
109
+ arguments={"command": command},
110
+ result="",
111
+ success=False,
112
+ duration_seconds=time.monotonic() - start,
113
+ error=str(exc),
114
+ )
115
+
116
+ async def _bash_docker(
117
+ self, command: str, working_dir: str, timeout: int
118
+ ) -> ExecuteStep:
119
+ start = time.monotonic()
120
+ docker_cmd = [
121
+ "docker", "run", "--rm",
122
+ "-v", f"{working_dir}:/workspace",
123
+ "-w", "/workspace",
124
+ self.sandbox_image,
125
+ "sh", "-c", command,
126
+ ]
127
+ proc = await asyncio.create_subprocess_exec(
128
+ *docker_cmd,
129
+ stdout=asyncio.subprocess.PIPE,
130
+ stderr=asyncio.subprocess.PIPE,
131
+ )
132
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
133
+ return ExecuteStep(
134
+ tool="bash",
135
+ arguments={"command": command, "working_dir": working_dir, "sandbox": True},
136
+ result=stdout.decode(errors="replace"),
137
+ success=proc.returncode == 0,
138
+ duration_seconds=time.monotonic() - start,
139
+ exit_code=proc.returncode,
140
+ error=stderr.decode(errors="replace") if proc.returncode else None,
141
+ )
142
+
143
+ async def edit(
144
+ self,
145
+ file_path: str,
146
+ old_content: str,
147
+ new_content: str,
148
+ ) -> ExecuteStep:
149
+ start = time.monotonic()
150
+ target = Path(file_path)
151
+ if not target.is_absolute():
152
+ target = self.working_dir / target
153
+
154
+ try:
155
+ content = target.read_text()
156
+ if old_content not in content:
157
+ return ExecuteStep(
158
+ tool="edit",
159
+ arguments={"file_path": str(target), "old_content": old_content, "new_content": new_content},
160
+ result=f"old_content not found in {target}",
161
+ success=False,
162
+ duration_seconds=time.monotonic() - start,
163
+ error=f"old_content not found in {target}",
164
+ )
165
+
166
+ self._file_history.setdefault(str(target), []).append(content)
167
+ updated = content.replace(old_content, new_content, 1)
168
+ target.write_text(updated)
169
+
170
+ return ExecuteStep(
171
+ tool="edit",
172
+ arguments={"file_path": str(target), "old_content": old_content, "new_content": new_content},
173
+ result=f"Edited {target}: replaced 1 occurrence",
174
+ success=True,
175
+ duration_seconds=time.monotonic() - start,
176
+ )
177
+ except FileNotFoundError:
178
+ return ExecuteStep(
179
+ tool="edit",
180
+ arguments={"file_path": str(target)},
181
+ result="",
182
+ success=False,
183
+ duration_seconds=time.monotonic() - start,
184
+ error=f"File not found: {target}",
185
+ )
186
+ except Exception as exc:
187
+ return ExecuteStep(
188
+ tool="edit",
189
+ arguments={"file_path": str(target)},
190
+ result="",
191
+ success=False,
192
+ duration_seconds=time.monotonic() - start,
193
+ error=str(exc),
194
+ )
195
+
196
+ async def read(self, file_path: str) -> ExecuteStep:
197
+ start = time.monotonic()
198
+ target = Path(file_path)
199
+ if not target.is_absolute():
200
+ target = self.working_dir / target
201
+
202
+ try:
203
+ content = target.read_text()
204
+ self._file_history.setdefault(str(target), []).append(content)
205
+ return ExecuteStep(
206
+ tool="read",
207
+ arguments={"file_path": str(target)},
208
+ result=content,
209
+ success=True,
210
+ duration_seconds=time.monotonic() - start,
211
+ )
212
+ except FileNotFoundError:
213
+ return ExecuteStep(
214
+ tool="read",
215
+ arguments={"file_path": str(target)},
216
+ result="",
217
+ success=False,
218
+ duration_seconds=time.monotonic() - start,
219
+ error=f"File not found: {target}",
220
+ )
221
+ except Exception as exc:
222
+ return ExecuteStep(
223
+ tool="read",
224
+ arguments={"file_path": str(target)},
225
+ result="",
226
+ success=False,
227
+ duration_seconds=time.monotonic() - start,
228
+ error=str(exc),
229
+ )
230
+
231
+ async def write(
232
+ self,
233
+ file_path: str,
234
+ content: str,
235
+ ) -> ExecuteStep:
236
+ start = time.monotonic()
237
+ target = Path(file_path)
238
+ if not target.is_absolute():
239
+ target = self.working_dir / target
240
+
241
+ try:
242
+ target.parent.mkdir(parents=True, exist_ok=True)
243
+
244
+ if target.exists():
245
+ self._file_history.setdefault(str(target), []).append(target.read_text())
246
+
247
+ target.write_text(content)
248
+ return ExecuteStep(
249
+ tool="write",
250
+ arguments={"file_path": str(target), "content_length": len(content)},
251
+ result=f"Wrote {len(content)} chars to {target}",
252
+ success=True,
253
+ duration_seconds=time.monotonic() - start,
254
+ )
255
+ except Exception as exc:
256
+ return ExecuteStep(
257
+ tool="write",
258
+ arguments={"file_path": str(target)},
259
+ result="",
260
+ success=False,
261
+ duration_seconds=time.monotonic() - start,
262
+ error=str(exc),
263
+ )
264
+
265
+ async def web_search(self, query: str) -> ExecuteStep:
266
+ start = time.monotonic()
267
+ try:
268
+ async with httpx.AsyncClient(timeout=30) as client:
269
+ resp = await client.get(
270
+ "https://api.duckduckgo.com/",
271
+ params={"q": query, "format": "json", "no_html": 1},
272
+ )
273
+ data = resp.json()
274
+ results = []
275
+ for key in ("AbstractText", "Answer", "Definition"):
276
+ if data.get(key):
277
+ results.append(data[key])
278
+ for topic in data.get("RelatedTopics", [])[:5]:
279
+ if isinstance(topic, dict) and topic.get("Text"):
280
+ results.append(topic["Text"])
281
+ result_text = "\n".join(results) if results else "No results found"
282
+ return ExecuteStep(
283
+ tool="web_search",
284
+ arguments={"query": query},
285
+ result=result_text,
286
+ success=True,
287
+ duration_seconds=time.monotonic() - start,
288
+ )
289
+ except Exception as exc:
290
+ return ExecuteStep(
291
+ tool="web_search",
292
+ arguments={"query": query},
293
+ result="",
294
+ success=False,
295
+ duration_seconds=time.monotonic() - start,
296
+ error=str(exc),
297
+ )
298
+
299
+ async def web_fetch(self, url: str) -> ExecuteStep:
300
+ start = time.monotonic()
301
+ try:
302
+ async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
303
+ resp = await client.get(url)
304
+ resp.raise_for_status()
305
+ return ExecuteStep(
306
+ tool="web_fetch",
307
+ arguments={"url": url},
308
+ result=resp.text[:50000],
309
+ success=True,
310
+ duration_seconds=time.monotonic() - start,
311
+ )
312
+ except Exception as exc:
313
+ return ExecuteStep(
314
+ tool="web_fetch",
315
+ arguments={"url": url},
316
+ result="",
317
+ success=False,
318
+ duration_seconds=time.monotonic() - start,
319
+ error=str(exc),
320
+ )
321
+
322
+ def get_file_history(self, file_path: str) -> list[str]:
323
+ return self._file_history.get(file_path, [])
324
+
325
+ def rollback_file(self, file_path: str) -> bool:
326
+ history = self._file_history.get(file_path, [])
327
+ if not history:
328
+ return False
329
+ Path(file_path).write_text(history.pop())
330
+ return True