|
1 | 1 | """Virtual review runner - runs RLM reviews on GitHub content without local repo.""" |
2 | 2 |
|
3 | 3 | import asyncio |
| 4 | +import concurrent.futures |
4 | 5 | import logging |
5 | 6 | import re |
6 | 7 | from typing import Callable |
|
24 | 25 | from .repo_tools import RepoTools |
25 | 26 |
|
26 | 27 |
|
| 28 | +def _sync_call(coro): |
| 29 | + """Bridge async coroutines to sync using ThreadPoolExecutor. |
| 30 | +
|
| 31 | + DSPy RLM tools must be sync, but RepoTools/LocalRepoTools methods are async. |
| 32 | + This helper runs async code in a thread pool. |
| 33 | + """ |
| 34 | + loop = None |
| 35 | + try: |
| 36 | + loop = asyncio.get_running_loop() |
| 37 | + except RuntimeError: |
| 38 | + # No running loop, create one |
| 39 | + return asyncio.run(coro) |
| 40 | + |
| 41 | + # We're in an async context, use thread pool to run the coroutine |
| 42 | + with concurrent.futures.ThreadPoolExecutor() as executor: |
| 43 | + future = executor.submit(asyncio.run, coro) |
| 44 | + return future.result() |
| 45 | + |
| 46 | + |
27 | 47 | # Tool usage instructions for the model - simplified and clear |
28 | 48 | AGENTIC_TOOLS_PROMPT = """ |
29 | 49 | ## AVAILABLE COMMANDS (USE ONLY THESE!) |
@@ -126,24 +146,131 @@ def __init__( |
126 | 146 |
|
127 | 147 | def _load_local_checklist(self, path: str) -> str: |
128 | 148 | """Load a bundled checklist file from the CLI package. |
129 | | - |
| 149 | +
|
130 | 150 | Args: |
131 | 151 | path: Path like 'checklists/solid-checklist.md' |
132 | | - |
| 152 | +
|
133 | 153 | Returns: |
134 | 154 | Content of the checklist file, or error message if not found |
135 | 155 | """ |
136 | 156 | from pathlib import Path |
137 | | - |
| 157 | + |
138 | 158 | # Get the directory where this module is located |
139 | 159 | cli_dir = Path(__file__).parent |
140 | 160 | checklist_path = cli_dir / path |
141 | | - |
| 161 | + |
142 | 162 | if checklist_path.exists(): |
143 | 163 | return checklist_path.read_text() |
144 | 164 | else: |
145 | 165 | return f"[Error] Checklist not found: {path}" |
146 | | - |
| 166 | + |
| 167 | + def _sync_call(self, coro): |
| 168 | + """Bridge async coroutines to sync using ThreadPoolExecutor. |
| 169 | +
|
| 170 | + DSPy RLM tools must be sync, but RepoTools/LocalRepoTools methods are async. |
| 171 | + This helper runs the coroutine in a thread pool and returns the result. |
| 172 | +
|
| 173 | + Args: |
| 174 | + coro: An async coroutine to execute |
| 175 | +
|
| 176 | + Returns: |
| 177 | + The result of the coroutine |
| 178 | + """ |
| 179 | + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: |
| 180 | + future = executor.submit(asyncio.run, coro) |
| 181 | + return future.result() |
| 182 | + |
| 183 | + def _create_tool_functions(self): |
| 184 | + """Create sync tool wrapper functions for DSPy RLM. |
| 185 | +
|
| 186 | + Returns a list of three sync tool functions as closures that capture |
| 187 | + self by reference (so self._repo_tools can change between review calls). |
| 188 | +
|
| 189 | + Returns: |
| 190 | + List of [fetch_file, list_dir, search_code] functions |
| 191 | + """ |
| 192 | + runner = self |
| 193 | + |
| 194 | + def fetch_file(path: str) -> str: |
| 195 | + """Fetch a file from the repository by path. |
| 196 | +
|
| 197 | + Handles both regular repository files and bundled checklist files. |
| 198 | + Returns file content as a string, or an error message if the file |
| 199 | + cannot be read. |
| 200 | +
|
| 201 | + Args: |
| 202 | + path: File path (e.g., 'src/main.py' or 'checklists/solid-checklist.md') |
| 203 | +
|
| 204 | + Returns: |
| 205 | + File content as string, or error message |
| 206 | + """ |
| 207 | + if path.startswith("checklists/"): |
| 208 | + return runner._load_local_checklist(path) |
| 209 | + return runner._sync_call(runner._repo_tools.fetch_file(path)) |
| 210 | + |
| 211 | + def list_dir(path: str) -> str: |
| 212 | + """List directory contents at the given path. |
| 213 | +
|
| 214 | + Returns a formatted text listing of files and directories, |
| 215 | + showing path, type (file/dir), and size in bytes. |
| 216 | +
|
| 217 | + Args: |
| 218 | + path: Directory path (e.g., 'src' or '') |
| 219 | +
|
| 220 | + Returns: |
| 221 | + Formatted text listing of directory contents |
| 222 | + """ |
| 223 | + entries = runner._sync_call(runner._repo_tools.list_directory(path)) |
| 224 | + if not entries: |
| 225 | + return "[No entries]" |
| 226 | + |
| 227 | + # Format as readable text |
| 228 | + lines = [] |
| 229 | + for entry in entries: |
| 230 | + if "error" in entry: |
| 231 | + lines.append(f"[Error] {entry['error']}") |
| 232 | + else: |
| 233 | + entry_path = entry.get("path", "?") |
| 234 | + entry_type = entry.get("type", "?") |
| 235 | + entry_size = entry.get("size", 0) |
| 236 | + if entry_type == "dir": |
| 237 | + lines.append(f"[DIR] {entry_path}") |
| 238 | + else: |
| 239 | + lines.append(f"[FILE] {entry_path} ({entry_size} bytes)") |
| 240 | + return "\n".join(lines) |
| 241 | + |
| 242 | + def search_code(query: str) -> str: |
| 243 | + """Search for code patterns in the repository. |
| 244 | +
|
| 245 | + Searches for code content, filenames, or paths. Returns a formatted |
| 246 | + text listing of matching files with code fragments. |
| 247 | +
|
| 248 | + Args: |
| 249 | + query: Search query (e.g., 'enable_tool_optimization' or 'rlm.py') |
| 250 | +
|
| 251 | + Returns: |
| 252 | + Formatted text listing of search results with file paths and fragments |
| 253 | + """ |
| 254 | + results = runner._sync_call(runner._repo_tools.search_code(query)) |
| 255 | + if not results: |
| 256 | + return "[No matches found]" |
| 257 | + |
| 258 | + # Format as readable text |
| 259 | + lines = [] |
| 260 | + for result in results: |
| 261 | + path = result.get("path", "?") |
| 262 | + fragment = result.get("fragment", "") |
| 263 | + if fragment: |
| 264 | + # Truncate fragment if too long |
| 265 | + if len(fragment) > 100: |
| 266 | + fragment = fragment[:100] + "..." |
| 267 | + lines.append(f"{path}: {fragment}") |
| 268 | + else: |
| 269 | + lines.append(f"{path}") |
| 270 | + return "\n".join(lines) |
| 271 | + |
| 272 | + return [fetch_file, list_dir, search_code] |
| 273 | + |
147 | 274 | def _ensure_configured(self): |
148 | 275 | """Configure DSPy and RLM on first use.""" |
149 | 276 | if self._configured: |
|
0 commit comments