tinyagent-py 0.0.11__py3-none-any.whl → 0.0.12__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.
@@ -63,6 +63,7 @@ def create_sandbox(
63
63
  pip_install: Sequence[str] | None = None,
64
64
  image_name: str = "tinyagent-sandbox-image",
65
65
  app_name: str = "persistent-code-session",
66
+ force_build: bool = False,
66
67
  **sandbox_kwargs,
67
68
  ) -> Tuple[modal.Sandbox, modal.App]:
68
69
  """Create (or lookup) a `modal.Sandbox` pre-configured for code execution.
@@ -99,7 +100,7 @@ def create_sandbox(
99
100
 
100
101
  # Build image -----------------------------------------------------------
101
102
  agent_image = (
102
- modal.Image.debian_slim(python_version=python_version)
103
+ modal.Image.debian_slim(python_version=python_version,force_build=force_build)
103
104
  .apt_install(*apt_packages)
104
105
  .pip_install(*full_pip_list)
105
106
  )
@@ -196,6 +197,7 @@ class SandboxSession:
196
197
  modal_secrets: modal.Secret,
197
198
  *,
198
199
  timeout: int = 5 * 60,
200
+
199
201
  **create_kwargs,
200
202
  ) -> None:
201
203
  self.modal_secrets = modal_secrets
@@ -26,6 +26,7 @@ class ModalProvider(CodeExecutionProvider):
26
26
  default_packages: Optional[List[str]] = None,
27
27
  apt_packages: Optional[List[str]] = None,
28
28
  python_version: Optional[str] = None,
29
+ authorized_imports: list[str] | None = None,
29
30
  modal_secrets: Dict[str, Union[str, None]] | None = None,
30
31
  lazy_init: bool = True,
31
32
  sandbox_name: str = "tinycodeagent-sandbox",
@@ -48,6 +49,7 @@ class ModalProvider(CodeExecutionProvider):
48
49
  (git, curl, …) so you only need to specify the extras.
49
50
  python_version: Python version used for the sandbox image. If
50
51
  ``None`` the current interpreter version is used.
52
+ authorized_imports: Optional allow-list of modules the user code is permitted to import. Supports wildcard patterns (e.g. "pandas.*"). If ``None`` the safety layer blocks only the predefined dangerous modules.
51
53
  """
52
54
 
53
55
  # Resolve default values ------------------------------------------------
@@ -70,6 +72,7 @@ class ModalProvider(CodeExecutionProvider):
70
72
  self.default_packages: List[str] = default_packages
71
73
  self.apt_packages: List[str] = apt_packages
72
74
  self.python_version: str = python_version
75
+ self.authorized_imports = authorized_imports
73
76
 
74
77
  # ----------------------------------------------------------------------
75
78
  final_packages = list(set(self.default_packages + (pip_packages or [])))
@@ -89,6 +92,7 @@ class ModalProvider(CodeExecutionProvider):
89
92
  self.modal_secrets = modal.Secret.from_dict(self.secrets)
90
93
  self.app = None
91
94
  self._app_run_python = None
95
+ self.is_trusted_code = kwargs.get("trust_code", False)
92
96
 
93
97
  self._setup_modal_app()
94
98
 
@@ -164,18 +168,30 @@ class ModalProvider(CodeExecutionProvider):
164
168
  if self.executed_default_codes:
165
169
  print("✔️ default codes already executed")
166
170
  full_code = "\n".join(self.code_tools_definitions) +"\n\n"+code
171
+ # Code tools and default code are trusted, user code is not
167
172
  else:
168
173
  full_code = "\n".join(self.code_tools_definitions) +"\n\n"+ "\n".join(self.default_python_codes) + "\n\n" + code
169
174
  self.executed_default_codes = True
175
+ # First execution includes framework code which is trusted
170
176
 
171
177
  # Use Modal's native execution methods
172
178
  if self.local_execution:
173
- # Use Modal's .local() method for local execution
174
- return self._app_run_python.local(full_code, globals_dict or {}, locals_dict or {})
179
+ return self._app_run_python.local(
180
+ full_code,
181
+ globals_dict or {},
182
+ locals_dict or {},
183
+ self.authorized_imports,
184
+ self.is_trusted_code,
185
+ )
175
186
  else:
176
- # Use Modal's .remote() method for remote execution
177
187
  with self.app.run():
178
- return self._app_run_python.remote(full_code, globals_dict or {}, locals_dict or {})
188
+ return self._app_run_python.remote(
189
+ full_code,
190
+ globals_dict or {},
191
+ locals_dict or {},
192
+ self.authorized_imports,
193
+ self.is_trusted_code,
194
+ )
179
195
 
180
196
  def _log_response(self, response: Dict[str, Any]):
181
197
  """Log the response from code execution."""
@@ -184,15 +200,31 @@ class ModalProvider(CodeExecutionProvider):
184
200
  print("#########################<printed_output>#########################")
185
201
  print(response["printed_output"])
186
202
  print("#########################</printed_output>#########################")
187
- print("#########################<return_value>#########################")
188
- print(response["return_value"])
189
- print("#########################</return_value>#########################")
190
- print("#########################<stderr>#########################")
191
- print(response["stderr"])
192
- print("#########################</stderr>#########################")
193
- print("#########################<traceback>#########################")
194
- print(response["error_traceback"])
195
- print("#########################</traceback>#########################")
203
+ if response.get("return_value",None) not in [None,""]:
204
+ print("#########################<return_value>#########################")
205
+ print(response["return_value"])
206
+ print("#########################</return_value>#########################")
207
+ if response.get("stderr",None) not in [None,""]:
208
+ print("#########################<stderr>#########################")
209
+ print(response["stderr"])
210
+ print("#########################</stderr>#########################")
211
+ if response.get("error_traceback",None) not in [None,""]:
212
+ print("#########################<traceback>#########################")
213
+ # Check if this is a security exception and highlight it in red if so
214
+ error_text = response["error_traceback"]
215
+ if "SECURITY" in error_text:
216
+ try:
217
+ from ..modal_sandbox import COLOR
218
+ except ImportError:
219
+ # Fallback colors if modal_sandbox is not available
220
+ COLOR = {
221
+ "RED": "\033[91m",
222
+ "ENDC": "\033[0m",
223
+ }
224
+ print(f"{COLOR['RED']}{error_text}{COLOR['ENDC']}")
225
+ else:
226
+ print(error_text)
227
+ print("#########################</traceback>#########################")
196
228
 
197
229
  async def cleanup(self):
198
230
  """Clean up Modal resources."""
