-
Notifications
You must be signed in to change notification settings - Fork 149
Expand file tree
/
Copy path_policy.py
More file actions
72 lines (60 loc) · 2.7 KB
/
Copy path_policy.py
File metadata and controls
72 lines (60 loc) · 2.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
"""Command policy shared by the shell tools: retryable failures and interactive-command detection."""
from __future__ import annotations
import errno
import functools
import re
from collections.abc import Awaitable, Callable
from typing import Concatenate, ParamSpec, TypeVar
from pydantic_ai.exceptions import ModelRetry
_P = ParamSpec('_P')
_ToolsetT = TypeVar('_ToolsetT')
# Spawning a command fails with a bare `OSError` for causes that have no
# dedicated subclass, and with `FileNotFoundError`/`NotADirectoryError` for
# causes that do. The errno says whose fault it is: these are the model's, and
# it can act on them. Every other errno (EMFILE, ENOMEM) is the host's, and must
# keep aborting the run rather than sending the model into a retry loop it
# can't win.
#
# ENOENT and ENOTDIR reach here only from the working directory, since the
# command string is handed to a shell that always exists -- a command whose own
# executable is missing is reported by that shell on stderr, not by the spawn.
#
# Keyed by `OSError.errno`, which the stdlib types as `int | None`.
_RECOVERABLE_ERRNOS: dict[int | None, str] = {
errno.ENOENT: 'The working directory no longer exists.',
errno.ENOTDIR: 'The working directory is no longer a directory.',
}
def recoverable(
fn: Callable[Concatenate[_ToolsetT, _P], Awaitable[str]],
) -> Callable[Concatenate[_ToolsetT, _P], Awaitable[str]]:
"""Convert model-correctable errors into `ModelRetry`.
pyai only feeds `ModelRetry` back to the model as a retry prompt; any other
exception propagates and aborts the whole run. A denied command, a command
the OS refuses to spawn, and a working directory the model's own earlier
command destroyed are all things the model can recover from, so surface them
as a retry instead of crashing the agent.
"""
@functools.wraps(fn)
async def wrapper(self: _ToolsetT, *args: _P.args, **kwargs: _P.kwargs) -> str:
try:
return await fn(self, *args, **kwargs)
except PermissionError as e:
raise ModelRetry(str(e)) from e
except OSError as e:
reason = _RECOVERABLE_ERRNOS.get(e.errno)
if reason is None:
raise
# `str(e)` embeds the absolute host path; the reason alone doesn't.
raise ModelRetry(reason) from e
return wrapper
def is_interactive_command(command: str) -> bool:
"""Detect commands that typically require interactive input."""
interactive_patterns = [
r'^(vi|vim|nano|emacs|less|more|top|htop|man)\b',
r'^sudo\s',
r'^passwd\b',
r'^ssh\b',
r'^telnet\b',
r'^ftp\b',
]
return any(re.match(p, command.strip()) for p in interactive_patterns)