tenseleyflow/loader / 3ebef1c

Browse files

Move recovery services into runtime

Authored by espadonne
SHA
3ebef1c35ccfb59ea892af8abf630ab95607adc3
Parents
098f467
Tree
ba62823

8 changed files

StatusFile+-
M src/loader/agent/recovery.py 12 647
M src/loader/runtime/context.py 1 1
M src/loader/runtime/executor.py 1 1
C src/loader/runtime/recovery.py 0 0
M src/loader/runtime/tool_batches.py 1 1
M tests/test_recovery.py 1 1
M tests/test_runtime_context.py 1 1
M tests/test_tool_batches.py 1 1
src/loader/agent/recovery.pymodified
@@ -1,647 +1,12 @@
1
-"""Error recovery system for the agent.
2
-
3
-Provides intelligent retry logic with adaptation when tools fail.
4
-"""
5
-
6
-from dataclasses import dataclass, field
7
-from enum import Enum, auto
8
-from typing import Any
9
-
10
-
11
-class ErrorCategory(Enum):
12
-    """Categories of errors for recovery strategies."""
13
-    # File system
14
-    FILE_NOT_FOUND = auto()
15
-    PERMISSION_DENIED = auto()
16
-    DISK_FULL = auto()
17
-    PATH_TOO_LONG = auto()
18
-
19
-    # Code/syntax
20
-    SYNTAX_ERROR = auto()
21
-    TYPE_ERROR = auto()
22
-    IMPORT_ERROR = auto()
23
-
24
-    # Commands/executables
25
-    COMMAND_NOT_FOUND = auto()
26
-    SCRIPT_NOT_FOUND = auto()  # npm/yarn/make script missing
27
-
28
-    # Dependencies
29
-    MISSING_DEPENDENCY = auto()  # Module/package not found
30
-    VERSION_MISMATCH = auto()  # Incompatible versions
31
-
32
-    # Build/compile
33
-    BUILD_ERROR = auto()
34
-    LINT_ERROR = auto()
35
-    TEST_FAILURE = auto()
36
-
37
-    # Runtime
38
-    TIMEOUT = auto()
39
-    OUT_OF_MEMORY = auto()
40
-    PORT_IN_USE = auto()
41
-    PROCESS_ERROR = auto()  # Segfault, killed, etc.
42
-
43
-    # Network/external
44
-    NETWORK_ERROR = auto()
45
-    CONNECTION_REFUSED = auto()  # Service not running
46
-    AUTH_ERROR = auto()  # Unauthorized, forbidden
47
-
48
-    # Git
49
-    GIT_CONFLICT = auto()
50
-    GIT_NOT_REPO = auto()
51
-    GIT_DIRTY = auto()  # Uncommitted changes blocking operation
52
-
53
-    # Config/environment
54
-    CONFIG_ERROR = auto()  # Invalid config, missing env var
55
-    INVALID_ARGUMENTS = auto()
56
-
57
-    # Fallback
58
-    UNKNOWN = auto()
59
-
60
-
61
-@dataclass
62
-class ToolAttempt:
63
-    """Record of a single tool execution attempt."""
64
-    tool_name: str
65
-    arguments: dict[str, Any]
66
-    error: str
67
-    category: ErrorCategory
68
-
69
-
70
-@dataclass
71
-class RecoveryContext:
72
-    """Tracks recovery state for a tool execution."""
73
-    original_tool: str
74
-    original_args: dict[str, Any]
75
-    attempts: list[ToolAttempt] = field(default_factory=list)
76
-    max_retries: int = 3
77
-
78
-    def add_attempt(self, tool_name: str, args: dict[str, Any], error: str) -> None:
79
-        """Record an attempted tool execution."""
80
-        category = categorize_error(error)
81
-        self.attempts.append(ToolAttempt(
82
-            tool_name=tool_name,
83
-            arguments=args,
84
-            error=error,
85
-            category=category,
86
-        ))
87
-
88
-    def can_retry(self) -> bool:
89
-        """Check if more retries are allowed."""
90
-        return len(self.attempts) < self.max_retries
91
-
92
-    def attempts_summary(self) -> str:
93
-        """Summarize what's been tried for the LLM."""
94
-        if not self.attempts:
95
-            return ""
96
-
97
-        lines = ["Previous attempts:"]
98
-        for i, attempt in enumerate(self.attempts, 1):
99
-            args_str = ", ".join(f"{k}={v!r}" for k, v in attempt.arguments.items())
100
-            lines.append(f"{i}. {attempt.tool_name}({args_str})")
101
-            lines.append(f"   Error: {attempt.error}")
102
-        return "\n".join(lines)
103
-
104
-    def was_tried(self, tool_name: str, args: dict[str, Any]) -> bool:
105
-        """Check if this exact tool+args combination was already tried."""
106
-        for attempt in self.attempts:
107
-            if attempt.tool_name == tool_name and attempt.arguments == args:
108
-                return True
109
-        return False
110
-
111
-    def is_similar_attempt(self, tool_name: str, args: dict[str, Any]) -> bool:
112
-        """Check if this is essentially the same as a previous attempt.
113
-
114
-        Catches cases like:
115
-        - npm start vs npm run start (same thing)
116
-        - python script.py vs python3 script.py
117
-        - Same command with minor flag variations
118
-        """
119
-        if tool_name != "bash":
120
-            return self.was_tried(tool_name, args)
121
-
122
-        new_cmd = args.get("command", "")
123
-        new_cmd_normalized = self._normalize_command(new_cmd)
124
-
125
-        for attempt in self.attempts:
126
-            if attempt.tool_name != "bash":
127
-                continue
128
-
129
-            old_cmd = attempt.arguments.get("command", "")
130
-            old_cmd_normalized = self._normalize_command(old_cmd)
131
-
132
-            if new_cmd_normalized == old_cmd_normalized:
133
-                return True
134
-
135
-        return False
136
-
137
-    @staticmethod
138
-    def _normalize_command(cmd: str) -> str:
139
-        """Normalize a command for comparison.
140
-
141
-        Makes these equivalent:
142
-        - npm start == npm run start
143
-        - cd dir && npm start == npm start (in dir context)
144
-        - python == python3
145
-        """
146
-        import re
147
-
148
-        # Remove cd prefix (we care about the actual command)
149
-        cmd = re.sub(r'^cd\s+[^\s]+\s*&&\s*', '', cmd)
150
-
151
-        # Normalize npm commands
152
-        cmd = re.sub(r'\bnpm run start\b', 'npm start', cmd)
153
-        cmd = re.sub(r'\bnpm run serve\b', 'npm serve', cmd)
154
-        cmd = re.sub(r'\bnpm run dev\b', 'npm dev', cmd)
155
-
156
-        # Normalize python
157
-        cmd = re.sub(r'\bpython3\b', 'python', cmd)
158
-
159
-        # Normalize whitespace
160
-        cmd = ' '.join(cmd.split())
161
-
162
-        return cmd.strip()
163
-
164
-
165
-def categorize_error(error_message: str) -> ErrorCategory:
166
-    """Categorize an error message for recovery strategy selection."""
167
-    error_lower = error_message.lower()
168
-
169
-    # === MOST SPECIFIC FIRST ===
170
-
171
-    # Git errors
172
-    if any(x in error_lower for x in [
173
-        "merge conflict", "conflict", "unmerged paths",
174
-        "fix conflicts", "both modified",
175
-    ]):
176
-        return ErrorCategory.GIT_CONFLICT
177
-
178
-    if any(x in error_lower for x in [
179
-        "not a git repository", "fatal: not a git",
180
-        "not in a git directory",
181
-    ]):
182
-        return ErrorCategory.GIT_NOT_REPO
183
-
184
-    if any(x in error_lower for x in [
185
-        "uncommitted changes", "working tree not clean",
186
-        "please commit or stash", "local changes would be overwritten",
187
-        "changes not staged",
188
-    ]):
189
-        return ErrorCategory.GIT_DIRTY
190
-
191
-    # Script/task runner errors
192
-    if any(x in error_lower for x in [
193
-        "missing script", "npm err! missing script",
194
-        "script not found", "no script",
195
-        "error: script", "yarn run error",
196
-        "make: *** no rule to make target",
197
-        "no such task", "task not found",
198
-    ]):
199
-        return ErrorCategory.SCRIPT_NOT_FOUND
200
-
201
-    # Port/address errors
202
-    if any(x in error_lower for x in [
203
-        "address already in use", "port already in use",
204
-        "eaddrinuse", "bind: address already in use",
205
-        "port is already allocated",
206
-    ]):
207
-        return ErrorCategory.PORT_IN_USE
208
-
209
-    # Connection/service errors
210
-    if any(x in error_lower for x in [
211
-        "connection refused", "econnrefused",
212
-        "could not connect", "unable to connect",
213
-        "service unavailable", "no such host",
214
-    ]):
215
-        return ErrorCategory.CONNECTION_REFUSED
216
-
217
-    # Auth errors
218
-    if any(x in error_lower for x in [
219
-        "unauthorized", "403 forbidden", "401 unauthorized",
220
-        "authentication failed", "invalid credentials",
221
-        "invalid token", "token expired",
222
-    ]):
223
-        return ErrorCategory.AUTH_ERROR
224
-
225
-    # Version/compatibility
226
-    if any(x in error_lower for x in [
227
-        "version mismatch", "incompatible version",
228
-        "requires node", "requires python",
229
-        "unsupported version", "engine requirements",
230
-        "peer dep", "peer dependency",
231
-    ]):
232
-        return ErrorCategory.VERSION_MISMATCH
233
-
234
-    # Test failures
235
-    if any(x in error_lower for x in [
236
-        "test failed", "tests failed", "failing tests",
237
-        "assertion error", "assertionerror",
238
-        "expected", "to equal", "to be",
239
-        "test suite failed",
240
-    ]):
241
-        return ErrorCategory.TEST_FAILURE
242
-
243
-    # Lint errors
244
-    if any(x in error_lower for x in [
245
-        "eslint", "pylint", "flake8", "ruff",
246
-        "lint error", "linting failed",
247
-        "style violation", "formatting error",
248
-    ]):
249
-        return ErrorCategory.LINT_ERROR
250
-
251
-    # Type errors (specific)
252
-    if any(x in error_lower for x in [
253
-        "typeerror", "type error",
254
-        "is not a function", "is not defined",
255
-        "undefined is not", "null is not",
256
-        "cannot read propert", "has no attribute",
257
-    ]):
258
-        return ErrorCategory.TYPE_ERROR
259
-
260
-    # Import errors (specific)
261
-    if any(x in error_lower for x in [
262
-        "importerror", "import error",
263
-        "cannot import", "failed to import",
264
-        "no module named", "module not found",
265
-    ]):
266
-        return ErrorCategory.IMPORT_ERROR
267
-
268
-    # Missing dependencies/modules
269
-    if any(x in error_lower for x in [
270
-        "cannot find module", "module not found",
271
-        "package not found", "not installed",
272
-        "modulenotfounderror", "could not resolve",
273
-        "missing dependency", "unmet dependency",
274
-    ]):
275
-        return ErrorCategory.MISSING_DEPENDENCY
276
-
277
-    # Build/compilation errors
278
-    if any(x in error_lower for x in [
279
-        "build failed", "compilation error", "compile error",
280
-        "tsc error", "failed to compile", "build error",
281
-        "bundler error", "webpack error", "vite error",
282
-    ]):
283
-        return ErrorCategory.BUILD_ERROR
284
-
285
-    # Config/environment errors
286
-    if any(x in error_lower for x in [
287
-        "invalid configuration", "config error",
288
-        "missing environment", "env var", "environment variable",
289
-        "configuration file", "config file not found",
290
-        ".env", "dotenv",
291
-    ]):
292
-        return ErrorCategory.CONFIG_ERROR
293
-
294
-    # Memory/resource errors
295
-    if any(x in error_lower for x in [
296
-        "out of memory", "memory error", "heap out of memory",
297
-        "javascript heap", "killed", "oom",
298
-        "cannot allocate memory", "memoryerror",
299
-    ]):
300
-        return ErrorCategory.OUT_OF_MEMORY
301
-
302
-    # Process errors
303
-    if any(x in error_lower for x in [
304
-        "segmentation fault", "segfault", "sigsegv",
305
-        "bus error", "sigbus", "core dumped",
306
-        "aborted", "sigabrt",
307
-    ]):
308
-        return ErrorCategory.PROCESS_ERROR
309
-
310
-    # Disk space
311
-    if any(x in error_lower for x in [
312
-        "no space left", "disk full", "enospc",
313
-        "not enough space", "disk quota exceeded",
314
-    ]):
315
-        return ErrorCategory.DISK_FULL
316
-
317
-    # === GENERAL CATEGORIES ===
318
-
319
-    if any(x in error_lower for x in ["no such file", "file not found", "does not exist", "enoent"]):
320
-        return ErrorCategory.FILE_NOT_FOUND
321
-
322
-    if any(x in error_lower for x in ["permission denied", "access denied", "not permitted", "eacces"]):
323
-        return ErrorCategory.PERMISSION_DENIED
324
-
325
-    if any(x in error_lower for x in ["syntax error", "invalid syntax", "parse error", "unexpected token"]):
326
-        return ErrorCategory.SYNTAX_ERROR
327
-
328
-    if any(x in error_lower for x in ["command not found", "not recognized", "no such command", "not found in path"]):
329
-        return ErrorCategory.COMMAND_NOT_FOUND
330
-
331
-    if any(x in error_lower for x in ["timeout", "timed out", "etimedout", "deadline exceeded"]):
332
-        return ErrorCategory.TIMEOUT
333
-
334
-    if any(x in error_lower for x in ["invalid argument", "missing required", "bad argument"]):
335
-        return ErrorCategory.INVALID_ARGUMENTS
336
-
337
-    if any(x in error_lower for x in ["network", "unreachable", "dns", "getaddrinfo"]):
338
-        return ErrorCategory.NETWORK_ERROR
339
-
340
-    return ErrorCategory.UNKNOWN
341
-
342
-
343
-def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
344
-    """Get hints for recovering from a specific error category."""
345
-    hints = {
346
-        # File system
347
-        ErrorCategory.FILE_NOT_FOUND: [
348
-            "Use glob to search for the file: glob(pattern='**/<filename>')",
349
-            "List the directory to see what exists: bash(ls -la <dir>)",
350
-            "Check for typos in the filename",
351
-            "The file might be in a different directory",
352
-        ],
353
-        ErrorCategory.PERMISSION_DENIED: [
354
-            "Check file permissions: bash(ls -la <file>)",
355
-            "The file might be read-only or owned by another user",
356
-            "Try a different location that is writable",
357
-        ],
358
-        ErrorCategory.DISK_FULL: [
359
-            "Check disk space: bash(df -h)",
360
-            "Clean up temporary files or free space",
361
-            "The operation cannot proceed until space is available",
362
-        ],
363
-
364
-        # Code/syntax
365
-        ErrorCategory.SYNTAX_ERROR: [
366
-            "Read the file to see the current content: read(file_path=...)",
367
-            "Check the exact line number mentioned in the error",
368
-            "Verify brackets, quotes, and indentation are correct",
369
-        ],
370
-        ErrorCategory.TYPE_ERROR: [
371
-            "Read the error carefully - it tells you what type was expected vs received",
372
-            "Check if a variable is undefined or null before using it",
373
-            "Verify function arguments match the expected types",
374
-        ],
375
-        ErrorCategory.IMPORT_ERROR: [
376
-            "Check if the module is installed: pip show <pkg> or npm list <pkg>",
377
-            "Verify the import path is correct",
378
-            "The module name might be different from the package name",
379
-        ],
380
-
381
-        # Commands
382
-        ErrorCategory.COMMAND_NOT_FOUND: [
383
-            "Check if the tool is installed: bash(which <command>)",
384
-            "Install the missing tool if needed",
385
-            "Use an alternative command that achieves the same goal",
386
-        ],
387
-        ErrorCategory.SCRIPT_NOT_FOUND: [
388
-            "FIRST: Read package.json/Makefile to see available scripts",
389
-            "Run `npm run` or `make help` to list available targets",
390
-            "Common alternatives: dev, serve, start:dev, develop",
391
-            "You may need to run the entry point directly: node index.js",
392
-        ],
393
-
394
-        # Dependencies
395
-        ErrorCategory.MISSING_DEPENDENCY: [
396
-            "Install dependencies: npm install, pip install -r requirements.txt, etc.",
397
-            "Check if you're in the correct project directory",
398
-            "The package name might be different: check package.json or requirements.txt",
399
-        ],
400
-        ErrorCategory.VERSION_MISMATCH: [
401
-            "Check the required version in package.json or similar",
402
-            "Update the tool: nvm use, pyenv, etc.",
403
-            "Consider using a version manager",
404
-        ],
405
-
406
-        # Build
407
-        ErrorCategory.BUILD_ERROR: [
408
-            "Read the error output - it usually points to a specific file:line",
409
-            "Read the problematic file to understand the issue",
410
-            "Try running a linter first to catch obvious issues",
411
-        ],
412
-        ErrorCategory.LINT_ERROR: [
413
-            "Read the linter output for specific issues",
414
-            "Auto-fix if possible: npm run lint --fix, ruff --fix",
415
-            "Read the problematic file and fix the style issues",
416
-        ],
417
-        ErrorCategory.TEST_FAILURE: [
418
-            "Read the test output to see which test failed and why",
419
-            "Read the failing test file to understand what's expected",
420
-            "Check if the code being tested has the expected behavior",
421
-        ],
422
-
423
-        # Runtime
424
-        ErrorCategory.TIMEOUT: [
425
-            "The operation is taking too long",
426
-            "Try a simpler or more targeted operation",
427
-            "Break the task into smaller steps",
428
-        ],
429
-        ErrorCategory.OUT_OF_MEMORY: [
430
-            "The operation requires too much memory",
431
-            "Try processing in smaller batches",
432
-            "Close other applications if possible",
433
-        ],
434
-        ErrorCategory.PORT_IN_USE: [
435
-            "Find what's using the port: bash(lsof -i :<port>) or bash(netstat -tlnp)",
436
-            "Kill the existing process or use a different port",
437
-            "Check if another instance is already running",
438
-        ],
439
-        ErrorCategory.PROCESS_ERROR: [
440
-            "This is a crash - likely a bug in the code or a native dependency issue",
441
-            "Check if all native dependencies are installed",
442
-            "Try running with debug output",
443
-        ],
444
-
445
-        # Network
446
-        ErrorCategory.NETWORK_ERROR: [
447
-            "Check network connectivity",
448
-            "The service might be down or unreachable",
449
-            "Try an offline alternative if possible",
450
-        ],
451
-        ErrorCategory.CONNECTION_REFUSED: [
452
-            "The service is not running. Start it first.",
453
-            "For databases: start mysql/postgres/redis/etc.",
454
-            "For APIs: check if the server is running on the expected port",
455
-            "Common commands: systemctl start <service>, docker start <container>",
456
-        ],
457
-        ErrorCategory.AUTH_ERROR: [
458
-            "Check if credentials/tokens are correct",
459
-            "The token might be expired - try logging in again",
460
-            "Check if you have permission for this operation",
461
-        ],
462
-
463
-        # Git
464
-        ErrorCategory.GIT_CONFLICT: [
465
-            "Read the conflicted files to see the conflict markers",
466
-            "Resolve conflicts by editing the files",
467
-            "After resolving: git add <files> && git commit",
468
-        ],
469
-        ErrorCategory.GIT_NOT_REPO: [
470
-            "This directory is not a git repository",
471
-            "Either cd to the correct directory or run git init",
472
-            "Check if you're in the right project folder",
473
-        ],
474
-        ErrorCategory.GIT_DIRTY: [
475
-            "You have uncommitted changes blocking the operation",
476
-            "Options: git stash, git commit, or git checkout -- <files>",
477
-            "Check what's changed: git status",
478
-        ],
479
-
480
-        # Config
481
-        ErrorCategory.CONFIG_ERROR: [
482
-            "Read the config file to check for issues",
483
-            "Check for missing environment variables",
484
-            "Look for a .env.example file to see required vars",
485
-        ],
486
-        ErrorCategory.INVALID_ARGUMENTS: [
487
-            "Review the tool/command parameters",
488
-            "Check documentation for correct usage",
489
-            "Verify argument types and formats",
490
-        ],
491
-
492
-        # Fallback
493
-        ErrorCategory.UNKNOWN: [
494
-            "INVESTIGATE: Read relevant files to understand the error",
495
-            "Try a fundamentally different approach",
496
-            "Break the task into smaller diagnostic steps",
497
-        ],
498
-    }
499
-
500
-    category_hints = hints.get(category, hints[ErrorCategory.UNKNOWN])
501
-
502
-    # Add tool-specific hints
503
-    if tool_name == "edit" and category == ErrorCategory.FILE_NOT_FOUND:
504
-        category_hints = ["Use 'write' tool instead of 'edit' to create a new file"] + category_hints
505
-
506
-    if tool_name == "bash" and category == ErrorCategory.COMMAND_NOT_FOUND:
507
-        category_hints = ["Check if installed: bash(which <command>)"] + category_hints
508
-
509
-    return "\n".join(f"- {hint}" for hint in category_hints)
510
-
511
-
512
-RECOVERY_PROMPT = """## TOOL FAILURE - INVESTIGATE AND ADAPT
513
-
514
-The command failed. You MUST analyze the error and take a DIFFERENT action.
515
-
516
-**Failed Command:** {tool_name}({args})
517
-**Error Type:** {category}
518
-**Error Message:** {error}
519
-
520
-{attempts_summary}
521
-
522
-## REQUIRED: Choose ONE of these recovery actions:
523
-
524
-{hints}
525
-
526
-## CRITICAL RULES:
527
-1. **INVESTIGATE FIRST** - Read config files, list directories, check what exists
528
-2. **DO NOT** just retry the same command with slight variations
529
-3. **DO NOT** try `npm start` then `npm run start` - these are the same thing!
530
-4. **READ THE ERROR** - It usually tells you exactly what's wrong
531
-5. If the error says "missing script: start", read package.json to see what scripts exist
532
-
533
-## Current attempt: {attempt_count}/{max_retries}
534
-
535
-**Your next action should gather information OR try a fundamentally different approach.**
536
-What will you do?"""
537
-
538
-
539
-def format_recovery_prompt(
540
-    context: RecoveryContext,
541
-    tool_name: str,
542
-    args: dict[str, Any],
543
-    error: str,
544
-) -> str:
545
-    """Format a prompt asking the LLM to recover from an error."""
546
-    category = categorize_error(error)
547
-    hints = get_recovery_hints(category, tool_name)
548
-    args_str = ", ".join(f"{k}={v!r}" for k, v in args.items())
549
-
550
-    return RECOVERY_PROMPT.format(
551
-        tool_name=tool_name,
552
-        args=args_str,
553
-        error=error,
554
-        category=category.name.replace("_", " ").title(),
555
-        attempts_summary=context.attempts_summary(),
556
-        hints=hints,
557
-        attempt_count=len(context.attempts),
558
-        max_retries=context.max_retries,
559
-    )
560
-
561
-
562
-def format_failure_message(context: RecoveryContext) -> str:
563
-    """Format a message when all retries are exhausted."""
564
-    # Get the category from the last attempt to provide specific guidance
565
-    last_category = context.attempts[-1].category if context.attempts else ErrorCategory.UNKNOWN
566
-
567
-    lines = [
568
-        f"Failed to complete the operation after {len(context.attempts)} attempts.",
569
-        "",
570
-        "What was tried:",
571
-    ]
572
-
573
-    for i, attempt in enumerate(context.attempts, 1):
574
-        args_str = ", ".join(f"{k}={v!r}" for k, v in attempt.arguments.items())
575
-        lines.append(f"{i}. {attempt.tool_name}({args_str})")
576
-        # Truncate long error messages
577
-        error_preview = attempt.error[:200] + "..." if len(attempt.error) > 200 else attempt.error
578
-        lines.append(f"   Error: {error_preview}")
579
-
580
-    # Category-specific suggestions for user action
581
-    suggestions = {
582
-        ErrorCategory.SCRIPT_NOT_FOUND: [
583
-            "Check package.json/Makefile to see available scripts",
584
-            "The project might not have a start script - check the README",
585
-            "Try running the main file directly: node index.js or similar",
586
-        ],
587
-        ErrorCategory.MISSING_DEPENDENCY: [
588
-            "Run: npm install, pip install -r requirements.txt, etc.",
589
-            "Check if you're in the correct project directory",
590
-        ],
591
-        ErrorCategory.FILE_NOT_FOUND: [
592
-            "Verify the file path exists",
593
-            "Use 'find' or 'ls' to locate the file",
594
-        ],
595
-        ErrorCategory.PORT_IN_USE: [
596
-            "Find and kill the process using the port",
597
-            "Or use a different port",
598
-        ],
599
-        ErrorCategory.CONNECTION_REFUSED: [
600
-            "Start the required service (database, API server, etc.)",
601
-            "Check if the service is configured correctly",
602
-        ],
603
-        ErrorCategory.GIT_CONFLICT: [
604
-            "Manually resolve the merge conflicts",
605
-            "Look for <<<<<<< markers in the files",
606
-        ],
607
-        ErrorCategory.GIT_DIRTY: [
608
-            "Commit or stash your changes first",
609
-            "Run: git status to see what's changed",
610
-        ],
611
-        ErrorCategory.AUTH_ERROR: [
612
-            "Check your credentials/tokens",
613
-            "You may need to log in again",
614
-        ],
615
-        ErrorCategory.BUILD_ERROR: [
616
-            "Check the build output for specific file:line errors",
617
-            "Fix the syntax/type errors in the mentioned files",
618
-        ],
619
-        ErrorCategory.TEST_FAILURE: [
620
-            "Review the test output to see what failed",
621
-            "The tests may need to be updated for code changes",
622
-        ],
623
-        ErrorCategory.CONFIG_ERROR: [
624
-            "Check your configuration files",
625
-            "Look for missing environment variables",
626
-        ],
627
-        ErrorCategory.OUT_OF_MEMORY: [
628
-            "Try processing less data at once",
629
-            "Close other applications to free memory",
630
-        ],
631
-        ErrorCategory.VERSION_MISMATCH: [
632
-            "Check required versions in package.json/pyproject.toml",
633
-            "Use a version manager (nvm, pyenv) to switch versions",
634
-        ],
635
-    }
636
-
637
-    specific_suggestions = suggestions.get(last_category, [
638
-        "Manually check the file/directory structure",
639
-        "Review the error messages for clues",
640
-        "Try a completely different approach",
641
-    ])
642
-
643
-    lines.extend(["", "Suggestions:"])
644
-    for suggestion in specific_suggestions:
645
-        lines.append(f"- {suggestion}")
646
-
647
-    return "\n".join(lines)
1
+"""Legacy compatibility exports for runtime-owned recovery services."""
2
+
3
+from ..runtime.recovery import (
4
+    ErrorCategory,
5
+    RECOVERY_PROMPT,
6
+    RecoveryContext,
7
+    ToolAttempt,
8
+    categorize_error,
9
+    format_failure_message,
10
+    format_recovery_prompt,
11
+    get_recovery_hints,
12
+)
src/loader/runtime/context.pymodified
@@ -11,12 +11,12 @@ from ..agent.reasoning import (
1111
     ActionVerification,
1212
     ConfidenceAssessment,
1313
 )
