agents-function-tools 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents_function_tools-0.2.0.dist-info/METADATA +90 -0
- agents_function_tools-0.2.0.dist-info/RECORD +14 -0
- agents_function_tools-0.2.0.dist-info/WHEEL +5 -0
- agents_function_tools-0.2.0.dist-info/licenses/LICENSE +202 -0
- agents_function_tools-0.2.0.dist-info/top_level.txt +1 -0
- function_tools/__init__.py +21 -0
- function_tools/archive.py +220 -0
- function_tools/command.py +172 -0
- function_tools/errors.py +11 -0
- function_tools/host.py +43 -0
- function_tools/http.py +136 -0
- function_tools/openai_tools.py +398 -0
- function_tools/responses.py +39 -0
- function_tools/workspace.py +433 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import FoundationToolError
|
|
11
|
+
from .workspace import Workspace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CommandPolicy:
|
|
16
|
+
"""Host-owned aliases for executables that an Agent may invoke."""
|
|
17
|
+
|
|
18
|
+
programs: Mapping[str, str] = field(default_factory=dict)
|
|
19
|
+
max_timeout_seconds: int = 300
|
|
20
|
+
max_output_chars: int = 65_536
|
|
21
|
+
max_arguments: int = 64
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.max_timeout_seconds <= 0:
|
|
25
|
+
raise FoundationToolError("INVALID_LIMIT", "Command timeout limit must be positive.")
|
|
26
|
+
if self.max_output_chars <= 0:
|
|
27
|
+
raise FoundationToolError("INVALID_LIMIT", "Output limit must be positive.")
|
|
28
|
+
if self.max_arguments <= 0:
|
|
29
|
+
raise FoundationToolError("INVALID_LIMIT", "Argument limit must be positive.")
|
|
30
|
+
|
|
31
|
+
def executable_for(self, alias: str) -> str:
|
|
32
|
+
normalized = alias.strip()
|
|
33
|
+
if not normalized or normalized not in self.programs:
|
|
34
|
+
allowed = ", ".join(sorted(self.programs)) or "none"
|
|
35
|
+
raise FoundationToolError(
|
|
36
|
+
"PROGRAM_NOT_ALLOWED", f"Program alias is not allowed. Allowed aliases: {allowed}."
|
|
37
|
+
)
|
|
38
|
+
executable = self.programs[normalized]
|
|
39
|
+
if not executable or "\x00" in executable:
|
|
40
|
+
raise FoundationToolError(
|
|
41
|
+
"INVALID_PROGRAM_MAPPING", f"Program mapping for {normalized} is invalid."
|
|
42
|
+
)
|
|
43
|
+
return executable
|
|
44
|
+
|
|
45
|
+
def describe(self) -> dict[str, Any]:
|
|
46
|
+
"""Return policy metadata without revealing host executable paths."""
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
"program_aliases": sorted(self.programs),
|
|
50
|
+
"max_timeout_seconds": self.max_timeout_seconds,
|
|
51
|
+
"max_output_chars": self.max_output_chars,
|
|
52
|
+
"max_arguments": self.max_arguments,
|
|
53
|
+
"shell": False,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LocalCommandRunner:
|
|
58
|
+
"""Run allowlisted commands without a shell inside a workspace directory."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, workspace: Workspace, policy: CommandPolicy) -> None:
|
|
61
|
+
self.workspace = workspace
|
|
62
|
+
self.policy = policy
|
|
63
|
+
|
|
64
|
+
def run(
|
|
65
|
+
self,
|
|
66
|
+
program: str,
|
|
67
|
+
args: Sequence[str] = (),
|
|
68
|
+
*,
|
|
69
|
+
cwd: str = ".",
|
|
70
|
+
timeout_seconds: int = 30,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
if not 1 <= timeout_seconds <= self.policy.max_timeout_seconds:
|
|
73
|
+
raise FoundationToolError(
|
|
74
|
+
"INVALID_TIMEOUT",
|
|
75
|
+
f"timeout_seconds must be between 1 and {self.policy.max_timeout_seconds}.",
|
|
76
|
+
)
|
|
77
|
+
if len(args) > self.policy.max_arguments:
|
|
78
|
+
raise FoundationToolError(
|
|
79
|
+
"TOO_MANY_ARGUMENTS",
|
|
80
|
+
f"Command cannot exceed {self.policy.max_arguments} arguments.",
|
|
81
|
+
)
|
|
82
|
+
normalized_args = [self._validate_argument(argument) for argument in args]
|
|
83
|
+
executable = self.policy.executable_for(program)
|
|
84
|
+
working_directory = self.workspace.resolve_directory(cwd)
|
|
85
|
+
|
|
86
|
+
command = [executable, *normalized_args]
|
|
87
|
+
popen_options: dict[str, Any] = {
|
|
88
|
+
"cwd": working_directory,
|
|
89
|
+
"env": self._restricted_environment(),
|
|
90
|
+
"stdin": subprocess.DEVNULL,
|
|
91
|
+
"stdout": subprocess.PIPE,
|
|
92
|
+
"stderr": subprocess.PIPE,
|
|
93
|
+
"text": True,
|
|
94
|
+
"encoding": "utf-8",
|
|
95
|
+
"errors": "replace",
|
|
96
|
+
"shell": False,
|
|
97
|
+
}
|
|
98
|
+
if os.name == "nt":
|
|
99
|
+
popen_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
100
|
+
else:
|
|
101
|
+
popen_options["start_new_session"] = True
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
process = subprocess.Popen(command, **popen_options)
|
|
105
|
+
except FileNotFoundError as error:
|
|
106
|
+
raise FoundationToolError(
|
|
107
|
+
"PROGRAM_NOT_FOUND", f"Configured executable for {program} was not found."
|
|
108
|
+
) from error
|
|
109
|
+
except OSError as error:
|
|
110
|
+
raise FoundationToolError("COMMAND_START_FAILED", str(error)) from error
|
|
111
|
+
|
|
112
|
+
timed_out = False
|
|
113
|
+
try:
|
|
114
|
+
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
|
115
|
+
except subprocess.TimeoutExpired:
|
|
116
|
+
timed_out = True
|
|
117
|
+
self._terminate_process_tree(process)
|
|
118
|
+
stdout, stderr = process.communicate()
|
|
119
|
+
|
|
120
|
+
stdout, stdout_truncated = self._truncate(stdout)
|
|
121
|
+
stderr, stderr_truncated = self._truncate(stderr)
|
|
122
|
+
return {
|
|
123
|
+
"program": program,
|
|
124
|
+
"args": normalized_args,
|
|
125
|
+
"cwd": self.workspace.relative_path(working_directory),
|
|
126
|
+
"return_code": 124 if timed_out else process.returncode,
|
|
127
|
+
"stdout": stdout,
|
|
128
|
+
"stderr": stderr,
|
|
129
|
+
"timed_out": timed_out,
|
|
130
|
+
"output_truncated": stdout_truncated or stderr_truncated,
|
|
131
|
+
"executor": "local_restricted",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def _validate_argument(self, argument: str) -> str:
|
|
135
|
+
if not isinstance(argument, str):
|
|
136
|
+
raise FoundationToolError("INVALID_ARGUMENT", "All command arguments must be strings.")
|
|
137
|
+
if "\x00" in argument:
|
|
138
|
+
raise FoundationToolError("INVALID_ARGUMENT", "Command arguments cannot contain NUL.")
|
|
139
|
+
if len(argument) > 4_096:
|
|
140
|
+
raise FoundationToolError(
|
|
141
|
+
"ARGUMENT_TOO_LONG", "A command argument cannot exceed 4096 characters."
|
|
142
|
+
)
|
|
143
|
+
return argument
|
|
144
|
+
|
|
145
|
+
def _restricted_environment(self) -> dict[str, str]:
|
|
146
|
+
names = ("PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "LANG", "LC_ALL")
|
|
147
|
+
return {name: os.environ[name] for name in names if name in os.environ}
|
|
148
|
+
|
|
149
|
+
def _terminate_process_tree(self, process: subprocess.Popen[str]) -> None:
|
|
150
|
+
if process.poll() is not None:
|
|
151
|
+
return
|
|
152
|
+
try:
|
|
153
|
+
if os.name == "nt":
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
156
|
+
stdin=subprocess.DEVNULL,
|
|
157
|
+
stdout=subprocess.DEVNULL,
|
|
158
|
+
stderr=subprocess.DEVNULL,
|
|
159
|
+
check=False,
|
|
160
|
+
shell=False,
|
|
161
|
+
)
|
|
162
|
+
if result.returncode != 0 or process.poll() is None:
|
|
163
|
+
process.kill()
|
|
164
|
+
else:
|
|
165
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
166
|
+
except OSError:
|
|
167
|
+
process.kill()
|
|
168
|
+
|
|
169
|
+
def _truncate(self, value: str) -> tuple[str, bool]:
|
|
170
|
+
if len(value) <= self.policy.max_output_chars:
|
|
171
|
+
return value, False
|
|
172
|
+
return value[: self.policy.max_output_chars], True
|
function_tools/errors.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FoundationToolError(Exception):
|
|
5
|
+
"""A safe, structured error that can be returned to an Agent."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, code: str, message: str, *, retryable: bool = False) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.code = code
|
|
10
|
+
self.message = message
|
|
11
|
+
self.retryable = retryable
|
function_tools/host.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import FoundationToolError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HostInspector:
|
|
14
|
+
"""Non-sensitive host metadata exposed by explicit allowlist only."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, environment_variables: Iterable[str] = ()) -> None:
|
|
17
|
+
self.environment_variables = frozenset(environment_variables)
|
|
18
|
+
|
|
19
|
+
def system_info(self) -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"os_name": os.name,
|
|
22
|
+
"platform": platform.system(),
|
|
23
|
+
"platform_release": platform.release(),
|
|
24
|
+
"machine": platform.machine(),
|
|
25
|
+
"python_version": platform.python_version(),
|
|
26
|
+
"cpu_count": os.cpu_count(),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def current_time(self) -> dict[str, Any]:
|
|
30
|
+
now = datetime.now(timezone.utc)
|
|
31
|
+
return {"utc": now.isoformat(), "epoch_seconds": now.timestamp()}
|
|
32
|
+
|
|
33
|
+
def environment(self, name: str) -> dict[str, Any]:
|
|
34
|
+
if name not in self.environment_variables:
|
|
35
|
+
allowed = ", ".join(sorted(self.environment_variables)) or "none"
|
|
36
|
+
raise FoundationToolError(
|
|
37
|
+
"ENVIRONMENT_VARIABLE_NOT_ALLOWED",
|
|
38
|
+
f"Environment variable is not allowed. Allowed names: {allowed}.",
|
|
39
|
+
)
|
|
40
|
+
return {"name": name, "value": os.environ.get(name), "is_set": name in os.environ}
|
|
41
|
+
|
|
42
|
+
def runtime_info(self) -> dict[str, Any]:
|
|
43
|
+
return {"python_executable": sys.executable, "python_prefix": sys.prefix}
|
function_tools/http.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
|
|
11
|
+
|
|
12
|
+
from .errors import FoundationToolError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class HttpPolicy:
|
|
17
|
+
"""Explicit egress policy for small, text-only HTTP reads."""
|
|
18
|
+
|
|
19
|
+
allowed_hosts: frozenset[str] = frozenset()
|
|
20
|
+
max_response_bytes: int = 1_000_000
|
|
21
|
+
timeout_seconds: int = 15
|
|
22
|
+
allow_private_addresses: bool = False
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if self.max_response_bytes <= 0 or self.timeout_seconds <= 0:
|
|
26
|
+
raise FoundationToolError("INVALID_LIMIT", "HTTP limits must be positive.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _RejectRedirects(HTTPRedirectHandler):
|
|
30
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
31
|
+
raise HTTPError(req.full_url, code, "Redirects are not allowed.", headers, fp)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpTextClient:
|
|
35
|
+
"""Fetch a bounded UTF-8 response from an exact host allowlist."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, policy: HttpPolicy) -> None:
|
|
38
|
+
self.policy = policy
|
|
39
|
+
|
|
40
|
+
def fetch(self, url: str) -> dict[str, Any]:
|
|
41
|
+
parsed = self._validate_url(url)
|
|
42
|
+
self._validate_addresses(parsed.hostname or "")
|
|
43
|
+
request = Request(
|
|
44
|
+
url,
|
|
45
|
+
headers={
|
|
46
|
+
"User-Agent": "company-agent-service/0.1",
|
|
47
|
+
"Accept": "text/plain,text/html,application/json",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
opener = build_opener(
|
|
51
|
+
_RejectRedirects(), HTTPSHandler(context=ssl.create_default_context())
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
with opener.open(request, timeout=self.policy.timeout_seconds) as response:
|
|
55
|
+
content_type = response.headers.get_content_type()
|
|
56
|
+
if content_type not in {
|
|
57
|
+
"text/plain",
|
|
58
|
+
"text/html",
|
|
59
|
+
"application/json",
|
|
60
|
+
"application/xml",
|
|
61
|
+
"text/xml",
|
|
62
|
+
}:
|
|
63
|
+
raise FoundationToolError(
|
|
64
|
+
"UNSUPPORTED_CONTENT_TYPE", f"Content type is not allowed: {content_type}."
|
|
65
|
+
)
|
|
66
|
+
raw = response.read(self.policy.max_response_bytes + 1)
|
|
67
|
+
if len(raw) > self.policy.max_response_bytes:
|
|
68
|
+
raise FoundationToolError(
|
|
69
|
+
"RESPONSE_TOO_LARGE", "Response exceeds the configured byte limit."
|
|
70
|
+
)
|
|
71
|
+
charset = response.headers.get_content_charset() or "utf-8"
|
|
72
|
+
except FoundationToolError:
|
|
73
|
+
raise
|
|
74
|
+
except HTTPError as error:
|
|
75
|
+
raise FoundationToolError(
|
|
76
|
+
"HTTP_ERROR",
|
|
77
|
+
f"HTTP request failed with status {error.code}.",
|
|
78
|
+
retryable=error.code >= 500,
|
|
79
|
+
) from error
|
|
80
|
+
except (URLError, OSError) as error:
|
|
81
|
+
raise FoundationToolError(
|
|
82
|
+
"HTTP_FETCH_FAILED", "HTTP request could not be completed.", retryable=True
|
|
83
|
+
) from error
|
|
84
|
+
try:
|
|
85
|
+
content = raw.decode(charset)
|
|
86
|
+
except (LookupError, UnicodeDecodeError) as error:
|
|
87
|
+
raise FoundationToolError(
|
|
88
|
+
"RESPONSE_NOT_TEXT", "Response cannot be decoded as declared text."
|
|
89
|
+
) from error
|
|
90
|
+
return {"url": url, "content_type": content_type, "content": content, "bytes": len(raw)}
|
|
91
|
+
|
|
92
|
+
def _validate_url(self, url: str):
|
|
93
|
+
parsed = urlparse(url)
|
|
94
|
+
host = (parsed.hostname or "").lower()
|
|
95
|
+
try:
|
|
96
|
+
port = parsed.port
|
|
97
|
+
except ValueError as error:
|
|
98
|
+
raise FoundationToolError("INVALID_URL", "URL port is invalid.") from error
|
|
99
|
+
if (
|
|
100
|
+
parsed.scheme != "https"
|
|
101
|
+
or not host
|
|
102
|
+
or port not in {None, 443}
|
|
103
|
+
or parsed.username
|
|
104
|
+
or parsed.password
|
|
105
|
+
):
|
|
106
|
+
raise FoundationToolError(
|
|
107
|
+
"INVALID_URL", "Only HTTPS default-port URLs without user credentials are allowed."
|
|
108
|
+
)
|
|
109
|
+
if host not in self.policy.allowed_hosts:
|
|
110
|
+
allowed = ", ".join(sorted(self.policy.allowed_hosts)) or "none"
|
|
111
|
+
raise FoundationToolError(
|
|
112
|
+
"HOST_NOT_ALLOWED", f"Host is not allowed. Allowed hosts: {allowed}."
|
|
113
|
+
)
|
|
114
|
+
return parsed
|
|
115
|
+
|
|
116
|
+
def _validate_addresses(self, hostname: str) -> None:
|
|
117
|
+
try:
|
|
118
|
+
addresses = {
|
|
119
|
+
item[4][0] for item in socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
|
120
|
+
}
|
|
121
|
+
except socket.gaierror as error:
|
|
122
|
+
raise FoundationToolError(
|
|
123
|
+
"DNS_LOOKUP_FAILED", "Allowed host could not be resolved.", retryable=True
|
|
124
|
+
) from error
|
|
125
|
+
if not addresses:
|
|
126
|
+
raise FoundationToolError(
|
|
127
|
+
"DNS_LOOKUP_FAILED", "Allowed host has no resolved addresses."
|
|
128
|
+
)
|
|
129
|
+
if self.policy.allow_private_addresses:
|
|
130
|
+
return
|
|
131
|
+
for address in addresses:
|
|
132
|
+
ip = ipaddress.ip_address(address)
|
|
133
|
+
if not ip.is_global:
|
|
134
|
+
raise FoundationToolError(
|
|
135
|
+
"PRIVATE_ADDRESS_BLOCKED", "Resolved address is not public."
|
|
136
|
+
)
|