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