14
-from ..agent.recovery import RecoveryContext
1514
 from ..context.project import ProjectContext
1615
 from ..llm.base import LLMBackend, Message
1716
 from ..tools.base import ToolRegistry
1817
 from .capabilities import CapabilityProfile
1918
 from .permissions import PermissionConfigStatus, PermissionPolicy
19
+from .recovery import RecoveryContext
2020
 from .session import ConversationSession
2121
 
2222
 
src/loader/runtime/executor.pymodified
@@ -8,13 +8,13 @@ from enum import StrEnum
88
 from typing import Any
99
 
1010
 from ..agent.parsing import format_tool_result
11
-from ..agent.recovery import ErrorCategory, categorize_error
1211
 from ..llm.base import Message, ToolCall
1312
 from ..tools.base import ConfirmationRequired, ToolRegistry
1413
 from ..tools.base import ToolResult as RegistryToolResult
1514
 from ..tools.workflow_tools import UserQuestionHandler
1615
 from .hooks import HookContext, HookDecision, HookManager
1716
 from .permissions import PermissionDecision, PermissionMode, PermissionPolicy
17
+from .recovery import ErrorCategory, categorize_error
1818
 from .tracing import RuntimeTracer
1919
 
2020
 BrowserConfirmation = Callable[[str, str, str], Awaitable[bool]] | None
