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 +41 -0
- verifyloop/cli.py +186 -0
- verifyloop/executor.py +330 -0
- verifyloop/memory.py +197 -0
- verifyloop/models.py +146 -0
- verifyloop/pipeline.py +246 -0
- verifyloop/planner.py +190 -0
- verifyloop/recoverer.py +204 -0
- verifyloop/verifier.py +390 -0
- verifyloop-0.1.0.dist-info/METADATA +383 -0
- verifyloop-0.1.0.dist-info/RECORD +14 -0
- verifyloop-0.1.0.dist-info/WHEEL +4 -0
- verifyloop-0.1.0.dist-info/entry_points.txt +2 -0
- verifyloop-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|