Skip to content

Commit b932f2d

Browse files
committed
x
1 parent cac0a6d commit b932f2d

File tree

2 files changed

+356
-0
lines changed

2 files changed

+356
-0
lines changed

libs/deepagents/integrations/__init__.py

Whitespace-only changes.
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
"""BackendProtocol implementation for Runloop."""
2+
3+
import datetime
4+
import os
5+
from typing import Optional
6+
7+
from deepagents.backends.protocol import BackendProtocol, WriteResult, EditResult
8+
from deepagents.backends.utils import (
9+
FileInfo,
10+
GrepMatch,
11+
check_empty_content,
12+
format_content_with_line_numbers,
13+
perform_string_replacement,
14+
)
15+
from runloop_api_client import Runloop
16+
17+
18+
class RunloopBackend:
19+
"""Backend that operates on files in a Runloop devbox.
20+
21+
This implementation uses the Runloop API client to execute commands
22+
and manipulate files within a remote devbox environment.
23+
"""
24+
25+
# NOTE: As an example, this currently uses a pre-allocated devbox.
26+
# For the real version we would want to create a devbox as needed,
27+
# run one or more commands, and then clean up when finished.
28+
29+
def __init__(
30+
self,
31+
devbox_id: str,
32+
client: Optional[Runloop] = None,
33+
bearer_token: Optional[str] = None,
34+
) -> None:
35+
"""Initialize Runloop backend.
36+
37+
Args:
38+
devbox_id: ID of the Runloop devbox to operate on.
39+
client: Optional existing Runloop client instance
40+
bearer_token: Optional API key for creating a new client
41+
(defaults to RUNLOOP_API_KEY environment variable)
42+
"""
43+
if client and bearer_token:
44+
raise ValueError("Provide either client or bearer_token, not both.")
45+
46+
if client is None:
47+
bearer_token = bearer_token or os.environ.get("RUNLOOP_API_KEY", None)
48+
if bearer_token is None:
49+
raise ValueError("Either client or bearer_token must be provided.")
50+
client = Runloop(bearer_token=bearer_token)
51+
52+
self._client = client
53+
self._devbox_id = devbox_id
54+
55+
def exec(self, command: str) -> tuple[str, int]:
56+
"""Execute a command in the devbox and return (stdout, exit_status)."""
57+
result = self._client.devboxes.execute_and_await_completion(
58+
id=self._devbox_id,
59+
command=command,
60+
)
61+
# NOTE: could check exit status for error (non-zero) and
62+
# return stderr here instead / in addition to stdout.
63+
return (result.stdout or "", result.exit_status)
64+
65+
66+
class RunloopProtocol(BackendProtocol):
67+
def __init__(self, backend):
68+
self._backend = backend
69+
70+
def ls_info(self, path: str) -> list[FileInfo]:
71+
"""List files and directories in the specified directory (non-recursive).
72+
73+
Args:
74+
path: Directory path to list files from.
75+
76+
Returns:
77+
List of FileInfo dicts for files and directories directly in the directory.
78+
Directories have a trailing / in their path and is_dir=True.
79+
"""
80+
# Use find to list only direct children
81+
cmd = f"find '{path}' -maxdepth 1 -mindepth 1 -printf '%p %s %T@ %y %Y\\n' 2>/dev/null"
82+
stdout, exit_code = self._backend.exec(cmd)
83+
84+
if exit_code != 0 or not stdout.strip():
85+
# NOTE: this silently ignores errors; not sure what error
86+
# handling semantics are needed here, but presumably not
87+
# this. :)
88+
return []
89+
90+
results: list[FileInfo] = []
91+
for line in stdout.strip().split("\n"):
92+
if not line:
93+
continue
94+
95+
# Parse out the listing info.
96+
(path, size, modified_secs, filetype, realtype) = line.split()
97+
modtime = datetime.datetime.fromtimestamp(float(modified_secs)).isoformat()
98+
99+
file_info: FileInfo = {
100+
"path": path + "/" if filetype == "d" else path,
101+
"is_dir": filetype == "d",
102+
"is_file": filetype == "f",
103+
"is_link": filetype == "l",
104+
"size": size if filetype == "f" else 0,
105+
"modified_at": modtime,
106+
}
107+
results.append(file_info)
108+
109+
results.sort(key=lambda x: x.get("path", ""))
110+
return results
111+
112+
def read(
113+
self,
114+
file_path: str,
115+
offset: int = 0,
116+
limit: int = 2000,
117+
) -> str:
118+
"""Read file content with line numbers.
119+
120+
Args:
121+
file_path: File path to read
122+
offset: Line offset to start reading from (0-indexed)
123+
limit: Maximum number of lines to read
124+
125+
Returns:
126+
Formatted file content with line numbers, or error message.
127+
"""
128+
# Check if file exists and get content
129+
start_line = offset + 1
130+
cmd = (
131+
f"if [ ! -f '{file_path}' ]; then "
132+
f"echo 'Error: File not found'; exit 1; "
133+
f"else "
134+
f"tail -n +{start_line} '{file_path}' | head -n {limit}; "
135+
f"fi"
136+
)
137+
stdout, exit_code = self._backend.exec(cmd)
138+
139+
if exit_code != 0 or "Error: File not found" in stdout:
140+
return f"Error: File '{file_path}' not found"
141+
142+
empty_msg = check_empty_content(stdout)
143+
if empty_msg:
144+
return empty_msg
145+
146+
return format_content_with_line_numbers(stdout, start_line=start_line)
147+
148+
def write(
149+
self,
150+
file_path: str,
151+
content: str,
152+
) -> WriteResult:
153+
"""Create a new file with content.
154+
155+
Args:
156+
file_path: Path where to write the file
157+
content: Content to write
158+
159+
Returns:
160+
WriteResult with path on success or error message on failure.
161+
"""
162+
# QUESTIONS:
163+
# * is the intent here to only support text formats, as with read() and edit()?
164+
# * for text, any assumptions/requirements about the character set?
165+
166+
# Check if file already exists
167+
check_cmd = f"test -e '{file_path}' && echo 'exists' || echo 'ok'"
168+
stdout, _ = self._backend.exec(check_cmd)
169+
170+
if "exists" in stdout:
171+
return WriteResult(
172+
error=f"Cannot write to {file_path} because it already exists. Read and then make an edit, or write to a new path."
173+
)
174+
175+
# Use the upload_file() method from the Runloop API client.
176+
try:
177+
self._backend._client.devboxes.upload_file(
178+
id=self._backend._devbox_id,
179+
path=file_path,
180+
file=content.encode("utf-8"), # NOTE: might want a different type?
181+
)
182+
except Exception as e:
183+
# TODO: catch specific exception
184+
return WriteResult(error=f"Error writing file '{file_path}': {e}")
185+
186+
return WriteResult(path=file_path)
187+
188+
def edit(
189+
self,
190+
file_path: str,
191+
old_string: str,
192+
new_string: str,
193+
replace_all: bool = False,
194+
) -> EditResult:
195+
"""Edit a file by replacing string occurrences.
196+
197+
Args:
198+
file_path: Path to the file to edit
199+
old_string: String to find and replace
200+
new_string: Replacement string
201+
replace_all: If True, replace all occurrences
202+
203+
Returns:
204+
EditResult with path and occurrences on success or error on failure.
205+
"""
206+
# QUESTIONS:
207+
# * this downloads the whole file to replace things locally; are files guaranteed to be small?
208+
# * what semantics do you want for non-existant / empty files?
209+
210+
try:
211+
# fetch the file
212+
response = self._backend._client.devboxes.download_file(
213+
id=self._backend._devbox_id,
214+
path=file_path
215+
)
216+
217+
# do the replacements
218+
new_text, occurrences = perform_string_replacement(
219+
response.text(), old_string, new_string, replace_all
220+
)
221+
222+
# write back
223+
self._backend._client.devboxes.upload_file(
224+
id=self._backend._devbox_id,
225+
path=file_path,
226+
file=new_text.encode("utf-8"), # NOTE: might want a different type?
227+
)
228+
return EditResult(path=file_path, occurrences=occurrences)
229+
230+
except Exception as e:
231+
# TODO: catch specific exception
232+
return EditResult(error=f"Error writing file '{file_path}': {e}")
233+
234+
def grep_raw(
235+
self,
236+
pattern: str,
237+
path: Optional[str] = None,
238+
glob: Optional[str] = None,
239+
) -> list[GrepMatch] | str:
240+
"""Search for a pattern in files.
241+
242+
Args:
243+
pattern: Regular expression pattern to search for
244+
path: Base path to search from (defaults to current directory)
245+
glob: Optional glob pattern to filter files (e.g., "*.py")
246+
247+
Returns:
248+
List of GrepMatch dicts on success, or error string on invalid input.
249+
"""
250+
# Use grep to search files. NOTE: might need something
251+
# differeent if you have other regex semantics.
252+
search_path = path or "."
253+
254+
# Build grep command
255+
grep_opts = "-rHn" # recursive, with filename, with line number
256+
257+
# Add glob pattern if specified
258+
if glob:
259+
grep_opts += f" --include='{glob}'"
260+
261+
# Escape pattern for shell
262+
pattern_escaped = pattern.replace("'", "\\'")
263+
264+
cmd = f"grep {grep_opts} -e '{pattern_escaped}' '{search_path}' 2>/dev/null || true"
265+
stdout, _ = self._backend.exec(cmd)
266+
267+
if not stdout.strip():
268+
return []
269+
270+
# Parse grep output: path:line_number:content
271+
matches: list[GrepMatch] = []
272+
for line in stdout.strip().split("\n"):
273+
if not line:
274+
continue
275+
276+
# Split on first two colons to handle content with colons
277+
parts = line.split(":", 2)
278+
try:
279+
file_path = parts[0]
280+
line_num = int(parts[1])
281+
line_text = parts[2]
282+
matches.append(
283+
{
284+
"path": file_path,
285+
"line": line_num,
286+
"text": line_text,
287+
}
288+
)
289+
except ValueError:
290+
continue
291+
292+
return matches
293+
294+
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
295+
"""Find files matching a glob pattern.
296+
297+
Args:
298+
pattern: Glob pattern (e.g., "*.py", "**/*.ts")
299+
path: Base path to search from
300+
301+
Returns:
302+
List of FileInfo dicts for matching files.
303+
"""
304+
# Use Python's glob module via remote execution
305+
pattern_escaped = pattern.replace("'", "'\\''")
306+
path_escaped = path.replace("'", "'\\''")
307+
308+
# Use a more complicated command, to grab stat output from the
309+
# matching files. Could be simplified if this isn't needed.
310+
python_cmd = (
311+
f"python3 -c \""
312+
f"import glob, os, json; "
313+
f"os.chdir('{path_escaped}'); "
314+
f"matches = glob.glob('{pattern_escaped}', recursive=True); "
315+
f"for m in matches: "
316+
f" if os.path.isfile(m): "
317+
f" s = os.stat(m); "
318+
f" print(json.dumps({{'path': m, 'size': s.st_size, 'mtime': s.st_mtime}})); "
319+
f"\" 2>/dev/null"
320+
)
321+
322+
stdout, exit_code = self._backend.exec(python_cmd)
323+
324+
if exit_code != 0 or not stdout.strip():
325+
return []
326+
327+
results: list[FileInfo] = []
328+
for line in stdout.strip().split("\n"):
329+
if not line:
330+
continue
331+
332+
try:
333+
import json
334+
335+
data = json.loads(line)
336+
# Convert relative path to absolute based on search path
337+
file_path = data["path"]
338+
if not file_path.startswith("/"):
339+
if path == "/":
340+
file_path = "/" + file_path
341+
else:
342+
file_path = path.rstrip("/") + "/" + file_path
343+
344+
results.append(
345+
{
346+
"path": file_path,
347+
"is_dir": False,
348+
"size": data["size"],
349+
"modified_at": str(data["mtime"]),
350+
}
351+
)
352+
except (json.JSONDecodeError, KeyError):
353+
continue
354+
355+
results.sort(key=lambda x: x.get("path", ""))
356+
return results

0 commit comments

Comments
 (0)