@@ -0,0 +1,546 @@
1
+ # TinyAgent code execution safety utilities
2
+ # -----------------------------------------
3
+ #
4
+ # This helper module defines *very* lightweight safeguards that are applied
5
+ # before running any user-supplied Python code inside the Modal sandbox.
6
+ # The goal is **not** to build a full blown secure interpreter (this would
7
+ # require a much more sophisticated setup à la Pyodide or the `python-secure`
8
+ # project). Instead we implement the following pragmatic defence layers:
9
+ #
10
+ # 1. Static AST inspection of the submitted code to detect direct `import` or
11
+ # `from … import …` statements that reference known dangerous modules
12
+ # (e.g. `os`, `subprocess`, …). This prevents the *most common* attack
13
+ # vector where an LLM attempts to read or modify the host file-system or
14
+ # spawn sub-processes.
15
+ # 2. Runtime patching of the built-in `__import__` hook so that *dynamic*
16
+ # imports carried out via `importlib` or `__import__(…)` are blocked at
17
+ # execution time as well.
18
+ # 3. Static AST inspection to detect calls to dangerous functions like `exec`,
19
+ # `eval`, `compile`, etc. that could be used to bypass security measures.
20
+ # 4. Runtime patching of built-in dangerous functions to prevent their use
21
+ # at execution time.
22
+ #
23
+ # The chosen approach keeps the TinyAgent runtime *fast* and *lean* while
24
+ # still providing a reasonable first line of defence against obviously
25
+ # malicious code.
26
+
27
+ from __future__ import annotations
28
+
29
+ import ast
30
+ import builtins
31
+ import warnings
32
+ from typing import Iterable, List, Set, Sequence, Any, Callable
33
+ import contextlib
34
+
35
+ __all__ = [
36
+ "DANGEROUS_MODULES",
37
+ "DANGEROUS_FUNCTIONS",
38
+ "RUNTIME_BLOCKED_FUNCTIONS",
39
+ "validate_code_safety",
40
+ "install_import_hook",
41
+ "function_safety_context",
42
+ ]
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Threat model / deny-list
46
+ # ---------------------------------------------------------------------------
47
+
48
+ # Non-exhaustive list of modules that grant (direct or indirect) access to the
49
+ # underlying operating system, spawn sub-processes, perform unrestricted I/O,
50
+ # or allow the user to circumvent the static import analysis performed below.
51
+ DANGEROUS_MODULES: Set[str] = {
52
+ "builtins", # Gives access to exec/eval etc.
53
+ "ctypes",
54
+ "importlib",
55
+ "io",
56
+ "multiprocessing",
57
+ "os",
58
+ "pathlib",
59
+ "pty",
60
+ "shlex",
61
+ "shutil",
62
+ "signal",
63
+ "socket",
64
+ "subprocess",
65
+ "sys",
66
+ "tempfile",
67
+ "threading",
68
+ "webbrowser",
69
+ }
70
+
71
+ # List of dangerous built-in functions that could be used to bypass security
72
+ # measures or execute arbitrary code
73
+ DANGEROUS_FUNCTIONS: Set[str] = {
74
+ "exec",
75
+ "eval",
76
+ "compile",
77
+ "__import__",
78
+ "open",
79
+ "input",
80
+ "breakpoint",
81
+ }
82
+
83
+ # Functions that should be blocked at runtime (a subset of DANGEROUS_FUNCTIONS)
84
+ RUNTIME_BLOCKED_FUNCTIONS: Set[str] = {
85
+ "exec",
86
+ "eval",
87
+ }
88
+
89
+ # Essential modules that are always allowed, even in untrusted code
90
+ # These are needed for the framework to function properly
91
+ ESSENTIAL_MODULES: Set[str] = {
92
+ "cloudpickle",
93
+ "tinyagent",
94
+ "json",
95
+ "time",
96
+ "datetime",
97
+ "requests",
98
+
99
+ }
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Utility helpers
104
+ # ---------------------------------------------------------------------------
105
+
106
+ def _is_allowed(module_root: str, allowed: Sequence[str] | None) -> bool:
107
+ """Return ``True`` if *module_root* is within *allowed* specification."""
108
+
109
+ if allowed is None:
110
+ # No explicit allow-list means everything that is **not** in the
111
+ # dangerous list is considered fine.
112
+ return True
113
+
114
+ # Fast path – wildcard allows everything.
115
+ if "*" in allowed:
116
+ return True
117
+
118
+ for pattern in allowed:
119
+ if pattern.endswith(".*"):
120
+ if module_root == pattern[:-2]:
121
+ return True
122
+ elif module_root == pattern:
123
+ return True
124
+ return False
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Static analysis helpers
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def _iter_import_nodes(tree: ast.AST) -> Iterable[ast.AST]:
132
+ """Yield all *import* related nodes from *tree*."""
133
+ for node in ast.walk(tree):
134
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
135
+ yield node
136
+
137
+
138
+ def _extract_module_roots(node: ast.AST) -> List[str]:
139
+ """Return the *top-level* module names referenced in an import node."""
140
+ roots: list[str] = []
141
+ if isinstance(node, ast.Import):
142
+ for alias in node.names:
143
+ roots.append(alias.name.split(".")[0])
144
+ elif isinstance(node, ast.ImportFrom):
145
+ if node.module is not None:
146
+ roots.append(node.module.split(".")[0])
147
+ return roots
148
+
149
+
150
+ def _check_for_dangerous_function_calls(tree: ast.AST, authorized_functions: Sequence[str] | None = None) -> Set[str]:
151
+ """
152
+ Check for calls to dangerous functions in the AST.
153
+
154
+ Parameters
155
+ ----------
156
+ tree
157
+ The AST to check
158
+ authorized_functions
159
+ Optional white-list of dangerous functions that are allowed
160
+
161
+ Returns
162
+ -------
163
+ Set[str]
164
+ Set of dangerous function names found in the code
165
+ """
166
+ dangerous_calls = set()
167
+
168
+ # Convert authorized_functions to a set if it's not None and not a boolean
169
+ authorized_set = set(authorized_functions) if authorized_functions is not None and not isinstance(authorized_functions, bool) else set()
170
+
171
+ for node in ast.walk(tree):
172
+ # Check for direct function calls: func()
173
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
174
+ func_name = node.func.id
175
+ # Only flag if it's a known dangerous function
176
+ if func_name in DANGEROUS_FUNCTIONS and func_name not in authorized_set:
177
+ dangerous_calls.add(func_name)
178
+
179
+ # Check for calls via string literals in exec/eval: exec("import os")
180
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ["exec", "eval"]:
181
+ # Only check if the function itself is not authorized
182
+ if node.func.id not in authorized_set:
183
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
184
+ dangerous_calls.add(f"{node.func.id} with string literal")
185
+
186
+ # Check for attribute access: builtins.exec()
187
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
188
+ if node.func.attr in DANGEROUS_FUNCTIONS:
189
+ if isinstance(node.func.value, ast.Name):
190
+ module_name = node.func.value.id
191
+ # Focus on builtins module access
192
+ if module_name == "builtins" and node.func.attr not in authorized_set:
193
+ func_name = f"{module_name}.{node.func.attr}"
194
+ dangerous_calls.add(func_name)
195
+
196
+ # Check for string manipulation that could be used to bypass security
197
+ # For example: e = "e" + "x" + "e" + "c"; e("import os")
198
+ if isinstance(node, ast.Assign):
199
+ for target in node.targets:
200
+ if isinstance(target, ast.Name) and isinstance(node.value, ast.BinOp):
201
+ # Check if we're building a string that could be a dangerous function name
202
+ potential_name = _extract_string_from_binop(node.value)
203
+ if potential_name in DANGEROUS_FUNCTIONS and potential_name not in authorized_set:
204
+ dangerous_calls.add(f"string manipulation to create '{potential_name}'")
205
+
206
+ # Check for getattr(builtins, "exec") pattern
207
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "getattr":
208
+ if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str):
209
+ attr_name = node.args[1].value
210
+ if attr_name in DANGEROUS_FUNCTIONS and attr_name not in authorized_set:
211
+ if isinstance(node.args[0], ast.Name) and node.args[0].id == "builtins":
212
+ module_name = node.args[0].id
213
+ dangerous_calls.add(f"getattr({module_name}, '{attr_name}')")
214
+
215
+ return dangerous_calls
216
+
217
+
218
+ def _extract_string_from_binop(node: ast.BinOp) -> str:
219
+ """
220
+ Attempt to extract a string from a binary operation node.
221
+ This helps detect string concatenation that builds dangerous function names.
222
+
223
+ For example: "e" + "x" + "e" + "c" -> "exec"
224
+
225
+ Parameters
226
+ ----------
227
+ node
228
+ The binary operation node
229
+
230
+ Returns
231
+ -------
232
+ str
233
+ The extracted string, or empty string if not extractable
234
+ """
235
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
236
+ return node.value
237
+
238
+ if not isinstance(node, ast.BinOp):
239
+ return ""
240
+
241
+ # Handle string concatenation
242
+ if isinstance(node.op, ast.Add):
243
+ left_str = _extract_string_from_binop(node.left)
244
+ right_str = _extract_string_from_binop(node.right)
245
+ return left_str + right_str
246
+
247
+ return ""
248
+
249
+
250
+ def _detect_string_obfuscation(tree: ast.AST) -> bool:
251
+ """
252
+ Detect common string obfuscation techniques that might be used to bypass security.
253
+
254
+ Parameters
255
+ ----------
256
+ tree
257
+ The AST to check
258
+
259
+ Returns
260
+ -------
261
+ bool
262
+ True if suspicious string manipulation is detected
263
+ """
264
+ suspicious_patterns = False
265
+
266
+ for node in ast.walk(tree):
267
+ # Check for chr() usage to build strings
268
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "chr":
269
+ suspicious_patterns = True
270
+ break
271
+
272
+ # Check for ord() usage in combination with string operations
273
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "ord":
274
+ suspicious_patterns = True
275
+ break
276
+
277
+ # Check for suspicious string joins with list comprehensions
278
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and
279
+ node.func.attr == "join" and isinstance(node.args[0], ast.ListComp)):
280
+ suspicious_patterns = True
281
+ break
282
+
283
+ # Check for base64 decoding
284
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and
285
+ node.func.attr in ["b64decode", "b64encode", "b32decode", "b32encode", "b16decode", "b16encode"]):
286
+ suspicious_patterns = True
287
+ break
288
+
289
+ # Check for string formatting that might be used to build dangerous code
290
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "format":
291
+ suspicious_patterns = True
292
+ break
293
+
294
+ return suspicious_patterns
295
+
296
+
297
+ def validate_code_safety(code: str, *, authorized_imports: Sequence[str] | None = None,
298
+ authorized_functions: Sequence[str] | None = None, trusted_code: bool = False) -> None:
299
+ """Static validation of user code.
300
+
301
+ Parameters
302
+ ----------
303
+ code
304
+ The user supplied source code (single string or multi-line).
305
+ authorized_imports
306
+ Optional white-list restricting which modules may be imported. If
307
+ *None* every module that is not in :pydata:`DANGEROUS_MODULES` is
308
+ allowed. Wildcards are supported – e.g. ``["numpy.*"]`` allows any
309
+ sub-package of *numpy*.
310
+ authorized_functions
311
+ Optional white-list of dangerous functions that are allowed.
312
+ trusted_code
313
+ If True, skip security checks. This should only be used for code that is part of the
314
+ framework, developer-provided tools, or default executed code.
315
+ """
316
+ # Skip security checks for trusted code
317
+ if trusted_code:
318
+ return
319
+
320
+ try:
321
+ tree = ast.parse(code, mode="exec")
322
+ except SyntaxError:
323
+ # If the code does not even parse we leave the error handling to the
324
+ # caller (who will attempt to compile / execute the code later on).
325
+ return
326
+
327
+ blocked: set[str] = set()
328
+
329
+ # Convert authorized_imports to a set if it's not None and not a boolean
330
+ combined_allowed = None
331
+ if authorized_imports is not None and not isinstance(authorized_imports, bool):
332
+ combined_allowed = set(list(authorized_imports) + list(ESSENTIAL_MODULES))
333
+
334
+
335
+ for node in _iter_import_nodes(tree):
336
+ for root in _extract_module_roots(node):
337
+
338
+ # Check if module is explicitly allowed
339
+ if combined_allowed is not None:
340
+ allowed = _is_allowed(root, combined_allowed)
341
+ else:
342
+ # If no explicit allow-list, only allow if not in DANGEROUS_MODULES
343
+ allowed = root not in DANGEROUS_MODULES
344
+
345
+ if root in DANGEROUS_MODULES and allowed and combined_allowed is not None:
346
+ warnings.warn(
347
+ f"⚠️ Importing dangerous module '{root}' was allowed due to authorized_imports configuration.",
348
+ stacklevel=2,
349
+ )
350
+
351
+ # Block dangerous modules unless explicitly allowed
352
+ if root in DANGEROUS_MODULES and not allowed:
353
+ blocked.add(root)
354
+ # If there is an explicit allow-list, block everything not on it
355
+ elif authorized_imports is not None and not isinstance(authorized_imports, bool) and not allowed and root not in ESSENTIAL_MODULES:
356
+ blocked.add(root)
357
+
358
+ # ------------------------------------------------------------------
359
+ # Detect direct calls to __import__ (e.g. __import__("os")) in *untrusted* code
360
+ # ------------------------------------------------------------------
361
+ for _node in ast.walk(tree):
362
+ if isinstance(_node, ast.Call):
363
+ # Pattern: __import__(...)
364
+ if isinstance(_node.func, ast.Name) and _node.func.id == "__import__":
365
+ # Check if it's a direct call to the built-in __import__
366
+ raise ValueError("SECURITY VIOLATION: Usage of __import__ is not allowed in untrusted code.")
367
+ # Pattern: builtins.__import__(...)
368
+ if (
369
+ isinstance(_node.func, ast.Attribute)
370
+ and isinstance(_node.func.value, ast.Name)
371
+ and _node.func.attr == "__import__"
372
+ and _node.func.value.id == "builtins"
373
+ ):
374
+ raise ValueError("SECURITY VIOLATION: Usage of builtins.__import__ is not allowed in untrusted code.")
375
+
376
+ # ------------------------------------------------------------------
377
+ # Detect calls to dangerous functions (e.g. exec, eval) in *untrusted* code
378
+ # ------------------------------------------------------------------
379
+ dangerous_calls = _check_for_dangerous_function_calls(tree, authorized_functions)
380
+ if dangerous_calls:
381
+ offenders = ", ".join(sorted(dangerous_calls))
382
+ raise ValueError(f"SECURITY VIOLATION: Usage of dangerous function(s) {offenders} is not allowed in untrusted code.")
383
+
384
+ # ------------------------------------------------------------------
385
+ # Detect string obfuscation techniques that might be used to bypass security
386
+ # ------------------------------------------------------------------
387
+ if _detect_string_obfuscation(tree):
388
+ raise ValueError("SECURITY VIOLATION: Suspicious string manipulation detected that could be used to bypass security.")
389
+
390
+ if blocked:
391
+ offenders = ", ".join(sorted(blocked))
392
+ msg = f"SECURITY VIOLATION: Importing module(s) {offenders} is not allowed."
393
+ if authorized_imports is not None and not isinstance(authorized_imports, bool):
394
+ msg += " Allowed imports are: " + ", ".join(sorted(authorized_imports))
395
+ raise ValueError(msg)
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Runtime import hook
400
+ # ---------------------------------------------------------------------------
401
+
402
+ def install_import_hook(
403
+ *,
404
+ blocked_modules: Set[str] | None = None,
405
+ authorized_imports: Sequence[str] | None = None,
406
+ trusted_code: bool = False,
407
+ ) -> None:
408
+ """Monkey-patch the built-in ``__import__`` to deny run-time imports.
409
+
410
+ The hook is *process-wide* but extremely cheap to install. It simply
411
+ checks the *root* package name against the provided *blocked_modules*
412
+ (defaults to :pydata:`DANGEROUS_MODULES`) and raises ``ImportError`` if the
413
+ import should be denied.
414
+
415
+ Calling this function **multiple times** is safe – only the first call
416
+ installs the wrapper, subsequent calls are ignored.
417
+
418
+ Parameters
419
+ ----------
420
+ blocked_modules
421
+ Set of module names to block. Defaults to DANGEROUS_MODULES.
422
+ authorized_imports
423
+ Optional white-list restricting which modules may be imported.
424
+ trusted_code
425
+ If True, skip security checks. This should only be used for code that is part of the
426
+ framework, developer-provided tools, or default executed code.
427
+ """
428
+ # Skip security checks for trusted code
429
+ if trusted_code:
430
+ return
431
+
432
+ blocked_modules = blocked_modules or DANGEROUS_MODULES
433
+
434
+ # Convert authorized_imports to a set if it's not None and not a boolean
435
+ authorized_set = set(authorized_imports) if authorized_imports is not None and not isinstance(authorized_imports, bool) else None
436
+
437
+ # Create a combined set for allowed modules (essential + authorized)
438
+ combined_allowed = None
439
+ if authorized_set is not None:
440
+ combined_allowed = set(list(authorized_set) + list(ESSENTIAL_MODULES))
441
+
442
+ # Check if we have already installed the hook to avoid double-wrapping.
443
+ if getattr(builtins, "__tinyagent_import_hook_installed", False):
444
+ return
445
+
446
+ original_import = builtins.__import__
447
+
448
+ def _safe_import(
449
+ name: str,
450
+ globals=None,
451
+ locals=None,
452
+ fromlist=(),
453
+ level: int = 0,
454
+ ): # type: ignore[override]
455
+ root = name.split(".")[0]
456
+
457
+ # Check if module is explicitly allowed
458
+ if combined_allowed is not None:
459
+ allowed = _is_allowed(root, combined_allowed)
460
+ else:
461
+ # If no explicit allow-list, only allow if not in blocked_modules
462
+ allowed = root not in blocked_modules
463
+
464
+ if root in blocked_modules and allowed and authorized_set is not None:
465
+ warnings.warn(
466
+ f"⚠️ Importing dangerous module '{root}' was allowed due to authorized_imports configuration.",
467
+ stacklevel=2,
468
+ )
469
+ elif root in blocked_modules and not allowed:
470
+ error_msg = f"SECURITY VIOLATION: Import of module '{name}' is blocked by TinyAgent security policy"
471
+ if authorized_set is not None:
472
+ error_msg += f". Allowed imports are: {', '.join(sorted(authorized_set))}"
473
+ raise ImportError(error_msg)
474
+ elif authorized_set is not None and not allowed and root not in ESSENTIAL_MODULES:
475
+ error_msg = f"SECURITY VIOLATION: Import of module '{name}' is not in the authorized imports list"
476
+ if authorized_set:
477
+ error_msg += f": {', '.join(sorted(authorized_set))}"
478
+ raise ImportError(error_msg)
479
+
480
+ return original_import(name, globals, locals, fromlist, level)
481
+
482
+ builtins.__import__ = _safe_import # type: ignore[assignment]
483
+ setattr(builtins, "__tinyagent_import_hook_installed", True)
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Runtime function hook
488
+ # ---------------------------------------------------------------------------
489
+
490
+ @contextlib.contextmanager
491
+ def function_safety_context(
492
+ *,
493
+ blocked_functions: Set[str] | None = None,
494
+ authorized_functions: Sequence[str] | None = None,
495
+ trusted_code: bool = False,
496
+ ):
497
+ """
498
+ Context manager for safely executing code with dangerous functions blocked.
499
+
500
+ Parameters
501
+ ----------
502
+ blocked_functions
503
+ Set of function names to block. Defaults to RUNTIME_BLOCKED_FUNCTIONS.
504
+ authorized_functions
505
+ Optional white-list of dangerous functions that are allowed.
506
+ trusted_code
507
+ If True, skip security checks. This should only be used for code that is part of the
508
+ framework, developer-provided tools, or default executed code.
509
+ """
510
+ if trusted_code:
511
+ yield
512
+ return
513
+
514
+ # Install the function hook
515
+ blocked_functions = blocked_functions or RUNTIME_BLOCKED_FUNCTIONS
516
+
517
+ # Convert authorized_functions to a set if it's not None and not a boolean
518
+ authorized_set = set(authorized_functions) if authorized_functions is not None and not isinstance(authorized_functions, bool) else set()
519
+
520
+ # Store original functions
521
+ original_functions = {}
522
+
523
+ # Replace dangerous functions with safe versions
524
+ for func_name in blocked_functions:
525
+ # Only block functions that exist in builtins
526
+ if hasattr(builtins, func_name) and func_name not in authorized_set:
527
+ original_functions[func_name] = getattr(builtins, func_name)
528
+
529
+ # Create a closure to capture the function name
530
+ def make_safe_function(name):
531
+ def safe_function(*args, **kwargs):
532
+ error_msg = f"SECURITY VIOLATION: Function '{name}' is blocked by TinyAgent security policy"
533
+ if authorized_functions and not isinstance(authorized_functions, bool) and isinstance(authorized_functions, (list, tuple, set)):
534
+ error_msg += f". Allowed functions are: {', '.join(sorted(authorized_set))}"
535
+ raise RuntimeError(error_msg)
536
+ return safe_function
537
+
538
+ # Replace the function
539
+ setattr(builtins, func_name, make_safe_function(func_name))
540
+
541
+ try:
542
+ yield
543
+ finally:
544
+ # Restore original functions
545
+ for func_name, original_func in original_functions.items():
546
+ setattr(builtins, func_name, original_func)
@@ -62,6 +62,7 @@ class TinyCodeAgent:
62
62
  self.user_variables = user_variables or {}