src/loader/agent/recovery.py → src/loader/runtime/recovery.pycopied (65% similarity)
@@ -1,7 +1,6 @@
1
-"""Error recovery system for the agent.
1
+"""Runtime-owned recovery state and retry guidance services."""
22
 
3
-Provides intelligent retry logic with adaptation when tools fail.
4
-"""
3
+from __future__ import annotations
54
 
65
 from dataclasses import dataclass, field
76
 from enum import Enum, auto
@@ -10,57 +9,49 @@ from typing import Any
109
 
1110
 class ErrorCategory(Enum):
1211
     """Categories of errors for recovery strategies."""
13
-    # File system
12
+
1413
     FILE_NOT_FOUND = auto()
1514
     PERMISSION_DENIED = auto()
1615
     DISK_FULL = auto()
1716
     PATH_TOO_LONG = auto()
1817
 
19
-    # Code/syntax
2018
     SYNTAX_ERROR = auto()
2119
     TYPE_ERROR = auto()
2220
     IMPORT_ERROR = auto()
2321
 
24
-    # Commands/executables
2522
     COMMAND_NOT_FOUND = auto()
26
-    SCRIPT_NOT_FOUND = auto()  # npm/yarn/make script missing
23
+    SCRIPT_NOT_FOUND = auto()
2724
 
