tinyagent-py 0.0.13__py3-none-any.whl → 0.0.16__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.
@@ -1,7 +1,13 @@
1
1
  import sys
2
2
  import cloudpickle
3
- from typing import Dict, Any, List
3
+ import subprocess
4
+ import os
5
+ from typing import Dict, Any, List, Tuple
4
6
  from .safety import validate_code_safety, function_safety_context
7
+ import shlex
8
+ import yaml
9
+ from pathlib import Path
10
+ import re
5
11
 
6
12
 
7
13
  def clean_response(resp: Dict[str, Any]) -> Dict[str, Any]:
@@ -17,6 +23,118 @@ def clean_response(resp: Dict[str, Any]) -> Dict[str, Any]:
17
23
  return {k: v for k, v in resp.items() if k in ['printed_output', 'return_value', 'stderr', 'error_traceback']}
18
24
 
19
25
 
26
+ def truncate_output(output: str, max_tokens: int = 3000, max_lines: int = 250) -> Tuple[str, bool, int, int]:
27
+ """
28
+ Truncate output based on token count and line count.
29
+
30
+ Args:
31
+ output: The output string to truncate
32
+ max_tokens: Maximum number of tokens to keep
33
+ max_lines: Maximum number of lines to keep
34
+
35
+ Returns:
36
+ Tuple containing:
37
+ - Truncated output
38
+ - Boolean indicating if truncation occurred
39
+ - Original token count
40
+ - Original line count
41
+ """
42
+ # Count original lines
43
+ lines = output.splitlines()
44
+ original_line_count = len(lines)
45
+
46
+ # Approximate token count (rough estimate: 4 chars ≈ 1 token)
47
+ original_token_count = len(output) // 4
48
+
49
+ # Check if truncation is needed
50
+ if original_line_count <= max_lines and original_token_count <= max_tokens:
51
+ return output, False, original_token_count, original_line_count
52
+
53
+ # Truncate by lines first
54
+ if original_line_count > max_lines:
55
+ lines = lines[:max_lines] # Keep only the first max_lines
56
+
57
+ # Join lines back together
58
+ truncated = '\n'.join(lines)
59
+
60
+ # If still too many tokens, truncate further
61
+ if len(truncated) // 4 > max_tokens:
62
+ # Keep the first max_tokens*4 characters (approximate)
63
+ truncated = truncated[:max_tokens*4]
64
+
65
+ # Try to start at a newline to avoid partial lines
66
+ newline_pos = truncated.find('\n')
67
+ if newline_pos > 0:
68
+ truncated = truncated[newline_pos+1:]
69
+
70
+ return truncated, True, original_token_count, original_line_count
71
+
72
+
73
+ def load_truncation_template(template_type: str = "python_output") -> str:
74
+ """
75
+ Load the truncation message template.
76
+
77
+ Args:
78
+ template_type: Type of template to load ("python_output" or "bash_output")
79
+
80
+ Returns:
81
+ Template string for the truncation message
82
+ """
83
+ template_path = Path(__file__).parent.parent / "prompts" / "truncation.yaml"
84
+
85
+ try:
86
+ with open(template_path, 'r') as f:
87
+ templates = yaml.safe_load(f)
88
+
89
+ return templates.get("truncation_messages", {}).get(template_type, {}).get("message",
90
+ "--- Output truncated due to size limitations ---")
91
+ except Exception:
92
+ # Fallback template if file can't be loaded
93
+ return "--- Output truncated due to size limitations ---"
94
+
95
+
96
+ def format_truncation_message(output: str, is_truncated: bool, original_tokens: int,
97
+ original_lines: int, max_lines: int, template_type: str = "python_output") -> str:
98
+ """
99
+ Format the truncated output with a truncation message if needed.
100
+
101
+ Args:
102
+ output: The truncated output
103
+ is_truncated: Whether truncation occurred
104
+ original_tokens: Original token count
105
+ original_lines: Original line count
106
+ max_lines: Maximum line count used for truncation
107
+ template_type: Type of template to use
108
+
109
+ Returns:
110
+ Formatted output with truncation message if needed
111
+ """
112
+ if not is_truncated:
113
+ return output
114
+
115
+ # Load the appropriate template
116
+ template = load_truncation_template(template_type)
117
+
118
+ # Determine size unit (tokens or KB)
119
+ if original_tokens > 1000:
120
+ size_value = original_tokens / 1000
121
+ size_unit = "K tokens"
122
+ else:
123
+ size_value = original_tokens
124
+ size_unit = "tokens"
125
+
126
+ # Format the message
127
+ message = template.format(
128
+ original_size=round(size_value, 1),
129
+ size_unit=size_unit,
130
+ original_lines=original_lines,
131
+ max_lines=max_lines
132
+ )
133
+
134
+ # Append the message to the output
135
+ return f"{output}\n\n{message}"
136
+
137
+
20
138
  def make_session_blob(ns: dict) -> bytes:
21
139
  """
22
140
  Create a serialized blob of the session namespace, excluding unserializable objects.
@@ -41,6 +159,146 @@ def make_session_blob(ns: dict) -> bytes:
41
159
  return cloudpickle.dumps(clean)
42
160
 
43
161
 
162
+ def _run_shell(
163
+ command: List[str],
164
+ timeout: int = 10,
165
+ workdir: str = None
166
+ ) -> Dict[str, Any]:
167
+ """
168
+ Execute a shell command securely with proper timeout and error handling.
169
+
170
+ Args:
171
+ command: List of command parts to execute
172
+ timeout: Maximum execution time in seconds
173
+ workdir: Working directory for command execution
174
+
175
+ Returns:
176
+ Dictionary containing execution results with keys:
177
+ - stdout: stdout from the execution
178
+ - stderr: stderr from the execution
179
+ - exit_code: exit code from the command
180
+ """
181
+ try:
182
+ # Set working directory if provided
183
+ cwd = os.path.expanduser(workdir) if workdir else None
184
+
185
+ # Check if this is a command that needs bash -c wrapping
186
+ if len(command) > 0:
187
+ # Special handling for bash login shells to avoid profile loading errors
188
+ if command[0] == "bash" and len(command) >= 3 and command[1] == "-lc":
189
+ # Create a clean environment that doesn't load user profile files
190
+ env = os.environ.copy()
191
+ env.update({
192
+ "BASH_ENV": "/dev/null",
193
+ "ENV": "/dev/null",
194
+ "BASH_PROFILE": "/dev/null",
195
+ "PROFILE": "/dev/null"
196
+ })
197
+ # Replace -lc with -c to avoid loading login profiles
198
+ modified_command = ["bash", "-c", command[2]]
199
+ process = subprocess.run(
200
+ modified_command,
201
+ shell=False,
202
+ capture_output=True,
203
+ text=True,
204
+ timeout=timeout,
205
+ cwd=cwd,
206
+ check=False,
207
+ env=env
208
+ )
209
+ # If the command already uses bash -c, use it directly
210
+ # This handles heredoc syntax and other complex shell constructs
211
+ elif command[0] == "bash" and len(command) >= 3 and command[1] == "-c":
212
+ process = subprocess.run(
213
+ command,
214
+ shell=False, # No need for shell=True as we're explicitly using bash -c
215
+ capture_output=True,
216
+ text=True,
217
+ timeout=timeout,
218
+ cwd=cwd,
219
+ check=False
220
+ )
221
+ # Special handling for interpreter commands with inline code execution flags
222
+ # This covers python -c, node -e, ruby -e, perl -e, etc.
223
+ elif len(command) >= 3 and command[0] in ["python", "node", "ruby", "perl", "php", "deno"] and command[1] in ["-c", "-e", "--eval", "--execute"]:
224
+ # Execute the interpreter command directly without shell wrapping
225
+ process = subprocess.run(
226
+ command,
227
+ shell=False,
228
+ capture_output=True,
229
+ text=True,
230
+ timeout=timeout,
231
+ cwd=cwd,
232
+ check=False
233
+ )
234
+ else:
235
+ # Check if the command contains heredoc syntax
236
+ command_str = " ".join(command)
237
+ if "<<" in command_str and any(f"<<'{token}'" in command_str or f'<<"{token}"' in command_str or f"<<{token}" in command_str for token in ["EOF", "EOL", "END", "HEREDOC", "PY", "JS", "RUBY", "PHP"]):
238
+ # For commands with heredoc, pass directly to bash -c without additional quoting
239
+ process = subprocess.run(
240
+ ["bash", "-c", command_str],
241
+ shell=False,
242
+ capture_output=True,
243
+ text=True,
244
+ timeout=timeout,
245
+ cwd=cwd,
246
+ check=False
247
+ )
248
+ else:
249
+ # For all other commands, wrap in bash -c to handle shell operators
250
+ # and properly quote arguments that need quoting
251
+
252
+ # Shell operators that should not be quoted
253
+ shell_operators = ['|', '&&', '||', '>', '<', '>>', '<<', ';']
254
+
255
+ # Quote each part that needs quoting
256
+ quoted_parts = []
257
+ for part in command:
258
+ if part in shell_operators:
259
+ # Don't quote shell operators
260
+ quoted_parts.append(part)
261
+ else:
262
+ # Use shlex.quote to properly escape special characters
263
+ quoted_parts.append(shlex.quote(part))
264
+
265
+ shell_command = " ".join(quoted_parts)
266
+ process = subprocess.run(
267
+ ["bash", "-c", shell_command],
268
+ shell=False, # Using explicit bash -c instead of shell=True
269
+ capture_output=True,
270
+ text=True,
271
+ timeout=timeout,
272
+ cwd=cwd,
273
+ check=False
274
+ )
275
+ else:
276
+ # Empty command
277
+ return {
278
+ "stdout": "",
279
+ "stderr": "Empty command",
280
+ "exit_code": 1
281
+ }
282
+
283
+ return {
284
+ "stdout": process.stdout,
285
+ "stderr": process.stderr,
286
+ "exit_code": process.returncode
287
+ }
288
+ except subprocess.TimeoutExpired:
289
+ return {
290
+ "stdout": "",
291
+ "stderr": f"Command timed out after {timeout} seconds",
292
+ "exit_code": 124 # Standard timeout exit code
293
+ }
294
+ except Exception as e:
295
+ return {
296
+ "stdout": "",
297
+ "stderr": f"Error executing command: {str(e)}",
298
+ "exit_code": 1
299
+ }
300
+
301
+
44
302
  def _run_python(
45
303
  code: str,
46
304
  globals_dict: Dict[str, Any] | None = None,
@@ -48,6 +306,7 @@ def _run_python(
48
306
  authorized_imports: List[str] | None = None,
49
307
  authorized_functions: List[str] | None = None,
50
308
  trusted_code: bool = False,
309
+ check_string_obfuscation: bool = True,
51
310
  ):
52
311
  """