63
63
  self.pip_packages = pip_packages or []
64
64
  self.local_execution = local_execution
65
+ self.provider = provider # Store provider type for reuse
65
66
 
66
67
  # Create the code execution provider
67
68
  self.code_provider = self._create_provider(provider, self.provider_config)
@@ -96,8 +97,13 @@ class TinyCodeAgent:
96
97
  config_pip_packages = config.get("pip_packages", [])
97
98
  final_pip_packages = list(set(self.pip_packages + config_pip_packages))
98
99
 
100
+ # Merge authorized_imports from both sources (direct parameter and provider_config)
101
+ config_authorized_imports = config.get("authorized_imports", [])
102
+ final_authorized_imports = list(set(self.authorized_imports + config_authorized_imports))
103
+
99
104
  final_config = config.copy()
100
105
  final_config["pip_packages"] = final_pip_packages
106
+ final_config["authorized_imports"] = final_authorized_imports
101
107
 
102
108
  return ModalProvider(
103
109
  log_manager=self.log_manager,
@@ -258,6 +264,14 @@ class TinyCodeAgent:
258
264
  result = await self.code_provider.execute_python(code_lines, timeout)
259
265
  return str(result)
260
266
  except Exception as e:
267
+ print("!"*100)
268
+ COLOR = {
269
+ "RED": "\033[91m",
270
+ "ENDC": "\033[0m",
271
+ }
272
+ print(f"{COLOR['RED']}{str(e)}{COLOR['ENDC']}")
273
+ print(f"{COLOR['RED']}{traceback.format_exc()}{COLOR['ENDC']}")
274
+ print("!"*100)
261
275
  return f"Error executing code: {str(e)}"
262
276
 
263
277
  self.agent.add_tool(run_python)
@@ -441,6 +455,69 @@ class TinyCodeAgent:
441
455
  """
442
456
  return self.pip_packages.copy()
443
457
 
458
+ def add_authorized_imports(self, imports: List[str]):
459
+ """
460
+ Add additional authorized imports to the execution environment.
461
+
462
+ Args:
463
+ imports: List of import names to authorize
464
+ """
465
+ self.authorized_imports.extend(imports)
466
+ self.authorized_imports = list(set(self.authorized_imports)) # Remove duplicates
467
+
468
+ # Update the provider with the new authorized imports
469
+ # This requires recreating the provider
470
+ print("⚠️ Warning: Adding authorized imports after initialization requires recreating the Modal environment.")
471
+ print(" For better performance, set authorized_imports during TinyCodeAgent initialization.")
472
+
473
+ # Recreate the provider with new authorized imports
474
+ self.code_provider = self._create_provider(self.provider, self.provider_config)
475
+
476
+ # Re-set user variables if they exist
477
+ if self.user_variables:
478
+ self.code_provider.set_user_variables(self.user_variables)
479
+
480
+ # Rebuild system prompt to include new authorized imports
481
+ self.system_prompt = self._build_system_prompt()
482
+ # Update the agent's system prompt
483
+ self.agent.system_prompt = self.system_prompt
484
+
485
+ def get_authorized_imports(self) -> List[str]:
486
+ """
487
+ Get a copy of current authorized imports.
488
+
489
+ Returns:
490
+ List of authorized imports
491
+ """
492
+ return self.authorized_imports.copy()
493
+
494
+ def remove_authorized_import(self, import_name: str):
495
+ """
496
+ Remove an authorized import.
497
+
498
+ Args:
499
+ import_name: Import name to remove
500
+ """
501
+ if import_name in self.authorized_imports:
502
+ self.authorized_imports.remove(import_name)
503
+
504
+ # Update the provider with the new authorized imports
505
+ # This requires recreating the provider
506
+ print("⚠️ Warning: Removing authorized imports after initialization requires recreating the Modal environment.")
507
+ print(" For better performance, set authorized_imports during TinyCodeAgent initialization.")
508
+
509
+ # Recreate the provider with updated authorized imports
510
+ self.code_provider = self._create_provider(self.provider, self.provider_config)
511
+
512
+ # Re-set user variables if they exist
513
+ if self.user_variables:
514
+ self.code_provider.set_user_variables(self.user_variables)
515
+
516
+ # Rebuild system prompt to reflect updated authorized imports
517
+ self.system_prompt = self._build_system_prompt()
518
+ # Update the agent's system prompt
519
+ self.agent.system_prompt = self.system_prompt
520
+
444
521
  async def close(self):
445
522
  """Clean up resources."""
446
523
  await self.code_provider.cleanup()
@@ -498,6 +575,7 @@ async def run_example():
498
575
  user_variables={
499
576
  "sample_data": [1, 2, 3, 4, 5, 10, 15, 20]
500
577
  },
578
+ authorized_imports=["tinyagent", "gradio", "requests", "numpy", "pandas"], # Explicitly specify authorized imports
501
579
  local_execution=False # Remote execution via Modal (default)
502
580
  )
503
581
 
@@ -524,6 +602,7 @@ async def run_example():
524
602
  user_variables={
525
603
  "sample_data": [1, 2, 3, 4, 5, 10, 15, 20]
526
604
  },
605
+ authorized_imports=["tinyagent", "gradio", "requests"], # More restricted imports for local execution
527
606
  local_execution=True # Local execution
528
607
  )
529
608
 
@@ -550,6 +629,18 @@ async def run_example():
550
629
  agent_remote.add_code_tool(validator)
551
630
  agent_local.add_code_tool(validator)
552
631
 
632
+ # Demonstrate adding authorized imports dynamically
633
+ print("\n" + "="*80)
634
+ print("🔧 Testing with dynamically added authorized imports")
635
+ agent_remote.add_authorized_imports(["matplotlib", "seaborn"])
636
+
637
+ # Test with visualization libraries
638
+ viz_prompt = "Create a simple plot of the sample_data and save it as a base64 encoded image string."
639
+
640
+ response_viz = await agent_remote.run(viz_prompt)
641
+ print("Remote Agent Visualization Response:")
642
+ print(response_viz)
643
+
553
644
  print("\n" + "="*80)
554
645
  print("🔧 Testing with dynamically added tools")
555
646
 
@@ -1,6 +1,7 @@
1
1
  import sys
2
2
  import cloudpickle
3
- from typing import Dict, Any
3
+ from typing import Dict, Any, List
4
+ from .safety import validate_code_safety, function_safety_context
4
5
 
5
6
 
6
7
  def clean_response(resp: Dict[str, Any]) -> Dict[str, Any]:
@@ -40,7 +41,14 @@ def make_session_blob(ns: dict) -> bytes:
40
41
  return cloudpickle.dumps(clean)
41
42
 
42
43
 
43
- def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dict[str, Any] = None):
44
+ def _run_python(
45
+ code: str,
46
+ globals_dict: Dict[str, Any] | None = None,
47
+ locals_dict: Dict[str, Any] | None = None,
48
+ authorized_imports: List[str] | None = None,
49
+ authorized_functions: List[str] | None = None,
50
+ trusted_code: bool = False,
51
+ ):
44
52
  """
45
53
  Execute Python code in a controlled environment with proper error handling.
46
54
 
@@ -48,6 +56,9 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
48
56
  code: Python code to execute
49
57
  globals_dict: Global variables dictionary
50
58
  locals_dict: Local variables dictionary
59
+ authorized_imports: List of authorized imports that user code may access. Wildcards (e.g. "numpy.*") are supported. A value of None disables the allow-list and only blocks dangerous modules.
60
+ authorized_functions: List of authorized dangerous functions that user code may access. A value of None disables the allow-list and blocks all dangerous functions.
61
+ trusted_code: If True, skip security checks. Should only be used for framework code, tools, or default executed code.
51
62
 
52
63
  Returns:
53
64
  Dictionary containing execution results
@@ -56,16 +67,27 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
56
67
  import traceback
57
68
  import io
58
69
  import ast
59
-
70
+ import builtins # Needed for import hook
71
+ import sys
72
+
73
+ # ------------------------------------------------------------------
74
+ # 1. Static safety analysis – refuse code containing dangerous imports or functions
75
+ # ------------------------------------------------------------------
76
+ validate_code_safety(code, authorized_imports=authorized_imports,
77
+ authorized_functions=authorized_functions, trusted_code=trusted_code)
78
+
60
79
  # Make copies to avoid mutating the original parameters
61
80
  globals_dict = globals_dict or {}
62
81
  locals_dict = locals_dict or {}
63
82
  updated_globals = globals_dict.copy()
64
83
  updated_locals = locals_dict.copy()
65
84
 
66
- # Pre-import essential modules into the global namespace
67
- # This ensures they're available for imports inside functions
68
- essential_modules = ['requests', 'json', 'os', 'sys', 'time', 'datetime', 're', 'random', 'math']
85
+ # Only pre-import a **minimal** set of safe modules so that common helper
86
+ # functions work out of the box without giving user code access to the
87
+ # full standard library. Anything outside this list must be imported
88
+ # explicitly by the user – and will be blocked by the safety layer above
89
+ # if considered dangerous.
90
+ essential_modules = ['requests', 'json', 'time', 'datetime', 're', 'random', 'math','cloudpickle']
69
91
 
70
92
  for module_name in essential_modules:
71
93
  try:
@@ -75,12 +97,30 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
75
97
  except ImportError:
76
98
  print(f"⚠️ Warning: {module_name} module not available")
77
99
 
100
+ # Variable to store print output
101
+ output_buffer = []
102
+
103
+ # Create a custom print function that captures output
104
+ def custom_print(*args, **kwargs):
105
+ # Get the sep and end kwargs, defaulting to ' ' and '\n'
106
+ sep = kwargs.get('sep', ' ')
107
+ end = kwargs.get('end', '\n')
108
+
109
+ # Convert all arguments to strings and join them
110
+ output = sep.join(str(arg) for arg in args) + end
111
+
112
+ # Store the output
113
+ output_buffer.append(output)
114
+
115
+ # Add the custom print function to the globals
116
+ #updated_globals['print'] = custom_print
117
+
118
+ # Parse the code
78
119
  tree = ast.parse(code, mode="exec")
79
120
  compiled = compile(tree, filename="<ast>", mode="exec")
80
121
  stdout_buf = io.StringIO()
81
- stderr_buf = io.StringIO()
82
-
83
- # Execute with stdout+stderr capture and exception handling
122
+ stderr_buf = io.StringIO()
123
+ # Execute with exception handling
84
124
  error_traceback = None
85
125
  output = None
86
126
 
@@ -92,8 +132,15 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
92
132
  merged_globals = updated_globals.copy()
93
133
  merged_globals.update(updated_locals)
94
134
 
135
+ # Add 'exec' to authorized_functions for internal use
136
+ internal_authorized_functions = ['exec','eval']
137
+ if authorized_functions is not None and not isinstance(authorized_functions, bool):
138
+ internal_authorized_functions.extend(authorized_functions)
139
+
95
140
  # Execute with only globals - this fixes generator expression scoping issues
96
- output = exec(code, merged_globals)
141
+ # Use the function_safety_context to block dangerous functions during execution
142
+ with function_safety_context(authorized_functions=internal_authorized_functions, trusted_code=trusted_code):
143
+ output = exec(compiled, merged_globals)
97
144
 
98
145
  # Update both dictionaries with any new variables created during execution
99
146
  for key, value in merged_globals.items():
@@ -106,6 +153,8 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
106
153
  # Capture the full traceback as a string
107
154
  error_traceback = traceback.format_exc()
108
155
 
156
+ # Join all captured output
157
+ #printed_output = ''.join(output_buffer)
109
158
  printed_output = stdout_buf.getvalue()
110
159
  stderr_output = stderr_buf.getvalue()
111
160
  error_traceback_output = error_traceback
@@ -5,6 +5,7 @@ import os
5
5
  import re
6
6
  import shutil
7
7
  import time
8
+ import io
8
9
  from pathlib import Path
9
10
  from typing import Any, Dict, List, Optional, Set, Union
10
11
 
@@ -36,6 +37,7 @@ class GradioCallback:
36
37
  show_thinking: bool = True,
37
38
  show_tool_calls: bool = True,
38
39
  logger: Optional[logging.Logger] = None,
40
+ log_manager: Optional[Any] = None,
39
41
  ):