28
-    # Dependencies
29
-    MISSING_DEPENDENCY = auto()  # Module/package not found
30
-    VERSION_MISMATCH = auto()  # Incompatible versions
25
+    MISSING_DEPENDENCY = auto()
26
+    VERSION_MISMATCH = auto()
3127
 
32
-    # Build/compile
3328
     BUILD_ERROR = auto()
3429
     LINT_ERROR = auto()
3530
     TEST_FAILURE = auto()
3631
 
37
-    # Runtime
3832
     TIMEOUT = auto()
3933
     OUT_OF_MEMORY = auto()
4034
     PORT_IN_USE = auto()
41
-    PROCESS_ERROR = auto()  # Segfault, killed, etc.
35
+    PROCESS_ERROR = auto()
4236
 
43
-    # Network/external
4437
     NETWORK_ERROR = auto()
45
-    CONNECTION_REFUSED = auto()  # Service not running
46
-    AUTH_ERROR = auto()  # Unauthorized, forbidden
38
+    CONNECTION_REFUSED = auto()
39
+    AUTH_ERROR = auto()
4740
 
48
-    # Git
4941
     GIT_CONFLICT = auto()
5042
     GIT_NOT_REPO = auto()
51
-    GIT_DIRTY = auto()  # Uncommitted changes blocking operation
43
+    GIT_DIRTY = auto()
5244
 
