framework-agents 0.1.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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- framework_agents-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Sandboxed code execution tools. Parity: src/sandbox/index.ts.
|
|
2
|
+
|
|
3
|
+
- run_javascript: exec in a restricted namespace (limited builtins — no
|
|
4
|
+
import/open/eval/process access), timeout enforced via asyncio.wait_for
|
|
5
|
+
cancel semantics, output = last expression value + printed output,
|
|
6
|
+
truncated.
|
|
7
|
+
- create_sandbox_backend: one rooted workspace with a shell `execute` tool
|
|
8
|
+
(cwd=root, scrubbed env — no secrets/proxies inherited, timeout, output
|
|
9
|
+
caps enforced INCREMENTALLY — the child is killed the moment accumulated
|
|
10
|
+
output crosses max_buffer, TS kills at 4MB — with a spill note).
|
|
11
|
+
|
|
12
|
+
Isolation, honestly scoped: this is a speed bump, NOT a (container) security
|
|
13
|
+
boundary. Never put secrets in the sandbox env, gate `execute` with
|
|
14
|
+
human-in-the-loop for sensitive work, treat outputs as untrusted.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import ast
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
import threading
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, Field
|
|
29
|
+
|
|
30
|
+
from .errors import AgentError
|
|
31
|
+
from .tools import Tool, ToolContext, define_tool
|
|
32
|
+
|
|
33
|
+
SCRUBBED_ENV_KEYS = ("PATH", "HOME", "LANG", "LC_ALL", "TZ", "TERM", "NO_COLOR")
|
|
34
|
+
|
|
35
|
+
# Safe subset only — no open/exec/eval/__import__/getattr/globals.
|
|
36
|
+
_SAFE_BUILTINS: dict[str, Any] = {
|
|
37
|
+
"abs": abs,
|
|
38
|
+
"all": all,
|
|
39
|
+
"any": any,
|
|
40
|
+
"bool": bool,
|
|
41
|
+
"bytes": bytes,
|
|
42
|
+
"chr": chr,
|
|
43
|
+
"dict": dict,
|
|
44
|
+
"divmod": divmod,
|
|
45
|
+
"enumerate": enumerate,
|
|
46
|
+
"filter": filter,
|
|
47
|
+
"float": float,
|
|
48
|
+
"frozenset": frozenset,
|
|
49
|
+
"hash": hash,
|
|
50
|
+
"hex": hex,
|
|
51
|
+
"int": int,
|
|
52
|
+
"isinstance": isinstance,
|
|
53
|
+
"len": len,
|
|
54
|
+
"list": list,
|
|
55
|
+
"map": map,
|
|
56
|
+
"max": max,
|
|
57
|
+
"min": min,
|
|
58
|
+
"oct": oct,
|
|
59
|
+
"ord": ord,
|
|
60
|
+
"pow": pow,
|
|
61
|
+
"range": range,
|
|
62
|
+
"repr": repr,
|
|
63
|
+
"reversed": reversed,
|
|
64
|
+
"round": round,
|
|
65
|
+
"set": set,
|
|
66
|
+
"sorted": sorted,
|
|
67
|
+
"str": str,
|
|
68
|
+
"sum": sum,
|
|
69
|
+
"tuple": tuple,
|
|
70
|
+
"zip": zip,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _JavascriptArgs(BaseModel):
|
|
75
|
+
code: str = Field(min_length=1, max_length=8000)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _ExecuteArgs(BaseModel):
|
|
79
|
+
command: str = Field(min_length=1, max_length=4000)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sleep(seconds: float) -> None:
|
|
83
|
+
"""Blocking sleep for sandboxed code (blocks only the sandbox thread)."""
|
|
84
|
+
threading.Event().wait(max(0.0, float(seconds)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _exec_in_sandbox(code: str) -> tuple[str, Any]:
|
|
88
|
+
"""Run code in the restricted namespace -> (printed output, result value)."""
|
|
89
|
+
buffer = io.StringIO()
|
|
90
|
+
|
|
91
|
+
def sandbox_print(*args: Any, sep: str = " ", end: str = "\n", file: Any = None, flush: bool = False) -> None:
|
|
92
|
+
print(*args, sep=sep, end=end, file=buffer, flush=flush)
|
|
93
|
+
|
|
94
|
+
namespace: dict[str, Any] = {
|
|
95
|
+
"__builtins__": _SAFE_BUILTINS,
|
|
96
|
+
"print": sandbox_print,
|
|
97
|
+
"sleep": _sleep,
|
|
98
|
+
"math": math,
|
|
99
|
+
"json": json,
|
|
100
|
+
}
|
|
101
|
+
tree = ast.parse(code, mode="exec")
|
|
102
|
+
if tree.body and isinstance(tree.body[-1], ast.Expr):
|
|
103
|
+
# Completion-value semantics (JS parity): the last expression
|
|
104
|
+
# statement becomes the result.
|
|
105
|
+
if len(tree.body) > 1:
|
|
106
|
+
exec(compile(ast.Module(body=tree.body[:-1], type_ignores=[]), "<sandbox>", "exec"), namespace) # noqa: S102
|
|
107
|
+
result = eval(compile(ast.Expression(tree.body[-1].value), "<sandbox>", "eval"), namespace) # noqa: S307
|
|
108
|
+
else:
|
|
109
|
+
exec(compile(tree, "<sandbox>", "exec"), namespace) # noqa: S102
|
|
110
|
+
result = None
|
|
111
|
+
return buffer.getvalue(), result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _run_javascript(code: str, timeout_ms: float, max_output_chars: int) -> str:
|
|
115
|
+
"""Exec in a daemon worker thread; asyncio.wait_for enforces the timeout.
|
|
116
|
+
|
|
117
|
+
Python cannot interrupt exec mid-statement, so a timeout ABANDONS the
|
|
118
|
+
worker (daemon — it cannot keep the process alive) and raises
|
|
119
|
+
SANDBOX_TIMEOUT, mirroring the TS vm timeout contract.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def unpacked() -> str:
|
|
123
|
+
printed, result = _exec_in_sandbox(code)
|
|
124
|
+
pieces: list[str] = []
|
|
125
|
+
if printed.rstrip("\n"):
|
|
126
|
+
pieces.append(printed.rstrip("\n"))
|
|
127
|
+
if result is not None:
|
|
128
|
+
rendered = result if isinstance(result, str) else json.dumps(result, default=str)
|
|
129
|
+
pieces.append(rendered)
|
|
130
|
+
out = "\n".join(pieces)
|
|
131
|
+
if len(out) > max_output_chars:
|
|
132
|
+
out = out[:max_output_chars] + "…[truncated]"
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
box: dict[str, Any] = {}
|
|
136
|
+
done = threading.Event()
|
|
137
|
+
|
|
138
|
+
def work() -> None:
|
|
139
|
+
try:
|
|
140
|
+
box["value"] = unpacked()
|
|
141
|
+
except BaseException as err: # noqa: BLE001 - surfaced on the caller side
|
|
142
|
+
box["error"] = err
|
|
143
|
+
finally:
|
|
144
|
+
done.set()
|
|
145
|
+
|
|
146
|
+
worker = threading.Thread(target=work, daemon=True, name="sandbox-javascript")
|
|
147
|
+
worker.start()
|
|
148
|
+
|
|
149
|
+
async def await_done() -> None:
|
|
150
|
+
while not done.is_set():
|
|
151
|
+
await asyncio.sleep(0.005)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
await asyncio.wait_for(await_done(), max(timeout_ms, 1) / 1000)
|
|
155
|
+
except (asyncio.TimeoutError, TimeoutError):
|
|
156
|
+
raise AgentError("SANDBOX_TIMEOUT", f"Sandbox exceeded {timeout_ms}ms", retryable=True)
|
|
157
|
+
if "error" in box:
|
|
158
|
+
raise AgentError("SANDBOX_ERROR", f"Sandbox failed: {box['error']}", retryable=False) from box["error"]
|
|
159
|
+
return box["value"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_javascript_tool(timeout_ms: float = 2000, max_output_chars: int = 4000) -> Tool:
|
|
163
|
+
"""`run_javascript` Tool: sandboxed exec, no network/fs, timeout, caps."""
|
|
164
|
+
|
|
165
|
+
async def execute(args: Any, ctx: Optional[ToolContext]) -> str:
|
|
166
|
+
code = args.code if isinstance(args, _JavascriptArgs) else args["code"]
|
|
167
|
+
return await _run_javascript(code, timeout_ms, max_output_chars)
|
|
168
|
+
|
|
169
|
+
return define_tool(
|
|
170
|
+
name="run_javascript",
|
|
171
|
+
description=(
|
|
172
|
+
"Execute JavaScript-like code in a sandbox (no network, no fs, timeout). "
|
|
173
|
+
"The last expression's value becomes the result."
|
|
174
|
+
),
|
|
175
|
+
schema=_JavascriptArgs,
|
|
176
|
+
execute=execute,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _scrub_env(extra: Optional[dict[str, str]] = None) -> dict[str, str]:
|
|
181
|
+
env: dict[str, str] = {}
|
|
182
|
+
for key in SCRUBBED_ENV_KEYS:
|
|
183
|
+
value = os.environ.get(key)
|
|
184
|
+
if value is not None:
|
|
185
|
+
env[key] = value
|
|
186
|
+
env.update(extra or {})
|
|
187
|
+
return env
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _OutputOverflow(Exception):
|
|
191
|
+
"""Internal: accumulated child output crossed ``max_buffer`` mid-run."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SandboxBackend:
|
|
195
|
+
"""Local-first sandbox workspace: shell `execute` rooted at `root`."""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
root: str,
|
|
200
|
+
timeout_ms: float = 30000,
|
|
201
|
+
max_buffer: int = 4 * 1024 * 1024,
|
|
202
|
+
max_output_chars: int = 12000,
|
|
203
|
+
env: Optional[dict[str, str]] = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
self.root = os.path.abspath(root)
|
|
206
|
+
self.timeout_ms = timeout_ms
|
|
207
|
+
self.max_buffer = max_buffer
|
|
208
|
+
self.max_output_chars = max_output_chars
|
|
209
|
+
self._env = _scrub_env(env)
|
|
210
|
+
self._spills = 0
|
|
211
|
+
|
|
212
|
+
def tools(self) -> list[Tool]:
|
|
213
|
+
return [self._execute_tool()]
|
|
214
|
+
|
|
215
|
+
def _spill(self, text: str) -> str:
|
|
216
|
+
self._spills += 1
|
|
217
|
+
name = f".spill-{self._spills}.txt"
|
|
218
|
+
with open(os.path.join(self.root, name), "w", encoding="utf-8") as handle:
|
|
219
|
+
handle.write(text)
|
|
220
|
+
return f"{text[: self.max_output_chars]}\n…[truncated {len(text) - self.max_output_chars} chars → read {name}]"
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _kill(proc: asyncio.subprocess.Process) -> None:
|
|
224
|
+
try:
|
|
225
|
+
proc.kill()
|
|
226
|
+
except ProcessLookupError: # pragma: no cover - already dead
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
async def _drain_and_wait(self, proc: asyncio.subprocess.Process) -> tuple[bytes, bytes]:
|
|
230
|
+
"""Read the child's output INCREMENTALLY (both pipes concurrently),
|
|
231
|
+
aborting at ``max_buffer`` MID-STREAM — unlike communicate(), which
|
|
232
|
+
buffers everything (a `yes` loop would fill RAM until the timeout).
|
|
233
|
+
The overflow abort cancels the sibling pump through the TaskGroup."""
|
|
234
|
+
total = 0
|
|
235
|
+
out: list[bytes] = []
|
|
236
|
+
err: list[bytes] = []
|
|
237
|
+
|
|
238
|
+
async def pump(bucket: list[bytes], stream: Optional[asyncio.StreamReader]) -> None:
|
|
239
|
+
nonlocal total
|
|
240
|
+
assert stream is not None # both pipes are PIPE in _run_command
|
|
241
|
+
while True:
|
|
242
|
+
chunk = await stream.read(64 * 1024)
|
|
243
|
+
if not chunk:
|
|
244
|
+
return
|
|
245
|
+
bucket.append(chunk)
|
|
246
|
+
total += len(chunk)
|
|
247
|
+
if total > self.max_buffer:
|
|
248
|
+
raise _OutputOverflow
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
async with asyncio.TaskGroup() as tg:
|
|
252
|
+
tg.create_task(pump(out, proc.stdout))
|
|
253
|
+
tg.create_task(pump(err, proc.stderr))
|
|
254
|
+
await proc.wait()
|
|
255
|
+
except ExceptionGroup as eg:
|
|
256
|
+
# Both pipes can cross the cap in one event-loop pass; unwrap an
|
|
257
|
+
# overflow-only group so _run_command sees the bare signal.
|
|
258
|
+
overflows = [e for e in eg.exceptions if isinstance(e, _OutputOverflow)]
|
|
259
|
+
if len(overflows) == len(eg.exceptions):
|
|
260
|
+
raise (overflows[0] if len(overflows) == 1 else _OutputOverflow()) from eg
|
|
261
|
+
raise
|
|
262
|
+
return b"".join(out), b"".join(err)
|
|
263
|
+
|
|
264
|
+
async def _run_command(self, command: str) -> str:
|
|
265
|
+
try:
|
|
266
|
+
proc = await asyncio.create_subprocess_shell(
|
|
267
|
+
command,
|
|
268
|
+
cwd=self.root,
|
|
269
|
+
env=self._env,
|
|
270
|
+
stdout=asyncio.subprocess.PIPE,
|
|
271
|
+
stderr=asyncio.subprocess.PIPE,
|
|
272
|
+
)
|
|
273
|
+
except OSError as err: # unusable cwd / shell
|
|
274
|
+
raise AgentError("SANDBOX_ERROR", f"Cannot run command: {err}", retryable=False) from err
|
|
275
|
+
try:
|
|
276
|
+
stdout, stderr = await asyncio.wait_for(self._drain_and_wait(proc), self.timeout_ms / 1000)
|
|
277
|
+
except _OutputOverflow:
|
|
278
|
+
# The cap fires DURING the run: kill the child immediately (TS
|
|
279
|
+
# kills at 4MB) instead of buffering until the timeout.
|
|
280
|
+
self._kill(proc)
|
|
281
|
+
await proc.wait()
|
|
282
|
+
raise AgentError(
|
|
283
|
+
"SANDBOX_OUTPUT_TOO_LARGE",
|
|
284
|
+
"Command output exceeded the buffer; write output to a file and read it in chunks",
|
|
285
|
+
retryable=False,
|
|
286
|
+
)
|
|
287
|
+
except (asyncio.TimeoutError, TimeoutError):
|
|
288
|
+
self._kill(proc)
|
|
289
|
+
await proc.wait()
|
|
290
|
+
raise AgentError("SANDBOX_TIMEOUT", f"Command exceeded {self.timeout_ms}ms", retryable=True)
|
|
291
|
+
code = proc.returncode or 0
|
|
292
|
+
text = stdout.decode("utf-8", errors="replace")
|
|
293
|
+
if stderr:
|
|
294
|
+
text += f"\n[stderr]\n{stderr.decode('utf-8', errors='replace')}"
|
|
295
|
+
if len(text) > self.max_output_chars:
|
|
296
|
+
text = self._spill(text)
|
|
297
|
+
if code != 0:
|
|
298
|
+
text += f"\n[Command failed with exit code {code}]"
|
|
299
|
+
return text or "[Command succeeded with exit code 0]"
|
|
300
|
+
|
|
301
|
+
def _execute_tool(self) -> Tool:
|
|
302
|
+
backend = self
|
|
303
|
+
|
|
304
|
+
async def execute(args: Any, ctx: Optional[ToolContext]) -> str:
|
|
305
|
+
command = args.command if isinstance(args, _ExecuteArgs) else args["command"]
|
|
306
|
+
return await backend._run_command(command)
|
|
307
|
+
|
|
308
|
+
return define_tool(
|
|
309
|
+
name="execute",
|
|
310
|
+
description=(
|
|
311
|
+
"Run a shell command in the sandbox workspace (cwd=root, scrubbed env, "
|
|
312
|
+
"timeout). Large output spills to a file."
|
|
313
|
+
),
|
|
314
|
+
schema=_ExecuteArgs,
|
|
315
|
+
execute=execute,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def create_sandbox_backend(
|
|
320
|
+
root: str,
|
|
321
|
+
timeout_ms: float = 30000,
|
|
322
|
+
max_buffer: int = 4 * 1024 * 1024,
|
|
323
|
+
max_output_chars: int = 12000,
|
|
324
|
+
env: Optional[dict[str, str]] = None,
|
|
325
|
+
) -> SandboxBackend:
|
|
326
|
+
"""Sandbox backend (execute tool) rooted at `root` (shell cwd jail)."""
|
|
327
|
+
return SandboxBackend(root=root, timeout_ms=timeout_ms, max_buffer=max_buffer, max_output_chars=max_output_chars, env=env)
|