40
42
  """
41
43
  Initialize the Gradio callback.
@@ -46,6 +48,7 @@ class GradioCallback:
46
48
  show_thinking: Whether to show the thinking process
47
49
  show_tool_calls: Whether to show tool calls
48
50
  logger: Optional logger to use
51
+ log_manager: Optional LoggingManager instance to capture logs from
49
52
  """
50
53
  self.logger = logger or logging.getLogger(__name__)
51
54
  self.show_thinking = show_thinking
@@ -81,6 +84,37 @@ class GradioCallback:
81
84
  # References to Gradio UI components (will be set in create_app)
82
85
  self._chatbot_component = None
83
86
  self._token_usage_component = None
87
+
88
+ # Log stream for displaying logs in the UI
89
+ self.log_stream = io.StringIO()
90
+ self._log_component = None
91
+
92
+ # Setup logging
93
+ self.log_manager = log_manager
94
+ if log_manager:
95
+ # Create a handler that writes to our StringIO stream
96
+ self.log_handler = logging.StreamHandler(self.log_stream)
97
+ self.log_handler.setFormatter(
98
+ logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
99
+ )
100
+ self.log_handler.setLevel(logging.DEBUG)
101
+
102
+ # Add the handler to the LoggingManager
103
+ log_manager.configure_handler(
104
+ self.log_handler,
105
+ format_string='%(asctime)s - %(levelname)s - %(name)s - %(message)s',
106
+ level=logging.DEBUG
107
+ )
108
+ self.logger.debug("Added log handler to LoggingManager")
109
+ elif logger:
110
+ # Fall back to single logger if no LoggingManager is provided
111
+ self.log_handler = logging.StreamHandler(self.log_stream)
112
+ self.log_handler.setFormatter(
113
+ logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
114
+ )
115
+ self.log_handler.setLevel(logging.DEBUG)
116
+ logger.addHandler(self.log_handler)
117
+ self.logger.debug("Added log handler to logger")
84
118
 