53
-    # Config/environment
54
-    CONFIG_ERROR = auto()  # Invalid config, missing env var
45
+    CONFIG_ERROR = auto()
5546
     INVALID_ARGUMENTS = auto()
5647
 
57
-    # Fallback
5848
     UNKNOWN = auto()
5949
 
6050
 
6151
 @dataclass
6252
 class ToolAttempt:
6353
     """Record of a single tool execution attempt."""
54
+
6455
     tool_name: str
6556
     arguments: dict[str, Any]
6657
     error: str
@@ -70,6 +61,7 @@ class ToolAttempt:
7061
 @dataclass
7162
 class RecoveryContext:
7263
     """Tracks recovery state for a tool execution."""
64
+
7365
     original_tool: str
7466
     original_args: dict[str, Any]
7567
     attempts: list[ToolAttempt] = field(default_factory=list)
@@ -77,45 +69,48 @@ class RecoveryContext:
7769
 
7870
     def add_attempt(self, tool_name: str, args: dict[str, Any], error: str) -> None:
7971
         """Record an attempted tool execution."""
72
+
8073
         category = categorize_error(error)
81
-        self.attempts.append(ToolAttempt(
82
-            tool_name=tool_name,
83
-            arguments=args,
84
-            error=error,
85
-            category=category,
86
-        ))
74
+        self.attempts.append(
75
+            ToolAttempt(
76
+                tool_name=tool_name,
77
+                arguments=args,
78
+                error=error,
79
+                category=category,
80
+            )
81
+        )
8782
 
8883
     def can_retry(self) -> bool:
8984
         """Check if more retries are allowed."""
85
+
9086
         return len(self.attempts) < self.max_retries
9187
 
9288
     def attempts_summary(self) -> str:
9389
         """Summarize what's been tried for the LLM."""
90
+
9491
         if not self.attempts:
9592
             return ""
9693
 
9794
         lines = ["Previous attempts:"]
98
-        for i, attempt in enumerate(self.attempts, 1):
99
-            args_str = ", ".join(f"{k}={v!r}" for k, v in attempt.arguments.items())
100
-            lines.append(f"{i}. {attempt.tool_name}({args_str})")
95
+        for index, attempt in enumerate(self.attempts, 1):
96
+            args_str = ", ".join(
97
+                f"{key}={value!r}" for key, value in attempt.arguments.items()
98
+            )
99
+            lines.append(f"{index}. {attempt.tool_name}({args_str})")
101100
             lines.append(f"   Error: {attempt.error}")
102101
         return "\n".join(lines)
103102
 
104103
     def was_tried(self, tool_name: str, args: dict[str, Any]) -> bool:
105
-        """Check if this exact tool+args combination was already tried."""
104
+        """Check if this exact tool and args combination was already tried."""
105
+
106106
         for attempt in self.attempts:
107107
             if attempt.tool_name == tool_name and attempt.arguments == args:
108108
                 return True
109109
         return False
110110
 
111111
     def is_similar_attempt(self, tool_name: str, args: dict[str, Any]) -> bool:
112
-        """Check if this is essentially the same as a previous attempt.
112
+        """Check if this attempt is effectively the same as a previous one."""
113113
 
114
-        Catches cases like:
115
-        - npm start vs npm run start (same thing)
116
-        - python script.py vs python3 script.py
117
-        - Same command with minor flag variations
118
-        """
119114
         if tool_name != "bash":
120115
             return self.was_tried(tool_name, args)
121116
 
@@ -136,205 +131,318 @@ class RecoveryContext:
136131
 
137132
     @staticmethod
138133
     def _normalize_command(cmd: str) -> str:
139
-        """Normalize a command for comparison.
134
+        """Normalize a shell command for comparison."""
140135
 
141
-        Makes these equivalent:
142
-        - npm start == npm run start
143
-        - cd dir && npm start == npm start (in dir context)
144
-        - python == python3
145
-        """
146136
         import re
147137
 
148
-        # Remove cd prefix (we care about the actual command)
149138
         cmd = re.sub(r'^cd\s+[^\s]+\s*&&\s*', '', cmd)
150
-
151
-        # Normalize npm commands
152139
         cmd = re.sub(r'\bnpm run start\b', 'npm start', cmd)
153140
         cmd = re.sub(r'\bnpm run serve\b', 'npm serve', cmd)
154141
         cmd = re.sub(r'\bnpm run dev\b', 'npm dev', cmd)
155
-
156
-        # Normalize python
157142
         cmd = re.sub(r'\bpython3\b', 'python', cmd)
158
-
159
-        # Normalize whitespace
160143
         cmd = ' '.join(cmd.split())
161
-
162144
         return cmd.strip()
163145
 
164146
 
165147
 def categorize_error(error_message: str) -> ErrorCategory:
166148
     """Categorize an error message for recovery strategy selection."""
167
-    error_lower = error_message.lower()
168149
 
169
-    # === MOST SPECIFIC FIRST ===
150
+    error_lower = error_message.lower()
170151
 
171
-    # Git errors
172
-    if any(x in error_lower for x in [
173
-        "merge conflict", "conflict", "unmerged paths",
174
-        "fix conflicts", "both modified",
175
-    ]):
152
+    if any(
153
+        token in error_lower
154
+        for token in [
155
+            "merge conflict",
156
+            "conflict",
157
+            "unmerged paths",
158
+            "fix conflicts",
159
+            "both modified",
160
+        ]
161
+    ):
176162
         return ErrorCategory.GIT_CONFLICT
177163
 
178
-    if any(x in error_lower for x in [
179
-        "not a git repository", "fatal: not a git",
180
-        "not in a git directory",
181
-    ]):
164
+    if any(
165
+        token in error_lower
166
+        for token in [
167
+            "not a git repository",
168
+            "fatal: not a git",
169
+            "not in a git directory",
170
+        ]
171
+    ):
182172
         return ErrorCategory.GIT_NOT_REPO
