tinyagent-py 0.0.8__py3-none-any.whl → 0.0.9__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.
@@ -0,0 +1,478 @@
1
+ # TinyAgent Modal sandbox utilities
2
+ # ---------------------------------
3
+
4
+ from __future__ import annotations
5
+
6
+ import inspect
7
+ import json
8
+ import os
9
+ import sys
10
+ from textwrap import dedent
11
+ from typing import Any, Iterable, Sequence, Tuple
12
+
13
+ import modal
14
+ from modal.stream_type import StreamType
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Configuration
18
+ # ---------------------------------------------------------------------------
19
+
20
+ # By default we use the interpreter version running this code.
21
+ DEFAULT_PYTHON_VERSION: str = f"{sys.version_info.major}.{sys.version_info.minor}"
22
+ PYTHON_VERSION: str = os.getenv("TINYAGENT_PYTHON_VERSION", DEFAULT_PYTHON_VERSION)
23
+
24
+ # Simple ANSI colour helper for interactive feedback
25
+ COLOR = {
26
+ "HEADER": "\033[95m",
27
+ "BLUE": "\033[94m",
28
+ "GREEN": "\033[92m",
29
+ "RED": "\033[91m",
30
+ "ENDC": "\033[0m",
31
+ }
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Small utilities
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def get_source(obj: Any) -> str:
38
+ """Return a *dedented* string with the source code of *obj*.
39
+
40
+ Raises ValueError if the source cannot be inspected. A dedicated utility
41
+ avoids a hard dependency on the public `inspect.getsource` semantics
42
+ scattered throughout the rest of the library.
43
+ """
44
+ try:
45
+ return dedent(inspect.getsource(obj))
46
+ except (OSError, TypeError) as exc: # pragma: no cover – environment specific
47
+ raise ValueError(f"Unable to retrieve source for {obj!r}") from exc
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Sandbox creation helpers
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def create_sandbox(
55
+ modal_secrets: modal.Secret, # Same semantics as the demo util
56
+ *,
57
+ timeout: int = 5 * 60,
58
+ volumes: dict | None = None,
59
+ workdir: str | None = None,
60
+ python_version: str | None = None,
61
+ apt_packages: Sequence[str] | None = None,
62
+ default_packages: Sequence[str] | None = None,
63
+ pip_install: Sequence[str] | None = None,
64
+ image_name: str = "tinyagent-sandbox-image",
65
+ app_name: str = "persistent-code-session",
66
+ **sandbox_kwargs,
67
+ ) -> Tuple[modal.Sandbox, modal.App]:
68
+ """Create (or lookup) a `modal.Sandbox` pre-configured for code execution.
69
+
70
+ The parameters largely bubble up to the underlying *modal* API while
71
+ providing TinyAgent-friendly defaults. Developers can override any aspect
72
+ of the environment without patching library internals.
73
+ """
74
+
75
+ # Resolve defaults ------------------------------------------------------
76
+ python_version = python_version or PYTHON_VERSION
77
+
78
+ if apt_packages is None:
79
+ # Always install the basics required for most workflows
80
+ apt_packages = ("git", "curl", "nodejs", "npm")
81
+
82
+ if default_packages is None:
83
+ default_packages = (
84
+ "asyncpg>=0.27.0",
85
+ "aiosqlite>=0.18.0",
86
+ "gradio>=3.50.0",
87
+ "jinja2",
88
+ "pyyaml",
89
+ "cloudpickle",
90
+ "modal",
91
+ "nest-asyncio",
92
+ "mcp[cli]",
93
+ "PyGithub",
94
+ "fastmcp",
95
+ "gitingest",
96
+ )
97
+
98
+ full_pip_list = set(default_packages).union(pip_install or [])
99
+
100
+ # Build image -----------------------------------------------------------
101
+ agent_image = (
102
+ modal.Image.debian_slim(python_version=python_version)
103
+ .apt_install(*apt_packages)
104
+ .pip_install(*full_pip_list)
105
+ )
106
+
107
+ # Re-use a named Modal app so subsequent calls share cached state / layers
108
+ app = modal.App.lookup(app_name, create_if_missing=True)
109
+
110
+ with modal.enable_output():
111
+ sandbox = modal.Sandbox.create(
112
+ image=agent_image,
113
+ timeout=timeout,
114
+ app=app,
115
+ volumes=volumes or {},
116
+ workdir=workdir,
117
+ secrets=[modal_secrets],
118
+ **sandbox_kwargs,
119
+ )
120
+
121
+ return sandbox, app
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Streaming execution helpers
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def _pretty(header: str, text: str) -> None: # Convenience printing wrapper
130
+ print(f"{COLOR[header]}{text}{COLOR['ENDC']}")
131
+
132
+
133
+ def run_streaming(
134
+ command: Sequence[str],
135
+ sb: modal.Sandbox,
136
+ *,
137
+ prefix: str = "📦",
138
+ ) -> Tuple[str, str]:
139
+ """Run *command* inside *sb* streaming stdout/stderr in real-time."""
140
+
141
+ _pretty("HEADER", f"{prefix}: Running in sandbox")
142
+ _pretty("GREEN", " ".join(map(str, command)))
143
+
144
+ exc = sb.exec(
145
+ *command,
146
+ stdout=StreamType.PIPE,
147
+ stderr=StreamType.PIPE,
148
+ )
149
+
150
+ stdout_lines: list[str] = []
151
+ stderr_lines: list[str] = []
152
+
153
+ # Forward STDOUT lines as they arrive
154
+ try:
155
+ for line in exc.stdout: # type: ignore[attr-defined]
156
+ print(f"{COLOR['BLUE']}OUT: {line.rstrip()}{COLOR['ENDC']}")
157
+ stdout_lines.append(line)
158
+ except Exception as e: # noqa: BLE001
159
+ _pretty("RED", f"Error during stdout streaming: {e}")
160
+
161
+ # Forward STDERR after stdout stream completes
162
+ try:
163
+ for line in exc.stderr: # type: ignore[attr-defined]
164
+ print(f"{COLOR['RED']}ERR: {line.rstrip()}{COLOR['ENDC']}")
165
+ stderr_lines.append(line)
166
+ except Exception as e: # noqa: BLE001
167
+ _pretty("RED", f"Error during stderr streaming: {e}")
168
+
169
+ exc.wait()
170
+ if exc.returncode != 0:
171
+ _pretty("HEADER", f"{prefix}: Failed with exitcode {exc.returncode}")
172
+ else:
173
+ _pretty("GREEN", f"{prefix}: Completed successfully")
174
+
175
+ return "".join(stdout_lines), "".join(stderr_lines)
176
+
177
+
178
+ # Specialisation for quick *python ‑c* jobs ------------------------------
179
+
180
+ def run_streaming_python(code: str, sb: modal.Sandbox) -> Tuple[str, str]:
181
+ """Convenience wrapper to execute *code* via `python -c` with printing flush."""
182
+
183
+ full_code = "from functools import partial\nprint = partial(print, flush=True)\n" + code
184
+ return run_streaming(["python", "-c", full_code], sb)
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Stateful session wrapper
189
+ # ---------------------------------------------------------------------------
190
+
191
+ class SandboxSession:
192
+ """Maintain a persistent sandbox instance across multiple executions."""
193
+
194
+ def __init__(
195
+ self,
196
+ modal_secrets: modal.Secret,
197
+ *,
198
+ timeout: int = 5 * 60,
199
+ **create_kwargs,
200
+ ) -> None:
201
+ self.modal_secrets = modal_secrets
202
+ self.timeout = timeout
203
+ self._create_kwargs = create_kwargs
204
+ self.sandbox: modal.Sandbox | None = None
205
+ self.app: modal.App | None = None
206
+ self._driver_proc: "modal.container_process.ContainerProcess" | None = None
207
+
208
+ # Public API -----------------------------------------------------------
209
+
210
+ def ensure_sandbox(self) -> modal.Sandbox:
211
+ if self.sandbox is None:
212
+ self.sandbox, self.app = create_sandbox(
213
+ self.modal_secrets,
214
+ timeout=self.timeout,
215
+ **self._create_kwargs,
216
+ )
217
+ _pretty("GREEN", "📦: Created new sandbox session")
218
+ return self.sandbox
219
+
220
+ def run_python(self, code: str) -> Tuple[str, str]:
221
+ """Shortcut for *python -c* streaming runs."""
222
+ return run_streaming_python(code, self.ensure_sandbox())
223
+
224
+ def run(self, command: Sequence[str]) -> Tuple[str, str]:
225
+ """Run arbitrary command with streaming output."""
226
+ return run_streaming(command, self.ensure_sandbox())
227
+
228
+ def terminate(self) -> None:
229
+ if self.sandbox is not None:
230
+ # Terminate driver first (if any)
231
+ self.terminate_driver()
232
+ self.sandbox.terminate()
233
+ self.sandbox = None
234
+ _pretty("HEADER", "📦: Terminated sandbox session")
235
+
236
+ # Context-manager interface -------------------------------------------
237
+
238
+ def __enter__(self) -> "SandboxSession":
239
+ self.ensure_sandbox()
240
+ return self
241
+
242
+ def __exit__(self, exc_type, exc_val, exc_tb): # noqa: D401
243
+ self.terminate()
244
+
245
+ # -------------------------------------------------------------------
246
+ # Driver / stateful Python execution helpers
247
+ # -------------------------------------------------------------------
248
+
249
+ def start_driver(self) -> "modal.container_process.ContainerProcess":
250
+ """Launch *driver_program* inside the sandbox (if not already running)."""
251
+
252
+ import modal.container_process # Local import to avoid hard dep at top
253
+
254
+ sandbox = self.ensure_sandbox()
255
+
256
+ if self._driver_proc is not None:
257
+ return self._driver_proc # Already running
258
+
259
+ driver_code = get_source(driver_program) + "\n\ndriver_program()"
260
+
261
+ self._driver_proc = sandbox.exec(
262
+ "python",
263
+ "-c",
264
+ driver_code,
265
+ stdout=StreamType.PIPE,
266
+ stderr=StreamType.PIPE,
267
+ )
268
+
269
+ _pretty("GREEN", "🚀 Driver program started in sandbox")
270
+ return self._driver_proc
271
+
272
+ def run_stateful(self, code: Any) -> str: # noqa: ANN401 – allow flexible input
273
+ """Execute *code* (or the source of *obj*) in the persistent driver with streaming.
274
+
275
+ If *code* is not a string, the helper attempts to obtain its
276
+ source using :pyfunc:`get_source` so that users can conveniently
277
+ pass in callables or other Python objects directly.
278
+ """
279
+
280
+ # Accept arbitrary objects for convenience ------------------------
281
+ if not isinstance(code, str):
282
+ try:
283
+ code = get_source(code)
284
+ except ValueError as exc: # Fallback to string representation
285
+ _pretty(
286
+ "RED",
287
+ f"⚠️ Unable to retrieve source for object {code!r}: {exc}. Falling back to str(obj).",
288
+ )
289
+ code = str(code)
290
+
291
+ proc = self.start_driver()
292
+ return run_code_streaming(proc, code)
293
+
294
+ def terminate_driver(self) -> None:
295
+ """Terminate the stateful driver process if running."""
296
+
297
+ if self._driver_proc is not None:
298
+ try:
299
+ self._driver_proc.terminate()
300
+ except Exception: # noqa: BLE001
301
+ pass
302
+ self._driver_proc = None
303
+ _pretty("HEADER", "📦: Driver process terminated")
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Stateful in-sandbox code execution helper
308
+ # ---------------------------------------------------------------------------
309
+
310
+ # Below we *copy verbatim* the proven driver + streaming logic from the demo
311
+ # script so as not to break existing behaviour. Only minimal stylistic tweaks
312
+ # (type annotations, comments) are applied.
313
+
314
+
315
+ def driver_program():
316
+ import json
317
+ import sys
318
+ import time
319
+ import builtins
320
+
321
+ globals: dict[str, Any] = {}
322
+
323
+ # Store original stdout and stderr
324
+ original_stdout = sys.stdout
325
+ original_stderr = sys.stderr
326
+ original_print = builtins.print
327
+
328
+ def streaming_print(*args, sep=' ', end='\n', file=None, flush=False):
329
+ """Custom print function that streams output immediately"""
330
+ if file is None or file == sys.stdout:
331
+ # Convert args to string like normal print
332
+ output = sep.join(str(arg) for arg in args) + end
333
+ # Send immediately as JSON
334
+ original_stdout.write(json.dumps({"type": "stdout", "content": output}) + '\n')
335
+ original_stdout.flush()
336
+ elif file == sys.stderr:
337
+ # Convert args to string like normal print
338
+ output = sep.join(str(arg) for arg in args) + end
339
+ # Send immediately as JSON
340
+ original_stdout.write(json.dumps({"type": "stderr", "content": output}) + '\n')
341
+ original_stdout.flush()
342
+ else:
343
+ # Use original print for other files
344
+ original_print(*args, sep=sep, end=end, file=file, flush=flush)
345
+
346
+ class StreamingStdout:
347
+ def write(self, text):
348
+ if text and not text.isspace():
349
+ original_stdout.write(json.dumps({"type": "stdout", "content": text}) + '\n')
350
+ original_stdout.flush()
351
+ return len(text)
352
+
353
+ def flush(self):
354
+ original_stdout.flush()
355
+
356
+ def fileno(self):
357
+ return original_stdout.fileno()
358
+
359
+ def isatty(self):
360
+ return False
361
+
362
+ def readable(self):
363
+ return False
364
+
365
+ def writable(self):
366
+ return True
367
+
368
+ def seekable(self):
369
+ return False
370
+
371
+ class StreamingStderr:
372
+ def write(self, text):
373
+ if text and not text.isspace():
374
+ original_stdout.write(json.dumps({"type": "stderr", "content": text}) + '\n')
375
+ original_stdout.flush()
376
+ return len(text)
377
+
378
+ def flush(self):
379
+ original_stdout.flush()
380
+
381
+ def fileno(self):
382
+ return original_stderr.fileno()
383
+
384
+ def isatty(self):
385
+ return False
386
+
387
+ def readable(self):
388
+ return False
389
+
390
+ def writable(self):
391
+ return True
392
+
393
+ def seekable(self):
394
+ return False
395
+
396
+ while True:
397
+ try:
398
+ command = json.loads(input())
399
+ if (code := command.get("code")) is None:
400
+ original_stdout.write(json.dumps({"error": "No code to execute"}) + '\n')
401
+ original_stdout.flush()
402
+ continue
403
+
404
+ # Send start marker
405
+ original_stdout.write(json.dumps({"type": "start"}) + '\n')
406
+ original_stdout.flush()
407
+
408
+ # Replace print and stdout/stderr for streaming
409
+ builtins.print = streaming_print
410
+ sys.stdout = StreamingStdout()
411
+ sys.stderr = StreamingStderr()
412
+
413
+ try:
414
+ exec(code, globals)
415
+ except Exception as e:
416
+ # Send error through stderr
417
+ sys.stderr.write(f"Execution Error: {e}\n")
418
+ finally:
419
+ # Restore original stdout/stderr/print
420
+ sys.stdout = original_stdout
421
+ sys.stderr = original_stderr
422
+ builtins.print = original_print
423
+
424
+ # Send completion marker
425
+ original_stdout.write(json.dumps({"type": "complete"}) + '\n')
426
+ original_stdout.flush()
427
+
428
+ except EOFError:
429
+ break
430
+ except Exception as e:
431
+ original_stdout.write(json.dumps({"type": "error", "message": str(e)}) + '\n')
432
+ original_stdout.flush()
433
+
434
+
435
+ def run_code_streaming(
436
+ p: "modal.container_process.ContainerProcess",
437
+ code: str,
438
+ ) -> str:
439
+ """Send *code* to an already-running `driver_program` process and stream output."""
440
+
441
+ p.stdin.write(json.dumps({"code": code}))
442
+ p.stdin.write("\n")
443
+ p.stdin.drain()
444
+
445
+ buffer = ""
446
+
447
+ for chunk in p.stdout:
448
+ buffer += chunk
449
+ while buffer.strip():
450
+ try:
451
+ result, idx = json.JSONDecoder().raw_decode(buffer)
452
+ except json.JSONDecodeError:
453
+ break # Need more data
454
+
455
+ buffer = buffer[idx:].lstrip()
456
+ _type = result.get("type")
457
+ if _type == "start":
458
+ print("🔄 Executing code…")
459
+ elif _type == "stdout":
460
+ print(result["content"], end="")
461
+ elif _type == "stderr":
462
+ print(f"\033[91m{result['content']}\033[0m", end="")
463
+ elif _type == "complete":
464
+ print("✅ Execution complete")
465
+ return buffer
466
+ elif result.get("error"):
467
+ print(f"❌ Error: {result['error']}")
468
+ return buffer
469
+
470
+
471
+ # Convenience shortcut --------------------------------------------------------
472
+
473
+ def start_sandbox_session(modal_secrets: modal.Secret, **kwargs) -> SandboxSession:
474
+ """Helper returning an *active* `SandboxSession` instance."""
475
+
476
+ session = SandboxSession(modal_secrets, **kwargs)
477
+ session.ensure_sandbox()
478
+ return session
@@ -0,0 +1,4 @@
1
+ from .base import CodeExecutionProvider
2
+ from .modal_provider import ModalProvider
3
+
4
+ __all__ = ["CodeExecutionProvider", "ModalProvider"]
@@ -0,0 +1,152 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict, List, Any, Optional
3
+ from tinyagent.hooks.logging_manager import LoggingManager
4
+
5
+
6
+ class CodeExecutionProvider(ABC):
7
+ """
8
+ Abstract base class for code execution providers.
9
+
10
+ This class defines the interface that all code execution providers must implement.
11
+ It allows for easy extension to support different execution environments
12
+ (Modal, Docker, local execution, cloud functions, etc.) with minimal code changes.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ log_manager: LoggingManager,
18
+ default_python_codes: Optional[List[str]] = None,
19
+ code_tools: List[Dict[str, Any]] = None,
20
+ pip_packages: List[str] = None,
21
+ secrets: Dict[str, Any] = None,
22
+ lazy_init: bool = True,
23
+ **kwargs
24
+ ):
25
+ self.log_manager = log_manager
26
+ self.default_python_codes = default_python_codes or []
27
+ self.code_tools = code_tools or []
28
+ self.pip_packages = pip_packages or []
29
+ self.secrets = secrets or {}
30
+ self.lazy_init = lazy_init
31
+ self.kwargs = kwargs
32
+ self.executed_default_codes = False
33
+ self._globals_dict = kwargs.get("globals_dict", {})
34
+ self._locals_dict = kwargs.get("locals_dict", {})
35
+ self._user_variables = {}
36
+ self.code_tools_definitions = []
37
+
38
+ @abstractmethod
39
+ async def execute_python(
40
+ self,
41
+ code_lines: List[str],
42
+ timeout: int = 120
43
+ ) -> Dict[str, Any]:
44
+ """
45
+ Execute Python code and return the result.
46
+
47
+ Args:
48
+ code_lines: List of Python code lines to execute
49
+ timeout: Maximum execution time in seconds
50
+
51
+ Returns:
52
+ Dictionary containing execution results with keys:
53
+ - printed_output: stdout from the execution
54
+ - return_value: the return value if any
55
+ - stderr: stderr from the execution
56
+ - error_traceback: exception traceback if any error occurred
57
+ """
58
+ pass
59
+
60
+ @abstractmethod
61
+ async def cleanup(self):
62
+ """Clean up any resources used by the provider."""
63
+ pass
64
+
65
+ def add_tools(self, tools: List[Any]) -> None:
66
+ """
67
+ Add tools to the execution environment.
68
+
69
+ Args:
70
+ tools: List of tool objects to add
71
+ """
72
+ import cloudpickle
73
+
74
+ tools_str_list = ["import cloudpickle"]
75
+ tools_str_list.append("###########<tools>###########\n")
76
+ for tool in tools:
77
+ tools_str_list.append(
78
+ f"globals()['{tool._tool_metadata['name']}'] = cloudpickle.loads({cloudpickle.dumps(tool)})"
79
+ )
80
+ tools_str_list.append("\n\n")
81
+ tools_str_list.append("###########</tools>###########\n")
82
+ tools_str_list.append("\n\n")
83
+ self.code_tools_definitions.extend(tools_str_list)
84
+
85
+ def set_user_variables(self, variables: Dict[str, Any]) -> None:
86
+ """
87
+ Set user variables that will be available in the Python environment.
88
+
89
+ Args:
90
+ variables: Dictionary of variable name -> value pairs
91
+ """
92
+ import cloudpickle
93
+
94
+ self._user_variables = variables.copy()
95
+
96
+ # Add variables to the execution environment by serializing them
97
+ # This ensures they are available when code is executed
98
+ variables_str_list = ["import cloudpickle"]
99
+ variables_str_list.append("###########<user_variables>###########\n")
100
+
101
+ for var_name, var_value in variables.items():
102
+ # Serialize the variable and add it to globals
103
+ serialized_var = cloudpickle.dumps(var_value)
104
+ variables_str_list.append(
105
+ f"globals()['{var_name}'] = cloudpickle.loads({serialized_var})"
106
+ )
107
+
108
+ variables_str_list.append("\n###########</user_variables>###########\n")
109
+ variables_str_list.append("\n")
110
+
111
+ # Remove any existing user variables from default codes
112
+ self._remove_existing_user_variables()
113
+
114
+ # Add new variables to default codes at the beginning (after tools if any)
115
+ # This ensures variables are available from the start
116
+ if variables_str_list:
117
+ # Find where to insert (after tools section if it exists)
118
+ insert_index = 0
119
+ for i, code in enumerate(self.default_python_codes):
120
+ if "###########</tools>###########" in code:
121
+ insert_index = i + 1
122
+ break
123
+
124
+ # Insert the variables code
125
+ for j, var_code in enumerate(variables_str_list):
126
+ self.default_python_codes.insert(insert_index + j, var_code)
127
+
128
+ def _remove_existing_user_variables(self) -> None:
129
+ """Remove existing user variables from default python codes."""
130
+ # Find and remove the user variables section
131
+ start_index = None
132
+ end_index = None
133
+
134
+ for i, code in enumerate(self.default_python_codes):
135
+ if "###########<user_variables>###########" in code:
136
+ start_index = i - 1 if i > 0 and "import cloudpickle" in self.default_python_codes[i-1] else i
137
+ elif "###########</user_variables>###########" in code:
138
+ end_index = i + 2 # Include the newline after
139
+ break
140
+
141
+ if start_index is not None and end_index is not None:
142
+ # Remove the old variables section
143
+ del self.default_python_codes[start_index:end_index]
144
+
145
+ def get_user_variables(self) -> Dict[str, Any]:
146
+ """
147
+ Get a copy of current user variables.
148
+
149
+ Returns:
150
+ Dictionary of current user variables
151
+ """
152
+ return self._user_variables.copy()