85
119
  self.logger.debug("GradioCallback initialized")
86
120
 
@@ -525,7 +559,7 @@ class GradioCallback:
525
559
  typing_message_index = len(chatbot_history) - 1
526
560
 
527
561
  # Initial yield to show user message and typing indicator
528
- yield chatbot_history, self._get_token_usage_text()
562
+ yield chatbot_history, self._get_token_usage_text(), self.log_stream.getvalue() if self._log_component else None
529
563
 
530
564
  # Kick off the agent in the background
531
565
  loop = asyncio.get_event_loop()
@@ -632,9 +666,10 @@ class GradioCallback:
632
666
  del in_progress_tool_calls[tid]
633
667
  self.logger.debug(f"Updated tool call to completed: {tname}")
634
668
 
635
- # yield updated history + token usage
669
+ # yield updated history + token usage + logs
636
670
  token_text = self._get_token_usage_text()
637
- yield chatbot_history, token_text
671
+ logs = self.log_stream.getvalue() if self._log_component else None
672
+ yield chatbot_history, token_text, logs
638
673
  self.last_update_yield_time = now
639
674
 
640
675
  await asyncio.sleep(update_interval)
@@ -657,8 +692,9 @@ class GradioCallback:
657
692
  )
658
693
  self.logger.debug(f"Added final result: {final_text[:50]}...")