183173
 
184
-    if any(x in error_lower for x in [
185
-        "uncommitted changes", "working tree not clean",
186
-        "please commit or stash", "local changes would be overwritten",
187
-        "changes not staged",
188
-    ]):
174
+    if any(
175
+        token in error_lower
176
+        for token in [
177
+            "uncommitted changes",
178
+            "working tree not clean",
179
+            "please commit or stash",
180
+            "local changes would be overwritten",
181
+            "changes not staged",
182
+        ]
183
+    ):
189184
         return ErrorCategory.GIT_DIRTY
190185
 
191
-    # Script/task runner errors
192
-    if any(x in error_lower for x in [
193
-        "missing script", "npm err! missing script",
194
-        "script not found", "no script",
195
-        "error: script", "yarn run error",
196
-        "make: *** no rule to make target",
197
-        "no such task", "task not found",
198
-    ]):
186
+    if any(
187
+        token in error_lower
188
+        for token in [
189
+            "missing script",
190
+            "npm err! missing script",
191
+            "script not found",
192
+            "no script",
193
+            "error: script",
194
+            "yarn run error",
195
+            "make: *** no rule to make target",
196
+            "no such task",
197
+            "task not found",
198
+        ]
199
+    ):
199200
         return ErrorCategory.SCRIPT_NOT_FOUND
200201
 
201
-    # Port/address errors
202
-    if any(x in error_lower for x in [
203
-        "address already in use", "port already in use",
204
-        "eaddrinuse", "bind: address already in use",
205
-        "port is already allocated",
206
-    ]):
202
+    if any(
203
+        token in error_lower
204
+        for token in [
205
+            "address already in use",
206
+            "port already in use",
207
+            "eaddrinuse",
208
+            "bind: address already in use",
209
+            "port is already allocated",
210
+        ]
211
+    ):
207212
         return ErrorCategory.PORT_IN_USE
208213
 
209
-    # Connection/service errors
210
-    if any(x in error_lower for x in [
211
-        "connection refused", "econnrefused",
212
-        "could not connect", "unable to connect",
213
-        "service unavailable", "no such host",
214
-    ]):
214
+    if any(
215
+        token in error_lower
216
+        for token in [
217
+            "connection refused",
218
+            "econnrefused",
219
+            "could not connect",
220
+            "unable to connect",
221
+            "service unavailable",
222
+            "no such host",
223
+        ]
224
+    ):
215225
         return ErrorCategory.CONNECTION_REFUSED
216226
 
217
-    # Auth errors
218
-    if any(x in error_lower for x in [
219
-        "unauthorized", "403 forbidden", "401 unauthorized",
220
-        "authentication failed", "invalid credentials",
221
-        "invalid token", "token expired",
222
-    ]):
227
+    if any(
228
+        token in error_lower
229
+        for token in [
230
+            "unauthorized",
231
+            "403 forbidden",
232
+            "401 unauthorized",
233
+            "authentication failed",
234
+            "invalid credentials",
235
+            "invalid token",
236
+            "token expired",
237
+        ]
238
+    ):
223239
         return ErrorCategory.AUTH_ERROR
224240
 
225
-    # Version/compatibility
226
-    if any(x in error_lower for x in [
227
-        "version mismatch", "incompatible version",
228
-        "requires node", "requires python",
229
-        "unsupported version", "engine requirements",
230
-        "peer dep", "peer dependency",
231
-    ]):
241
+    if any(
242
+        token in error_lower
243
+        for token in [
244
+            "version mismatch",
245
+            "incompatible version",
246
+            "requires node",
247
+            "requires python",
248
+            "unsupported version",
249
+            "engine requirements",
250
+            "peer dep",
251
+            "peer dependency",
252
+        ]
253
+    ):
232254
         return ErrorCategory.VERSION_MISMATCH
233255
 
234
-    # Test failures
235
-    if any(x in error_lower for x in [
236
-        "test failed", "tests failed", "failing tests",
237
-        "assertion error", "assertionerror",
238
-        "expected", "to equal", "to be",
239
-        "test suite failed",
240
-    ]):
256
+    if any(
257
+        token in error_lower
258
+        for token in [
259
+            "test failed",
260
+            "tests failed",
261
+            "failing tests",
262
+            "assertion error",
263
+            "assertionerror",
264
+            "expected",
265
+            "to equal",
266
+            "to be",
267
+            "test suite failed",
268
+        ]
269
+    ):
241270
         return ErrorCategory.TEST_FAILURE
242271
 
243
-    # Lint errors
244
-    if any(x in error_lower for x in [
245
-        "eslint", "pylint", "flake8", "ruff",
246
-        "lint error", "linting failed",
247
-        "style violation", "formatting error",
248
-    ]):
272
+    if any(
273
+        token in error_lower
274
+        for token in [
275
+            "eslint",
276
+            "pylint",
277
+            "flake8",
278
+            "ruff",
279
+            "lint error",
280
+            "linting failed",
281
+            "style violation",
282
+            "formatting error",
283
+        ]
284
+    ):
249285
         return ErrorCategory.LINT_ERROR
250286
 
251
-    # Type errors (specific)
252
-    if any(x in error_lower for x in [
253
-        "typeerror", "type error",
254
-        "is not a function", "is not defined",
255
-        "undefined is not", "null is not",
256
-        "cannot read propert", "has no attribute",
257
-    ]):
287
+    if any(
288
+        token in error_lower
289
+        for token in [
290
+            "typeerror",
291
+            "type error",
292
+            "is not a function",
293
+            "is not defined",
294
+            "undefined is not",
295
+            "null is not",
296
+            "cannot read propert",
297
+            "has no attribute",
298
+        ]
299
+    ):
258300
         return ErrorCategory.TYPE_ERROR
259301
 
260
-    # Import errors (specific)
261
-    if any(x in error_lower for x in [
262
-        "importerror", "import error",
263
-        "cannot import", "failed to import",
264
-        "no module named", "module not found",
265
-    ]):
302
+    if any(
303
+        token in error_lower
304
+        for token in [
305
+            "importerror",
306
+            "import error",
307
+            "cannot import",
308
+            "failed to import",
309
+            "no module named",
310
+            "module not found",
311
+        ]
312
+    ):
266313
         return ErrorCategory.IMPORT_ERROR
267314
 
268
-    # Missing dependencies/modules
269
-    if any(x in error_lower for x in [
270
-        "cannot find module", "module not found",
271
-        "package not found", "not installed",
272
-        "modulenotfounderror", "could not resolve",
273
-        "missing dependency", "unmet dependency",
274
-    ]):
315
+    if any(
316
+        token in error_lower
317
+        for token in [
318
+            "cannot find module",
319
+            "module not found",
320
+            "package not found",
321
+            "not installed",
322
+            "modulenotfounderror",
323
+            "could not resolve",
324
+            "missing dependency",
325
+            "unmet dependency",
326
+        ]
327
+    ):
275328
         return ErrorCategory.MISSING_DEPENDENCY
276329
 
277
-    # Build/compilation errors
278
-    if any(x in error_lower for x in [
279
-        "build failed", "compilation error", "compile error",
280
-        "tsc error", "failed to compile", "build error",
281
-        "bundler error", "webpack error", "vite error",
282
-    ]):
330
+    if any(
331
+        token in error_lower
332
+        for token in [
333
+            "build failed",
334
+            "compilation error",
335
+            "compile error",
336
+            "tsc error",
337
+            "failed to compile",
338
+            "build error",
339
+            "bundler error",
340
+            "webpack error",
341
+            "vite error",
342
+        ]
343
+    ):
283344
         return ErrorCategory.BUILD_ERROR