53
312
  Execute Python code in a controlled environment with proper error handling.
@@ -59,6 +318,7 @@ def _run_python(
59
318
  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
319
  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
320
  trusted_code: If True, skip security checks. Should only be used for framework code, tools, or default executed code.
321
+ check_string_obfuscation: If True (default), check for string obfuscation techniques. Set to False to allow legitimate use of base64 encoding and other string manipulations.
62
322
 
63
323
  Returns:
64
324
  Dictionary containing execution results
@@ -74,7 +334,8 @@ def _run_python(
74
334
  # 1. Static safety analysis – refuse code containing dangerous imports or functions
75
335
  # ------------------------------------------------------------------
76
336
  validate_code_safety(code, authorized_imports=authorized_imports,
77
- authorized_functions=authorized_functions, trusted_code=trusted_code)
337
+ authorized_functions=authorized_functions, trusted_code=trusted_code,
338
+ check_string_obfuscation=check_string_obfuscation)
78
339
 
79
340
  # Make copies to avoid mutating the original parameters
80
341
  globals_dict = globals_dict or {}
@@ -2,4 +2,6 @@
2
2
  from .rich_ui_callback import RichUICallback
3
3
  from .rich_code_ui_callback import RichCodeUICallback
4
4
  from .logging_manager import LoggingManager
5
- __all__ = ["RichUICallback", "RichCodeUICallback", "LoggingManager"]
5
+ from .token_tracker import TokenTracker, UsageStats, create_token_tracker
6
+
7
+ __all__ = ["RichUICallback", "RichCodeUICallback", "LoggingManager", "TokenTracker", "UsageStats", "create_token_tracker"]