659
694
 
660
- # final token usage
661
- yield chatbot_history, self._get_token_usage_text()
695
+ # final token usage and logs
696
+ logs = self.log_stream.getvalue() if self._log_component else None
697
+ yield chatbot_history, self._get_token_usage_text(), logs
662
698
 
663
699
  def _format_response(self, response_text):
664
700
  """
@@ -839,6 +875,22 @@ class GradioCallback:
839
875
  # Clear button
840
876
  clear_btn = gr.Button("Clear Conversation")
841
877
 
878
+ # Log accordion - similar to the example provided
879
+ with gr.Accordion("Agent Logs", open=False) as log_accordion:
880
+ self._log_component = gr.Code(
881
+ label="Live Logs",
882
+ lines=15,
883
+ interactive=False,
884
+ value=self.log_stream.getvalue()
885
+ )
886
+ refresh_logs_btn = gr.Button("🔄 Refresh Logs")
887
+ refresh_logs_btn.click(
888
+ fn=lambda: self.log_stream.getvalue(),
889
+ inputs=None,
890
+ outputs=[self._log_component],
891
+ queue=False
892
+ )
893
+
842
894
  # Store processed input temporarily between steps
843
895
  processed_input_state = gr.State("")
844
896
 
@@ -859,7 +911,7 @@ class GradioCallback:
859
911
  # 3. Run the main interaction loop (this yields updates)
860
912
  fn=self.interact_with_agent,
861
913
  inputs=[processed_input_state, self._chatbot_component],
862
- outputs=[self._chatbot_component, self._token_usage_component], # Update chat and tokens
914
+ outputs=[self._chatbot_component, self._token_usage_component, self._log_component], # Update chat, tokens, and logs
863
915
  queue=True # Explicitly enable queue for this async generator
864
916
  ).then(
865
917
  # 4. Re-enable the button after interaction finishes
@@ -885,7 +937,7 @@ class GradioCallback:
885
937
  # 3. Run the main interaction loop (this yields updates)
886
938
  fn=self.interact_with_agent,
887
939
  inputs=[processed_input_state, self._chatbot_component],
888
- outputs=[self._chatbot_component, self._token_usage_component], # Update chat and tokens
940
+ outputs=[self._chatbot_component, self._token_usage_component, self._log_component], # Update chat, tokens, and logs
889
941
  queue=True # Explicitly enable queue for this async generator
890
942
  ).then(
891
943
  # 4. Re-enable the button after interaction finishes
@@ -899,8 +951,8 @@ class GradioCallback:
899
951
  clear_btn.click(
900
952
  fn=self.clear_conversation,
901
953
  inputs=None, # No inputs needed
902
- # Outputs: Clear chatbot and reset token text
903
- outputs=[self._chatbot_component, self._token_usage_component],
954
+ # Outputs: Clear chatbot, reset token text, and update logs
955
+ outputs=[self._chatbot_component, self._token_usage_component, self._log_component],
904
956
  queue=False # Run quickly
905
957
  )
906
958
 
@@ -917,6 +969,12 @@ class GradioCallback:
917
969
  self.assistant_text_responses = []
918
970
  self.token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
919
971
  self.is_running = False
972
+
973
+ # Clear log stream
974
+ if hasattr(self, 'log_stream'):
975
+ self.log_stream.seek(0)
976
+ self.log_stream.truncate(0)
977
+ self.logger.info("Log stream cleared")
920
978
 
921
979
  # Completely reset the agent state with a new session
922
980
  try:
@@ -965,8 +1023,9 @@ class GradioCallback:
965
1023
  except Exception as e:
966
1024
  self.logger.error(f"Failed to reset TinyAgent completely: {e}")
967
1025
 
968
- # Return cleared UI components: empty chat + fresh token usage
969
- return [], self._get_token_usage_text()
1026
+ # Return cleared UI components: empty chat + fresh token usage + empty logs
1027
+ logs = self.log_stream.getvalue() if hasattr(self, 'log_stream') else ""
1028
+ return [], self._get_token_usage_text(), logs
970
1029
 
971
1030
  def launch(self, agent, title="TinyAgent Chat", description=None, share=False, **kwargs):
972
1031
  """