284345
 
285
-    # Config/environment errors
286
-    if any(x in error_lower for x in [
287
-        "invalid configuration", "config error",
288
-        "missing environment", "env var", "environment variable",
289
-        "configuration file", "config file not found",
290
-        ".env", "dotenv",
291
-    ]):
346
+    if any(
347
+        token in error_lower
348
+        for token in [
349
+            "invalid configuration",
350
+            "config error",
351
+            "missing environment",
352
+            "env var",
353
+            "environment variable",
354
+            "configuration file",
355
+            "config file not found",
356
+            ".env",
357
+            "dotenv",
358
+        ]
359
+    ):
292360
         return ErrorCategory.CONFIG_ERROR
293361
 
294
-    # Memory/resource errors
295
-    if any(x in error_lower for x in [
296
-        "out of memory", "memory error", "heap out of memory",
297
-        "javascript heap", "killed", "oom",
298
-        "cannot allocate memory", "memoryerror",
299
-    ]):
362
+    if any(
363
+        token in error_lower
364
+        for token in [
365
+            "out of memory",
366
+            "memory error",
367
+            "heap out of memory",
368
+            "javascript heap",
369
+            "killed",
370
+            "oom",
371
+            "cannot allocate memory",
372
+            "memoryerror",
373
+        ]
374
+    ):
300375
         return ErrorCategory.OUT_OF_MEMORY
301376
 
302
-    # Process errors
303
-    if any(x in error_lower for x in [
304
-        "segmentation fault", "segfault", "sigsegv",
305
-        "bus error", "sigbus", "core dumped",
306
-        "aborted", "sigabrt",
307
-    ]):
377
+    if any(
378
+        token in error_lower
379
+        for token in [
380
+            "segmentation fault",
381
+            "segfault",
382
+            "sigsegv",
383
+            "bus error",
384
+            "sigbus",
385
+            "core dumped",
386
+            "aborted",
387
+            "sigabrt",
388
+        ]
389
+    ):
308390
         return ErrorCategory.PROCESS_ERROR
309391
 
310
-    # Disk space
311
-    if any(x in error_lower for x in [
312
-        "no space left", "disk full", "enospc",
313
-        "not enough space", "disk quota exceeded",
314
-    ]):
392
+    if any(
393
+        token in error_lower
394
+        for token in [
395
+            "no space left",
396
+            "disk full",
397
+            "enospc",
398
+            "not enough space",
399
+            "disk quota exceeded",
400
+        ]
401
+    ):
315402
         return ErrorCategory.DISK_FULL
316403
 
317
-    # === GENERAL CATEGORIES ===
318
-
319
-    if any(x in error_lower for x in ["no such file", "file not found", "does not exist", "enoent"]):
404
+    if any(
405
+        token in error_lower
406
+        for token in ["no such file", "file not found", "does not exist", "enoent"]
407
+    ):
320408
         return ErrorCategory.FILE_NOT_FOUND
321409
 
322
-    if any(x in error_lower for x in ["permission denied", "access denied", "not permitted", "eacces"]):
410
+    if any(
411
+        token in error_lower
412
+        for token in ["permission denied", "access denied", "not permitted", "eacces"]
413
+    ):
323414
         return ErrorCategory.PERMISSION_DENIED
324415
 
325
-    if any(x in error_lower for x in ["syntax error", "invalid syntax", "parse error", "unexpected token"]):
416
+    if any(
417
+        token in error_lower
418
+        for token in ["syntax error", "invalid syntax", "parse error", "unexpected token"]
419
+    ):
326420
         return ErrorCategory.SYNTAX_ERROR
327421
 
328
-    if any(x in error_lower for x in ["command not found", "not recognized", "no such command", "not found in path"]):
422
+    if any(
423
+        token in error_lower
424
+        for token in [
425
+            "command not found",
426
+            "not recognized",
427
+            "no such command",
428
+            "not found in path",
429
+        ]
430
+    ):
329431
         return ErrorCategory.COMMAND_NOT_FOUND
330432
 
331
-    if any(x in error_lower for x in ["timeout", "timed out", "etimedout", "deadline exceeded"]):
433
+    if any(
434
+        token in error_lower
435
+        for token in ["timeout", "timed out", "etimedout", "deadline exceeded"]
436
+    ):
332437
         return ErrorCategory.TIMEOUT
333438
 
334
-    if any(x in error_lower for x in ["invalid argument", "missing required", "bad argument"]):
439
+    if any(
440
+        token in error_lower
441
+        for token in ["invalid argument", "missing required", "bad argument"]
442
+    ):
335443
         return ErrorCategory.INVALID_ARGUMENTS
336444
 
337
-    if any(x in error_lower for x in ["network", "unreachable", "dns", "getaddrinfo"]):
445
+    if any(token in error_lower for token in ["network", "unreachable", "dns", "getaddrinfo"]):
338446
         return ErrorCategory.NETWORK_ERROR
339447
 
340448
     return ErrorCategory.UNKNOWN
@@ -342,8 +450,8 @@ def categorize_error(error_message: str) -> ErrorCategory:
342450
 
343451
 def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
344452
     """Get hints for recovering from a specific error category."""