@@ -1028,21 +1087,31 @@ async def run_example():
1028
1087
  from tinyagent import TinyAgent # Assuming TinyAgent is importable
1029
1088
  from tinyagent.hooks.logging_manager import LoggingManager # Assuming LoggingManager exists
1030
1089
 
1031
- # --- Logging Setup (Simplified) ---
1090
+ # --- Logging Setup (Similar to the example provided) ---
1032
1091
  log_manager = LoggingManager(default_level=logging.INFO)
1033
1092
  log_manager.set_levels({
1034
1093
  'tinyagent.hooks.gradio_callback': logging.DEBUG,
1035
1094
  'tinyagent.tiny_agent': logging.DEBUG,
1036
1095
  'tinyagent.mcp_client': logging.DEBUG,
1096
+ 'tinyagent.code_agent': logging.DEBUG,
1037
1097
  })
1098
+
1099
+ # Console handler for terminal output
1038
1100
  console_handler = logging.StreamHandler(sys.stdout)
1039
1101
  log_manager.configure_handler(
1040
1102
  console_handler,
1041
1103
  format_string='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
1042
1104
  level=logging.DEBUG
1043
1105
  )
1106
+
1107
+ # The Gradio UI will automatically set up its own log handler
1108
+ # through the LoggingManager when we pass it to GradioCallback
1109
+
1110
+ # Get loggers for different components
1044
1111
  ui_logger = log_manager.get_logger('tinyagent.hooks.gradio_callback')
1045
1112
  agent_logger = log_manager.get_logger('tinyagent.tiny_agent')
1113
+ mcp_logger = log_manager.get_logger('tinyagent.mcp_client')
1114
+
1046
1115
  ui_logger.info("--- Starting GradioCallback Example ---")
1047
1116
  # --- End Logging Setup ---
1048
1117
 
@@ -1064,12 +1133,13 @@ async def run_example():
1064
1133
 
1065
1134
  agent.add_tool(get_weather)
1066
1135
 
1067
- # Create the Gradio callback
1136
+ # Create the Gradio callback with LoggingManager integration
1068
1137
  gradio_ui = GradioCallback(
1069
1138
  file_upload_folder=upload_folder,
1070
1139
  show_thinking=True,
1071
1140
  show_tool_calls=True,
1072
- logger=ui_logger # Pass the specific logger
1141
+ logger=ui_logger,
1142
+ log_manager=log_manager # Pass the LoggingManager for comprehensive logging
1073
1143
  )
1074
1144
  agent.add_callback(gradio_ui)
1075
1145
 
@@ -1084,25 +1154,9 @@ async def run_example():
1084
1154
  ui_logger.error(f"Failed to connect to MCP servers: {e}", exc_info=True)
1085
1155
  # Continue without servers - we still have the local get_weather tool
1086
1156
 
1087
- # Create the Gradio app but don't launch it yet
1088
- #app = gradio_ui.create_app(
1089
- # agent,
1090
- # title="TinyAgent Chat Interface",
1091
- # description="Chat with TinyAgent. Try asking: 'Plan a trip to Toronto for 7 days in the next month.'",
1092
- #)
1093
-
1094
- # Configure the queue without extra parameters
1095
- #app.queue()
1096
-
1097
- # Launch the app in a way that doesn't block our event loop
1157
+ # Launch the Gradio interface
1098
1158
  ui_logger.info("Launching Gradio interface...")
1099
1159
  try:
1100
- # Launch without blocking
1101
- #app.launch(
1102
- # share=False,
1103
- # prevent_thread_lock=True, # Critical to not block our event loop
1104
- # show_error=True
1105
- #)
1106
1160
  gradio_ui.launch(
1107
1161
  agent,
1108
1162
  title="TinyAgent Chat Interface",
@@ -1113,9 +1167,19 @@ async def run_example():
1113
1167
  )
1114
1168
  ui_logger.info("Gradio interface launched (non-blocking).")
1115
1169
 
1170
+ # Generate some log messages to demonstrate the log panel
1171
+ # These will appear in both the terminal and the Gradio UI log panel
1172
+ ui_logger.info("UI component initialized successfully")
1173
+ agent_logger.debug("Agent ready to process requests")
1174
+ mcp_logger.info("MCP connection established")
1175
+
1176
+ for i in range(3):
1177
+ ui_logger.info(f"Example log message {i+1} from UI logger")
1178
+ agent_logger.debug(f"Example debug message {i+1} from agent logger")
1179
+ mcp_logger.warning(f"Example warning {i+1} from MCP logger")
1180
+ await asyncio.sleep(1)
1181
+
1116
1182
  # Keep the main event loop running to handle both Gradio and MCP operations
1117
- # This is the key part - we need to keep our main event loop running
1118
- # but also allow it to process both Gradio and MCP client operations
1119
1183
  while True:
1120
1184
  await asyncio.sleep(1) # More efficient than an Event().wait()
1121
1185
 
tinyagent/tiny_agent.py CHANGED
@@ -725,13 +725,10 @@ class TinyAgent:
725
725
  next_turn_should_call_tools = False
726
726
  else:
727
727
  # No tool calls in this message
728
- if num_turns > 0:
729
- #if next_turn_should_call_tools and num_turns > 0:
730
- # If we expected tool calls but didn't get any, we're done
731
- await self._run_callbacks("agent_end", result=assistant_msg["content"] or "")
732
- return assistant_msg["content"] or ""
733
-
734
- next_turn_should_call_tools = True
728
+ # If the model provides a direct answer without tool calls, we should return it
729
+ # This handles the case where the LLM gives a direct answer without using tools
730
+ await self._run_callbacks("agent_end", result=assistant_msg["content"] or "")
731
+ return assistant_msg["content"] or ""
735
732
 
736
733
  num_turns += 1
737
734
  if num_turns >= max_turns:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyagent-py
3
- Version: 0.0.11
3
+ Version: 0.0.12
4
4
  Summary: TinyAgent with MCP Client, Code Agent (Thinking, Planning, and Executing in Python), and Extendable Hooks, Tiny but powerful
5
5
  Author-email: Mahdi Golchin <golchin@askdev.ai>
6
6
  Project-URL: Homepage, https://github.com/askbudi/tinyagent
@@ -1,20 +1,21 @@
1
1
  tinyagent/__init__.py,sha256=-3ZN8unMZDrA366BET1HKp-fnFCyXCAD1fPVbHkJSsY,172
2
2
  tinyagent/mcp_client.py,sha256=9dmLtJ8CTwKWKTH6K9z8CaCQuaicOH9ifAuNyX7Kdo0,6030
3
3
  tinyagent/memory_manager.py,sha256=tAaZZdxBJ235wJIyr04n3f2Damok4s2UXunTtur_p-4,44916
4
- tinyagent/tiny_agent.py,sha256=_qHQc2yroSI1Ihn5Ex3FH_ml7aaSNC9-yR540C16Cxs,41125
4
+ tinyagent/tiny_agent.py,sha256=X-rh8gCxeVSNS4RJ9vdtqTKf8ksDGrXsV1jwprW6Fj8,41049
5
5
  tinyagent/code_agent/__init__.py,sha256=YSOblSwRS1QcAYUu--GvF4fKeQX1KRTj9P8CWySY3pY,327
6
6
  tinyagent/code_agent/example.py,sha256=qC6i3auUT1YwXS9WK1Ovq-9oDOUgzRxDegYdlVcVcfA,5861
7
7
  tinyagent/code_agent/helper.py,sha256=oZnpo-_H3cB12LxNN7Ztd-31EiUcuI2UpWP69xuF8oE,7205
8
- tinyagent/code_agent/modal_sandbox.py,sha256=3FaTLESkdQmEO9A5dMmMOOkd0w2cnGGpfXngvojQxJ8,16317
9
- tinyagent/code_agent/tiny_code_agent.py,sha256=D0stvCao0dvrpY2ChxGxf048SEUDW-yHgJ_9Aluw2Ek,23142
10
- tinyagent/code_agent/utils.py,sha256=rEnrKv9l8knjUroBQhz6Q8q6ehCt9IAsJtM-m3ZMNjY,4231
8
+ tinyagent/code_agent/modal_sandbox.py,sha256=RcQ5a-UFyqV7xSHnttpgAQQ-mNWk-9Z0tb836ua7C0E,16381
9
+ tinyagent/code_agent/safety.py,sha256=WHad2ypzfsdKnXG6FcgXcgGiMC-H4KTmOzhP9S9i3Zw,22209
10
+ tinyagent/code_agent/tiny_code_agent.py,sha256=r3c_irn5RJp9qNewhlOLFaGGTAjLWqHjTwM8QewN54I,27426
11
+ tinyagent/code_agent/utils.py,sha256=CAg8LKc_sxnXmwF0L2nRP13XsftYl3ZiD5qWO5D7QeQ,6842
11
12
  tinyagent/code_agent/providers/__init__.py,sha256=myfy9qsBDjNOhcgXJ2E9jO1q5eo6jHp43I2k0k8esLY,136
12
13
  tinyagent/code_agent/providers/base.py,sha256=Hm8jrD60QovgQHTwiFE1pKDHPk1cwGbUdzuSwic9Rjc,5832
13
- tinyagent/code_agent/providers/modal_provider.py,sha256=myq0WKiNi8G0MpUUGo7olbGFVmcZ3iMa8XTmT537PZg,8576
14
+ tinyagent/code_agent/providers/modal_provider.py,sha256=4uhmDA2pLYbbBi3actrBkHD_-pt3gt2incKBj12hab0,10084
14
15
  tinyagent/code_agent/tools/__init__.py,sha256=0XtrgYBgBayOffW50KyrlmrXXs9iu6z1DHu7-D8WGqY,94
15
16
  tinyagent/code_agent/tools/example_tools.py,sha256=YbXb7PKuvvxh-LV12Y4n_Ez3RyLA95gWOcZrKsa7UHg,1203
16
17
  tinyagent/hooks/__init__.py,sha256=RZow2r0XHLJ3-tnmecScdc0_wrEdmOy5dtXqoiRME5Y,254
17
- tinyagent/hooks/gradio_callback.py,sha256=TS28i4UUIEWsl_e75tRXyVTOKzH7at8nmIl8d3ZTYag,53493
18
+ tinyagent/hooks/gradio_callback.py,sha256=3vKfGknn7XOHtcoF6DkLKDCJmC_oCRhu1-kqWmAAOc4,56821
18
19
  tinyagent/hooks/logging_manager.py,sha256=UpdmpQ7HRPyer-jrmQSXcBwi409tV9LnGvXSHjTcYTI,7935
19
20
  tinyagent/hooks/rich_code_ui_callback.py,sha256=PLcu5MOSoP4oZR3BtvcV9DquxcIT_d0WzSlkvaDcGOk,19820
20
21
  tinyagent/hooks/rich_ui_callback.py,sha256=5iCNOiJmhc1lOL7ZjaOt5Sk3rompko4zu_pAxfTVgJQ,22897
@@ -25,8 +26,8 @@ tinyagent/storage/json_file_storage.py,sha256=SYD8lvTHu2-FEHm1tZmsrcgEOirBrlUsUM
25
26
  tinyagent/storage/postgres_storage.py,sha256=IGwan8UXHNnTZFK1F8x4kvMDex3GAAGWUg9ePx_5IF4,9018
26
27
  tinyagent/storage/redis_storage.py,sha256=hu3y7wHi49HkpiR-AW7cWVQuTVOUk1WaB8TEPGUKVJ8,1742
27
28
  tinyagent/storage/sqlite_storage.py,sha256=ZyOYe0d_oHO1wOIT8FxKIbc67tP_0e_8FnM2Zq8Pwj8,5915
28
- tinyagent_py-0.0.11.dist-info/licenses/LICENSE,sha256=YIogcVQnknaaE4K-oaQylFWo8JGRBWnwmGb3fWB_Pww,1064
29
- tinyagent_py-0.0.11.dist-info/METADATA,sha256=2TrsEfdxPBD7rdRXj4Z5pxrTynh1PlsqTGX4evRVKSE,13250
30
- tinyagent_py-0.0.11.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
- tinyagent_py-0.0.11.dist-info/top_level.txt,sha256=Ny8aJNchZpc2Vvhp3306L5vjceJakvFxBk-UjjVeA_I,10
32
- tinyagent_py-0.0.11.dist-info/RECORD,,
29
+ tinyagent_py-0.0.12.dist-info/licenses/LICENSE,sha256=YIogcVQnknaaE4K-oaQylFWo8JGRBWnwmGb3fWB_Pww,1064
30
+ tinyagent_py-0.0.12.dist-info/METADATA,sha256=Q05U9Z6UJNPoHtmAbpMzxB2HfcnQbGOHHz3W1EfgWt0,13250
31
+ tinyagent_py-0.0.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
32
+ tinyagent_py-0.0.12.dist-info/top_level.txt,sha256=Ny8aJNchZpc2Vvhp3306L5vjceJakvFxBk-UjjVeA_I,10
33
+ tinyagent_py-0.0.12.dist-info/RECORD,,