453
+
345454
     hints = {
346
-        # File system
347455
         ErrorCategory.FILE_NOT_FOUND: [
348456
             "Use glob to search for the file: glob(pattern='**/<filename>')",
349457
             "List the directory to see what exists: bash(ls -la <dir>)",
@@ -360,8 +468,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
360468
             "Clean up temporary files or free space",
361469
             "The operation cannot proceed until space is available",
362470
         ],
363
-
364
-        # Code/syntax
365471
         ErrorCategory.SYNTAX_ERROR: [
366472
             "Read the file to see the current content: read(file_path=...)",
367473
             "Check the exact line number mentioned in the error",
@@ -377,8 +483,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
377483
             "Verify the import path is correct",
378484
             "The module name might be different from the package name",
379485
         ],
380
-
381
-        # Commands
382486
         ErrorCategory.COMMAND_NOT_FOUND: [
383487
             "Check if the tool is installed: bash(which <command>)",
384488
             "Install the missing tool if needed",
@@ -387,11 +491,9 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
387491
         ErrorCategory.SCRIPT_NOT_FOUND: [
388492
             "FIRST: Read package.json/Makefile to see available scripts",
389493
             "Run `npm run` or `make help` to list available targets",
390
-            "Common alternatives: dev, serve, start:dev, develop",
391
-            "You may need to run the entry point directly: node index.js",
494
+            "Do NOT guess script names - inspect what exists",
495
+            "Look for README or docs that explain the project workflow",
392496
         ],
393
-
394
-        # Dependencies
395497
         ErrorCategory.MISSING_DEPENDENCY: [
396498
             "Install dependencies: npm install, pip install -r requirements.txt, etc.",
397499
             "Check if you're in the correct project directory",
@@ -402,8 +504,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
402504
             "Update the tool: nvm use, pyenv, etc.",
403505
             "Consider using a version manager",
404506
         ],
405
-
406
-        # Build
407507
         ErrorCategory.BUILD_ERROR: [
408508
             "Read the error output - it usually points to a specific file:line",
409509
             "Read the problematic file to understand the issue",
@@ -419,8 +519,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
419519
             "Read the failing test file to understand what's expected",
420520
             "Check if the code being tested has the expected behavior",
421521
         ],
422
-
423
-        # Runtime
424522
         ErrorCategory.TIMEOUT: [
425523
             "The operation is taking too long",
426524
             "Try a simpler or more targeted operation",
@@ -441,8 +539,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
441539
             "Check if all native dependencies are installed",
442540
             "Try running with debug output",
443541
         ],
444
-
445
-        # Network
446542
         ErrorCategory.NETWORK_ERROR: [
447543
             "Check network connectivity",
448544
             "The service might be down or unreachable",
@@ -459,8 +555,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
459555
             "The token might be expired - try logging in again",
460556
             "Check if you have permission for this operation",
461557
         ],
462
-
463
-        # Git
464558
         ErrorCategory.GIT_CONFLICT: [
465559
             "Read the conflicted files to see the conflict markers",
466560
             "Resolve conflicts by editing the files",
@@ -476,8 +570,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
476570
             "Options: git stash, git commit, or git checkout -- <files>",
477571
             "Check what's changed: git status",
478572
         ],
479
-
480
-        # Config
481573
         ErrorCategory.CONFIG_ERROR: [
482574
             "Read the config file to check for issues",
483575
             "Check for missing environment variables",
@@ -488,8 +580,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
488580
             "Check documentation for correct usage",
489581
             "Verify argument types and formats",
490582
         ],
491
-
492
-        # Fallback
493583
         ErrorCategory.UNKNOWN: [
494584
             "INVESTIGATE: Read relevant files to understand the error",
495585
             "Try a fundamentally different approach",
@@ -499,7 +589,6 @@ def get_recovery_hints(category: ErrorCategory, tool_name: str) -> str:
499589
 
500590
     category_hints = hints.get(category, hints[ErrorCategory.UNKNOWN])
501591
 
502
-    # Add tool-specific hints
503592
     if tool_name == "edit" and category == ErrorCategory.FILE_NOT_FOUND:
504593
         category_hints = ["Use 'write' tool instead of 'edit' to create a new file"] + category_hints
505594
 
@@ -543,9 +632,10 @@ def format_recovery_prompt(
543632
     error: str,
544633
 ) -> str:
545634
     """Format a prompt asking the LLM to recover from an error."""
635
+
546636
     category = categorize_error(error)
547637
     hints = get_recovery_hints(category, tool_name)
548
-    args_str = ", ".join(f"{k}={v!r}" for k, v in args.items())
638
+    args_str = ", ".join(f"{key}={value!r}" for key, value in args.items())
549639
 
550640
     return RECOVERY_PROMPT.format(
551641
         tool_name=tool_name,
@@ -561,8 +651,10 @@ def format_recovery_prompt(
561651
 
562652
 def format_failure_message(context: RecoveryContext) -> str:
563653
     """Format a message when all retries are exhausted."""
564
-    # Get the category from the last attempt to provide specific guidance
565
-    last_category = context.attempts[-1].category if context.attempts else ErrorCategory.UNKNOWN
654
+
655
+    last_category = (
656
+        context.attempts[-1].category if context.attempts else ErrorCategory.UNKNOWN
657
+    )
566658
 
567659
     lines = [
568660
         f"Failed to complete the operation after {len(context.attempts)} attempts.",
@@ -570,14 +662,16 @@ def format_failure_message(context: RecoveryContext) -> str:
570662
         "What was tried:",
571663
     ]
572664
 
573
-    for i, attempt in enumerate(context.attempts, 1):
574
-        args_str = ", ".join(f"{k}={v!r}" for k, v in attempt.arguments.items())
575
-        lines.append(f"{i}. {attempt.tool_name}({args_str})")
576
-        # Truncate long error messages
577
-        error_preview = attempt.error[:200] + "..." if len(attempt.error) > 200 else attempt.error
665
+    for index, attempt in enumerate(context.attempts, 1):
666
+        args_str = ", ".join(
667
+            f"{key}={value!r}" for key, value in attempt.arguments.items()
668
+        )
669
+        lines.append(f"{index}. {attempt.tool_name}({args_str})")
670
+        error_preview = (
671
+            attempt.error[:200] + "..." if len(attempt.error) > 200 else attempt.error
672
+        )
578673
         lines.append(f"   Error: {error_preview}")
579674
 
580
-    # Category-specific suggestions for user action
581675
     suggestions = {
582676
         ErrorCategory.SCRIPT_NOT_FOUND: [
583677
             "Check package.json/Makefile to see available scripts",
@@ -634,11 +728,14 @@ def format_failure_message(context: RecoveryContext) -> str:
634728
         ],
635729
     }
636730
 
637
-    specific_suggestions = suggestions.get(last_category, [
638
-        "Manually check the file/directory structure",
639
-        "Review the error messages for clues",
640
-        "Try a completely different approach",
641
-    ])
731
+    specific_suggestions = suggestions.get(
732
+        last_category,
733
+        [
734
+            "Manually check the file/directory structure",
735
+            "Review the error messages for clues",
736
+            "Try a completely different approach",
737
+        ],
738
+    )
642739
 
643740
     lines.extend(["", "Suggestions:"])
644741
     for suggestion in specific_suggestions:
src/loader/runtime/tool_batches.pymodified
@@ -5,12 +5,12 @@ from __future__ import annotations
55
 from collections.abc import Awaitable, Callable
66
 from dataclasses import dataclass, field
77
 
8
-from ..agent.recovery import RecoveryContext, format_failure_message, format_recovery_prompt
98
 from ..llm.base import Message, Role, ToolCall
109
 from .context import RuntimeContext
1110
 from .dod import DefinitionOfDone, DefinitionOfDoneStore, record_successful_tool_call
1211
 from .events import AgentEvent, TurnSummary
1312
 from .executor import ToolExecutionState, ToolExecutor
13
+from .recovery import RecoveryContext, format_failure_message, format_recovery_prompt
1414
 from .workflow import sync_todos_to_definition_of_done
1515
 
1616
 EventSink = Callable[[AgentEvent], Awaitable[None]]
tests/test_recovery.pymodified
@@ -1,6 +1,6 @@
11
 """Tests for the error recovery system."""
22
 
3
-from loader.agent.recovery import (
3
+from loader.runtime.recovery import (
44
     ErrorCategory,
55
     RecoveryContext,
66
     categorize_error,
tests/test_runtime_context.pymodified
@@ -5,8 +5,8 @@ from __future__ import annotations
55
 from pathlib import Path
66
 
77
 from loader.agent.loop import Agent, AgentConfig
8
-from loader.agent.recovery import RecoveryContext
98
 from loader.runtime.context import RuntimeContext
9
+from loader.runtime.recovery import RecoveryContext
1010
 from tests.helpers.runtime_harness import ScriptedBackend
1111
 
1212
 
tests/test_tool_batches.pymodified
@@ -9,7 +9,6 @@ from types import SimpleNamespace
99
 import pytest
1010
 
1111
 from loader.agent.reasoning import ActionVerification, ConfidenceAssessment, ConfidenceLevel
12
-from loader.agent.recovery import RecoveryContext
1312
 from loader.llm.base import Message, Role, ToolCall
1413
 from loader.runtime.context import RuntimeContext, RuntimeLegacyServices
1514
 from loader.runtime.dod import DefinitionOfDoneStore, create_definition_of_done
@@ -20,6 +19,7 @@ from loader.runtime.permissions import (
2019
     build_permission_policy,
2120
     load_permission_rules,
2221
 )
22
+from loader.runtime.recovery import RecoveryContext
2323
 from loader.runtime.tool_batches import ToolBatchRunner
2424
 from loader.runtime.tracing import RuntimeTracer
2525
 from loader.tools.base import ToolResult as RegistryToolResult