tinyagent-py 0.0.12__py3-none-any.whl → 0.0.15__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.
- tinyagent/code_agent/helper.py +2 -2
- tinyagent/code_agent/modal_sandbox.py +1 -1
- tinyagent/code_agent/providers/base.py +212 -11
- tinyagent/code_agent/providers/modal_provider.py +157 -32
- tinyagent/code_agent/safety.py +6 -2
- tinyagent/code_agent/tiny_code_agent.py +317 -11
- tinyagent/code_agent/utils.py +129 -9
- tinyagent/hooks/__init__.py +3 -1
- tinyagent/hooks/gradio_callback.py +3 -2
- tinyagent/hooks/jupyter_notebook_callback.py +1464 -0
- tinyagent/hooks/token_tracker.py +564 -0
- tinyagent/prompts/summarize.yaml +96 -0
- tinyagent/tiny_agent.py +426 -17
- {tinyagent_py-0.0.12.dist-info → tinyagent_py-0.0.15.dist-info}/METADATA +11 -1
- {tinyagent_py-0.0.12.dist-info → tinyagent_py-0.0.15.dist-info}/RECORD +18 -15
- {tinyagent_py-0.0.12.dist-info → tinyagent_py-0.0.15.dist-info}/WHEEL +0 -0
- {tinyagent_py-0.0.12.dist-info → tinyagent_py-0.0.15.dist-info}/licenses/LICENSE +0 -0
- {tinyagent_py-0.0.12.dist-info → tinyagent_py-0.0.15.dist-info}/top_level.txt +0 -0
@@ -1,14 +1,26 @@
|
|
1
1
|
import traceback
|
2
|
+
import os
|
3
|
+
import json
|
2
4
|
from textwrap import dedent
|
3
5
|
from typing import Optional, List, Dict, Any
|
4
6
|
from pathlib import Path
|
5
7
|
from tinyagent import TinyAgent, tool
|
6
8
|
from tinyagent.hooks.logging_manager import LoggingManager
|
9
|
+
from tinyagent.hooks.rich_code_ui_callback import RichCodeUICallback
|
10
|
+
from tinyagent.hooks.jupyter_notebook_callback import JupyterNotebookCallback
|
7
11
|
from .providers.base import CodeExecutionProvider
|
8
12
|
from .providers.modal_provider import ModalProvider
|
9
13
|
from .helper import translate_tool_for_code_agent, load_template, render_system_prompt, prompt_code_example, prompt_qwen_helper
|
10
14
|
|
11
15
|
|
16
|
+
DEFAULT_SUMMARY_SYSTEM_PROMPT = (
|
17
|
+
"You are an expert coding assistant. Your goal is to generate a concise, structured summary "
|
18
|
+
"of the conversation below that captures all essential information needed to continue "
|
19
|
+
"development after context replacement. Include tasks performed, code areas modified or "
|
20
|
+
"reviewed, key decisions or assumptions, test results or errors, and outstanding tasks or next steps."
|
21
|
+
|
22
|
+
)
|
23
|
+
|
12
24
|
class TinyCodeAgent:
|
13
25
|
"""
|
14
26
|
A TinyAgent specialized for code execution tasks.
|
@@ -27,10 +39,15 @@ class TinyCodeAgent:
|
|
27
39
|
code_tools: Optional[List[Any]] = None,
|
28
40
|
authorized_imports: Optional[List[str]] = None,
|
29
41
|
system_prompt_template: Optional[str] = None,
|
42
|
+
system_prompt: Optional[str] = None,
|
30
43
|
provider_config: Optional[Dict[str, Any]] = None,
|
31
44
|
user_variables: Optional[Dict[str, Any]] = None,
|
32
45
|
pip_packages: Optional[List[str]] = None,
|
33
46
|
local_execution: bool = False,
|
47
|
+
check_string_obfuscation: bool = True,
|
48
|
+
default_workdir: Optional[str] = None,
|
49
|
+
summary_config: Optional[Dict[str, Any]] = None,
|
50
|
+
ui: Optional[str] = None,
|
34
51
|
**agent_kwargs
|
35
52
|
):
|
36
53
|
"""
|
@@ -50,6 +67,11 @@ class TinyCodeAgent:
|
|
50
67
|
pip_packages: List of additional Python packages to install in Modal environment
|
51
68
|
local_execution: If True, uses Modal's .local() method for local execution.
|
52
69
|
If False, uses Modal's .remote() method for cloud execution (default: False)
|
70
|
+
check_string_obfuscation: If True (default), check for string obfuscation techniques. Set to False to allow
|
71
|
+
legitimate use of base64 encoding and other string manipulations.
|
72
|
+
default_workdir: Default working directory for shell commands. If None, the current working directory is used.
|
73
|
+
summary_config: Optional configuration for generating conversation summaries
|
74
|
+
ui: The user interface callback to use ('rich', 'jupyter', or None).
|
53
75
|
**agent_kwargs: Additional arguments passed to TinyAgent
|
54
76
|
"""
|
55
77
|
self.model = model
|
@@ -63,6 +85,8 @@ class TinyCodeAgent:
|
|
63
85
|
self.pip_packages = pip_packages or []
|
64
86
|
self.local_execution = local_execution
|
65
87
|
self.provider = provider # Store provider type for reuse
|
88
|
+
self.check_string_obfuscation = check_string_obfuscation
|
89
|
+
self.default_workdir = default_workdir or os.getcwd() # Default to current working directory if not specified
|
66
90
|
|
67
91
|
# Create the code execution provider
|
68
92
|
self.code_provider = self._create_provider(provider, self.provider_config)
|
@@ -72,23 +96,32 @@ class TinyCodeAgent:
|
|
72
96
|
self.code_provider.set_user_variables(self.user_variables)
|
73
97
|
|
74
98
|
# Build system prompt
|
75
|
-
self.
|
99
|
+
self.static_system_prompt= system_prompt
|
100
|
+
self.system_prompt = self._build_system_prompt(system_prompt_template)
|
101
|
+
|
76
102
|
|
77
|
-
|
103
|
+
self.summary_config = summary_config or {}
|
104
|
+
|
105
|
+
# Create the underlying TinyAgent with summary configuration
|
78
106
|
self.agent = TinyAgent(
|
79
107
|
model=model,
|
80
108
|
api_key=api_key,
|
81
109
|
system_prompt=self.system_prompt,
|
82
110
|
logger=log_manager.get_logger('tinyagent.tiny_agent') if log_manager else None,
|
111
|
+
summary_config=summary_config,
|
83
112
|
**agent_kwargs
|
84
113
|
)
|
85
114
|
|
86
|
-
# Add the code execution
|
87
|
-
self.
|
115
|
+
# Add the code execution tools
|
116
|
+
self._setup_code_execution_tools()
|
88
117
|
|
89
118
|
# Add LLM tools (not code tools - those go to the provider)
|
90
119
|
if self.tools:
|
91
120
|
self.agent.add_tools(self.tools)
|
121
|
+
|
122
|
+
# Add the selected UI callback
|
123
|
+
if ui:
|
124
|
+
self.add_ui_callback(ui)
|
92
125
|
|
93
126
|
def _create_provider(self, provider_type: str, config: Dict[str, Any]) -> CodeExecutionProvider:
|
94
127
|
"""Create a code execution provider based on the specified type."""
|
@@ -104,6 +137,7 @@ class TinyCodeAgent:
|
|
104
137
|
final_config = config.copy()
|
105
138
|
final_config["pip_packages"] = final_pip_packages
|
106
139
|
final_config["authorized_imports"] = final_authorized_imports
|
140
|
+
final_config["check_string_obfuscation"] = self.check_string_obfuscation
|
107
141
|
|
108
142
|
return ModalProvider(
|
109
143
|
log_manager=self.log_manager,
|
@@ -117,7 +151,9 @@ class TinyCodeAgent:
|
|
117
151
|
def _build_system_prompt(self, template_path: Optional[str] = None) -> str:
|
118
152
|
"""Build the system prompt for the code agent."""
|
119
153
|
# Use default template if none provided
|
120
|
-
if
|
154
|
+
if self.static_system_prompt is not None:
|
155
|
+
return self.static_system_prompt
|
156
|
+
elif template_path is None :
|
121
157
|
template_path = str(Path(__file__).parent.parent / "prompts" / "code_agent.yaml")
|
122
158
|
|
123
159
|
# Translate code tools to code agent format
|
@@ -242,8 +278,8 @@ class TinyCodeAgent:
|
|
242
278
|
|
243
279
|
return "\n".join(code_tools_lines)
|
244
280
|
|
245
|
-
def
|
246
|
-
"""Set up the
|
281
|
+
def _setup_code_execution_tools(self):
|
282
|
+
"""Set up the code execution tools using the code provider."""
|
247
283
|
@tool(name="run_python", description=dedent("""
|
248
284
|
This tool receives Python code and executes it in a sandboxed environment.
|
249
285
|
During each intermediate step, you can use 'print()' to save important information.
|
@@ -261,8 +297,17 @@ class TinyCodeAgent:
|
|
261
297
|
async def run_python(code_lines: List[str], timeout: int = 120) -> str:
|
262
298
|
"""Execute Python code using the configured provider."""
|
263
299
|
try:
|
300
|
+
# Before execution, ensure provider has the latest user variables
|
301
|
+
if self.user_variables:
|
302
|
+
self.code_provider.set_user_variables(self.user_variables)
|
303
|
+
|
264
304
|
result = await self.code_provider.execute_python(code_lines, timeout)
|
265
|
-
|
305
|
+
|
306
|
+
# After execution, update TinyCodeAgent's user_variables from the provider
|
307
|
+
# This ensures they stay in sync
|
308
|
+
self.user_variables = self.code_provider.get_user_variables()
|
309
|
+
|
310
|
+
return json.dumps(result)
|
266
311
|
except Exception as e:
|
267
312
|
print("!"*100)
|
268
313
|
COLOR = {
|
@@ -272,9 +317,133 @@ class TinyCodeAgent:
|
|
272
317
|
print(f"{COLOR['RED']}{str(e)}{COLOR['ENDC']}")
|
273
318
|
print(f"{COLOR['RED']}{traceback.format_exc()}{COLOR['ENDC']}")
|
274
319
|
print("!"*100)
|
275
|
-
|
320
|
+
|
321
|
+
# Even after an exception, update user_variables from the provider
|
322
|
+
# This ensures any variables that were successfully created/modified are preserved
|
323
|
+
self.user_variables = self.code_provider.get_user_variables()
|
324
|
+
|
325
|
+
return json.dumps({"error": f"Error executing code: {str(e)}"})
|
326
|
+
|
327
|
+
@tool(name="bash", description=dedent("""
|
328
|
+
This tool executes shell commands securely in a sandboxed environment.
|
329
|
+
Only a limited set of safe commands are allowed for security reasons.
|
330
|
+
Before executing the command, please follow these steps:
|
331
|
+
|
332
|
+
1. Directory Verification:
|
333
|
+
- If the command will create new directories or files, first use ls to verify the parent directory exists and is the correct location
|
334
|
+
- For example, before running "mkdir foo/bar", first use ls to check that "foo" exists and is the intended parent directory
|
335
|
+
|
336
|
+
2. Command Execution:
|
337
|
+
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
|
338
|
+
- Examples of proper quoting:
|
339
|
+
- cd "/Users/name/My Documents" (correct)
|
340
|
+
- cd /Users/name/My Documents (incorrect - will fail)
|
341
|
+
- python "/path/with spaces/script.py" (correct)
|
342
|
+
- python /path/with spaces/script.py (incorrect - will fail)
|
343
|
+
- After ensuring proper quoting, execute the command.
|
344
|
+
- Capture the output of the command.
|
345
|
+
|
346
|
+
Usage notes:
|
347
|
+
- The command argument is required.
|
348
|
+
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance.
|
349
|
+
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
|
350
|
+
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
351
|
+
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
|
352
|
+
|
353
|
+
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` first, which all ${PRODUCT_NAME} users have pre-installed.
|
354
|
+
" - When issuing multiple commands, use the ; or && operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).\n" +
|
355
|
+
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
|
356
|
+
<good-example>
|
357
|
+
pytest /foo/bar/tests
|
358
|
+
</good-example>
|
359
|
+
<bad-example>
|
360
|
+
cd /foo/bar && pytest tests
|
361
|
+
</bad-example>
|
362
|
+
|
363
|
+
Args:
|
364
|
+
command: list[str]: The shell command to execute as a list of strings. Example: ["ls", "-la"] or ["cat", "file.txt"]
|
365
|
+
|
366
|
+
absolute_workdir: str: could be presented workdir in the system prompt or one of the subdirectories of the workdir. This is the only allowed path, and accessing else will result in an error.
|
367
|
+
description: str: A clear, concise description of what this command does in 5-10 words.
|
368
|
+
timeout: int: Maximum execution time in seconds (default: 30).
|
369
|
+
Returns:
|
370
|
+
Dictionary with stdout, stderr, and exit_code from the command execution.
|
371
|
+
If the command is rejected for security reasons, stderr will contain the reason.
|
372
|
+
The stdout will include information about which working directory was used.
|
373
|
+
"""))
|
374
|
+
async def run_shell(command: List[str], absolute_workdir: str, description: str, timeout: int = 30) -> str:
|
375
|
+
"""Execute shell commands securely using the configured provider."""
|
376
|
+
try:
|
377
|
+
# Use the default working directory if none is specified
|
378
|
+
effective_workdir = absolute_workdir or self.default_workdir
|
379
|
+
print(f" {command} to {description}")
|
380
|
+
# Verify that the working directory exists
|
381
|
+
if effective_workdir and not os.path.exists(effective_workdir):
|
382
|
+
return json.dumps({
|
383
|
+
"stdout": "",
|
384
|
+
"stderr": f"Working directory does not exist: {effective_workdir}",
|
385
|
+
"exit_code": 1
|
386
|
+
})
|
387
|
+
|
388
|
+
if effective_workdir and not os.path.isdir(effective_workdir):
|
389
|
+
return json.dumps({
|
390
|
+
"stdout": "",
|
391
|
+
"stderr": f"Path is not a directory: {effective_workdir}",
|
392
|
+
"exit_code": 1
|
393
|
+
})
|
394
|
+
|
395
|
+
result = await self.code_provider.execute_shell(command, timeout, effective_workdir)
|
396
|
+
return json.dumps(result)
|
397
|
+
except Exception as e:
|
398
|
+
COLOR = {
|
399
|
+
"RED": "\033[91m",
|
400
|
+
"ENDC": "\033[0m",
|
401
|
+
}
|
402
|
+
print(f"{COLOR['RED']}{str(e)}{COLOR['ENDC']}")
|
403
|
+
print(f"{COLOR['RED']}{traceback.format_exc()}{COLOR['ENDC']}")
|
404
|
+
|
405
|
+
return json.dumps({"error": f"Error executing shell command: {str(e)}"})
|
276
406
|
|
277
407
|
self.agent.add_tool(run_python)
|
408
|
+
self.agent.add_tool(run_shell)
|
409
|
+
|
410
|
+
def set_default_workdir(self, workdir: str, create_if_not_exists: bool = False):
|
411
|
+
"""
|
412
|
+
Set the default working directory for shell commands.
|
413
|
+
|
414
|
+
Args:
|
415
|
+
workdir: The path to use as the default working directory
|
416
|
+
create_if_not_exists: If True, create the directory if it doesn't exist
|
417
|
+
|
418
|
+
Raises:
|
419
|
+
ValueError: If the directory doesn't exist and create_if_not_exists is False
|
420
|
+
OSError: If there's an error creating the directory
|
421
|
+
"""
|
422
|
+
workdir = os.path.expanduser(workdir) # Expand user directory if needed
|
423
|
+
|
424
|
+
if not os.path.exists(workdir):
|
425
|
+
if create_if_not_exists:
|
426
|
+
try:
|
427
|
+
os.makedirs(workdir, exist_ok=True)
|
428
|
+
print(f"Created directory: {workdir}")
|
429
|
+
except OSError as e:
|
430
|
+
raise OSError(f"Failed to create directory {workdir}: {str(e)}")
|
431
|
+
else:
|
432
|
+
raise ValueError(f"Directory does not exist: {workdir}")
|
433
|
+
|
434
|
+
if not os.path.isdir(workdir):
|
435
|
+
raise ValueError(f"Path is not a directory: {workdir}")
|
436
|
+
|
437
|
+
self.default_workdir = workdir
|
438
|
+
|
439
|
+
def get_default_workdir(self) -> str:
|
440
|
+
"""
|
441
|
+
Get the current default working directory for shell commands.
|
442
|
+
|
443
|
+
Returns:
|
444
|
+
The current default working directory path
|
445
|
+
"""
|
446
|
+
return self.default_workdir
|
278
447
|
|
279
448
|
async def run(self, user_input: str, max_turns: int = 10) -> str:
|
280
449
|
"""
|
@@ -289,6 +458,22 @@ class TinyCodeAgent:
|
|
289
458
|
"""
|
290
459
|
return await self.agent.run(user_input, max_turns)
|
291
460
|
|
461
|
+
async def resume(self, max_turns: int = 10) -> str:
|
462
|
+
"""
|
463
|
+
Resume the conversation without adding a new user message.
|
464
|
+
|
465
|
+
This method continues the conversation from the current state,
|
466
|
+
allowing the agent to process the existing conversation history
|
467
|
+
and potentially take additional actions.
|
468
|
+
|
469
|
+
Args:
|
470
|
+
max_turns: Maximum number of conversation turns
|
471
|
+
|
472
|
+
Returns:
|
473
|
+
The agent's response
|
474
|
+
"""
|
475
|
+
return await self.agent.resume(max_turns)
|
476
|
+
|
292
477
|
async def connect_to_server(self, command: str, args: List[str], **kwargs):
|
293
478
|
"""Connect to an MCP server."""
|
294
479
|
return await self.agent.connect_to_server(command, args, **kwargs)
|
@@ -537,6 +722,78 @@ class TinyCodeAgent:
|
|
537
722
|
"""Get the session ID."""
|
538
723
|
return self.agent.session_id
|
539
724
|
|
725
|
+
def set_check_string_obfuscation(self, enabled: bool):
|
726
|
+
"""
|
727
|
+
Enable or disable string obfuscation detection.
|
728
|
+
|
729
|
+
Args:
|
730
|
+
enabled: If True, check for string obfuscation techniques. If False, allow
|
731
|
+
legitimate use of base64 encoding and other string manipulations.
|
732
|
+
"""
|
733
|
+
self.check_string_obfuscation = enabled
|
734
|
+
|
735
|
+
# Update the provider with the new setting
|
736
|
+
if hasattr(self.code_provider, 'check_string_obfuscation'):
|
737
|
+
self.code_provider.check_string_obfuscation = enabled
|
738
|
+
|
739
|
+
async def summarize(self) -> str:
|
740
|
+
"""
|
741
|
+
Generate a summary of the current conversation history.
|
742
|
+
|
743
|
+
Args:
|
744
|
+
Returns:
|
745
|
+
A string containing the conversation summary
|
746
|
+
"""
|
747
|
+
# Use the underlying TinyAgent's summarize_conversation method
|
748
|
+
return await self.agent.summarize()
|
749
|
+
|
750
|
+
async def compact(self) -> bool:
|
751
|
+
"""
|
752
|
+
Compact the conversation history by replacing it with a summary.
|
753
|
+
|
754
|
+
This method delegates to the underlying TinyAgent's compact method,
|
755
|
+
which:
|
756
|
+
1. Generates a summary of the current conversation
|
757
|
+
2. If successful, replaces the conversation with just [system, user] messages
|
758
|
+
where the user message contains the summary
|
759
|
+
3. Returns True if compaction was successful, False otherwise
|
760
|
+
|
761
|
+
Returns:
|
762
|
+
Boolean indicating whether the compaction was successful
|
763
|
+
"""
|
764
|
+
return await self.agent.compact()
|
765
|
+
|
766
|
+
def add_ui_callback(self, ui_type: str, optimized: bool = True):
|
767
|
+
"""
|
768
|
+
Adds a UI callback to the agent based on the type.
|
769
|
+
|
770
|
+
Args:
|
771
|
+
ui_type: The type of UI callback ('rich' or 'jupyter')
|
772
|
+
optimized: Whether to use the optimized version (default: True for better performance)
|
773
|
+
"""
|
774
|
+
if ui_type == 'rich':
|
775
|
+
ui_callback = RichCodeUICallback(
|
776
|
+
logger=self.log_manager.get_logger('tinyagent.hooks.rich_code_ui_callback') if self.log_manager else None
|
777
|
+
)
|
778
|
+
self.add_callback(ui_callback)
|
779
|
+
elif ui_type == 'jupyter':
|
780
|
+
if optimized:
|
781
|
+
from tinyagent.hooks.jupyter_notebook_callback import OptimizedJupyterNotebookCallback
|
782
|
+
ui_callback = OptimizedJupyterNotebookCallback(
|
783
|
+
logger=self.log_manager.get_logger('tinyagent.hooks.jupyter_notebook_callback') if self.log_manager else None,
|
784
|
+
max_visible_turns=20, # Limit visible turns for performance
|
785
|
+
max_content_length=100000, # Limit total content
|
786
|
+
enable_markdown=True, # Keep markdown but optimized
|
787
|
+
show_raw_responses=False # Show formatted responses
|
788
|
+
)
|
789
|
+
else:
|
790
|
+
ui_callback = JupyterNotebookCallback(
|
791
|
+
logger=self.log_manager.get_logger('tinyagent.hooks.jupyter_notebook_callback') if self.log_manager else None
|
792
|
+
)
|
793
|
+
self.add_callback(ui_callback)
|
794
|
+
else:
|
795
|
+
self.log_manager.get_logger(__name__).warning(f"Unknown UI type: {ui_type}. No UI callback will be added.")
|
796
|
+
|
540
797
|
|
541
798
|
# Example usage demonstrating both LLM tools and code tools
|
542
799
|
async def run_example():
|
@@ -548,6 +805,7 @@ async def run_example():
|
|
548
805
|
Code tools: Available in the Python execution environment
|
549
806
|
"""
|
550
807
|
from tinyagent import tool
|
808
|
+
import os
|
551
809
|
|
552
810
|
# Example LLM tool - available to the LLM for direct calling
|
553
811
|
@tool(name="search_web", description="Search the web for information")
|
@@ -576,7 +834,9 @@ async def run_example():
|
|
576
834
|
"sample_data": [1, 2, 3, 4, 5, 10, 15, 20]
|
577
835
|
},
|
578
836
|
authorized_imports=["tinyagent", "gradio", "requests", "numpy", "pandas"], # Explicitly specify authorized imports
|
579
|
-
local_execution=False # Remote execution via Modal (default)
|
837
|
+
local_execution=False, # Remote execution via Modal (default)
|
838
|
+
check_string_obfuscation=True,
|
839
|
+
default_workdir=os.path.join(os.getcwd(), "examples") # Set a default working directory for shell commands
|
580
840
|
)
|
581
841
|
|
582
842
|
# Connect to MCP servers
|
@@ -593,6 +853,13 @@ async def run_example():
|
|
593
853
|
print(response_remote)
|
594
854
|
print("\n" + "="*80 + "\n")
|
595
855
|
|
856
|
+
# Test the resume functionality
|
857
|
+
print("🔄 Testing resume functionality (continuing without new user input)")
|
858
|
+
resume_response = await agent_remote.resume(max_turns=3)
|
859
|
+
print("Resume Response:")
|
860
|
+
print(resume_response)
|
861
|
+
print("\n" + "="*80 + "\n")
|
862
|
+
|
596
863
|
# Now test with local execution
|
597
864
|
print("🏠 Testing TinyCodeAgent with LOCAL execution")
|
598
865
|
agent_local = TinyCodeAgent(
|
@@ -603,7 +870,8 @@ async def run_example():
|
|
603
870
|
"sample_data": [1, 2, 3, 4, 5, 10, 15, 20]
|
604
871
|
},
|
605
872
|
authorized_imports=["tinyagent", "gradio", "requests"], # More restricted imports for local execution
|
606
|
-
local_execution=True # Local execution
|
873
|
+
local_execution=True, # Local execution
|
874
|
+
check_string_obfuscation=True
|
607
875
|
)
|
608
876
|
|
609
877
|
# Connect to MCP servers
|
@@ -655,6 +923,44 @@ async def run_example():
|
|
655
923
|
print("Local Agent Validation Response:")
|
656
924
|
print(response2_local)
|
657
925
|
|
926
|
+
# Test shell execution
|
927
|
+
print("\n" + "="*80)
|
928
|
+
print("🐚 Testing shell execution")
|
929
|
+
|
930
|
+
shell_prompt = "Run 'ls -la' to list files in the current directory."
|
931
|
+
|
932
|
+
response_shell = await agent_remote.run(shell_prompt)
|
933
|
+
print("Shell Execution Response:")
|
934
|
+
print(response_shell)
|
935
|
+
|
936
|
+
# Test default working directory functionality
|
937
|
+
print("\n" + "="*80)
|
938
|
+
print("🏠 Testing default working directory functionality")
|
939
|
+
|
940
|
+
# Set a custom default working directory
|
941
|
+
custom_dir = os.path.expanduser("~") # Use home directory as an example
|
942
|
+
agent_remote.set_default_workdir(custom_dir)
|
943
|
+
print(f"Set default working directory to: {custom_dir}")
|
944
|
+
|
945
|
+
# Create a new directory for testing
|
946
|
+
test_dir = os.path.join(os.getcwd(), "test_workdir")
|
947
|
+
print(f"Setting default working directory with auto-creation: {test_dir}")
|
948
|
+
agent_remote.set_default_workdir(test_dir, create_if_not_exists=True)
|
949
|
+
|
950
|
+
# Run shell command without specifying workdir - should use the default
|
951
|
+
shell_prompt_default_dir = "Run 'pwd' to show the current working directory."
|
952
|
+
|
953
|
+
response_shell_default = await agent_remote.run(shell_prompt_default_dir)
|
954
|
+
print("Shell Execution with Default Working Directory:")
|
955
|
+
print(response_shell_default)
|
956
|
+
|
957
|
+
# Run shell command with explicit workdir - should override the default
|
958
|
+
shell_prompt_explicit_dir = "Run 'pwd' in the /tmp directory."
|
959
|
+
|
960
|
+
response_shell_explicit = await agent_remote.run(shell_prompt_explicit_dir)
|
961
|
+
print("Shell Execution with Explicit Working Directory:")
|
962
|
+
print(response_shell_explicit)
|
963
|
+
|
658
964
|
await agent_remote.close()
|
659
965
|
await agent_local.close()
|
660
966
|
|
tinyagent/code_agent/utils.py
CHANGED
@@ -1,7 +1,10 @@
|
|
1
1
|
import sys
|
2
2
|
import cloudpickle
|
3
|
+
import subprocess
|
4
|
+
import os
|
3
5
|
from typing import Dict, Any, List
|
4
6
|
from .safety import validate_code_safety, function_safety_context
|
7
|
+
import shlex
|
5
8
|
|
6
9
|
|
7
10
|
def clean_response(resp: Dict[str, Any]) -> Dict[str, Any]:
|
@@ -41,6 +44,96 @@ def make_session_blob(ns: dict) -> bytes:
|
|
41
44
|
return cloudpickle.dumps(clean)
|
42
45
|
|
43
46
|
|
47
|
+
def _run_shell(
|
48
|
+
command: List[str],
|
49
|
+
timeout: int = 10,
|
50
|
+
workdir: str = None
|
51
|
+
) -> Dict[str, Any]:
|
52
|
+
"""
|
53
|
+
Execute a shell command securely with proper timeout and error handling.
|
54
|
+
|
55
|
+
Args:
|
56
|
+
command: List of command parts to execute
|
57
|
+
timeout: Maximum execution time in seconds
|
58
|
+
workdir: Working directory for command execution
|
59
|
+
|
60
|
+
Returns:
|
61
|
+
Dictionary containing execution results with keys:
|
62
|
+
- stdout: stdout from the execution
|
63
|
+
- stderr: stderr from the execution
|
64
|
+
- exit_code: exit code from the command
|
65
|
+
"""
|
66
|
+
try:
|
67
|
+
# Set working directory if provided
|
68
|
+
cwd = os.path.expanduser(workdir) if workdir else None
|
69
|
+
|
70
|
+
# Check if this is a command that needs bash -c wrapping
|
71
|
+
if len(command) > 0:
|
72
|
+
# If the command already uses bash -c, use it directly
|
73
|
+
if command[0] == "bash" and len(command) >= 3 and command[1] in ["-c", "-lc"]:
|
74
|
+
process = subprocess.run(
|
75
|
+
command,
|
76
|
+
shell=False, # No need for shell=True as we're explicitly using bash -c
|
77
|
+
capture_output=True,
|
78
|
+
text=True,
|
79
|
+
timeout=timeout,
|
80
|
+
cwd=cwd,
|
81
|
+
check=False
|
82
|
+
)
|
83
|
+
else:
|
84
|
+
# For all other commands, wrap in bash -c to handle shell operators
|
85
|
+
# and properly quote arguments that need quoting
|
86
|
+
|
87
|
+
# Shell operators that should not be quoted
|
88
|
+
shell_operators = ['|', '&&', '||', '>', '<', '>>', '<<', ';']
|
89
|
+
|
90
|
+
# Quote each part that needs quoting
|
91
|
+
quoted_parts = []
|
92
|
+
for part in command:
|
93
|
+
if part in shell_operators:
|
94
|
+
# Don't quote shell operators
|
95
|
+
quoted_parts.append(part)
|
96
|
+
else:
|
97
|
+
# Use shlex.quote to properly escape special characters
|
98
|
+
quoted_parts.append(shlex.quote(part))
|
99
|
+
|
100
|
+
shell_command = " ".join(quoted_parts)
|
101
|
+
process = subprocess.run(
|
102
|
+
["bash", "-c", shell_command],
|
103
|
+
shell=False, # Using explicit bash -c instead of shell=True
|
104
|
+
capture_output=True,
|
105
|
+
text=True,
|
106
|
+
timeout=timeout,
|
107
|
+
cwd=cwd,
|
108
|
+
check=False
|
109
|
+
)
|
110
|
+
else:
|
111
|
+
# Empty command
|
112
|
+
return {
|
113
|
+
"stdout": "",
|
114
|
+
"stderr": "Empty command",
|
115
|
+
"exit_code": 1
|
116
|
+
}
|
117
|
+
|
118
|
+
return {
|
119
|
+
"stdout": process.stdout,
|
120
|
+
"stderr": process.stderr,
|
121
|
+
"exit_code": process.returncode
|
122
|
+
}
|
123
|
+
except subprocess.TimeoutExpired:
|
124
|
+
return {
|
125
|
+
"stdout": "",
|
126
|
+
"stderr": f"Command timed out after {timeout} seconds",
|
127
|
+
"exit_code": 124 # Standard timeout exit code
|
128
|
+
}
|
129
|
+
except Exception as e:
|
130
|
+
return {
|
131
|
+
"stdout": "",
|
132
|
+
"stderr": f"Error executing command: {str(e)}",
|
133
|
+
"exit_code": 1
|
134
|
+
}
|
135
|
+
|
136
|
+
|
44
137
|
def _run_python(
|
45
138
|
code: str,
|
46
139
|
globals_dict: Dict[str, Any] | None = None,
|
@@ -48,6 +141,7 @@ def _run_python(
|
|
48
141
|
authorized_imports: List[str] | None = None,
|
49
142
|
authorized_functions: List[str] | None = None,
|
50
143
|
trusted_code: bool = False,
|
144
|
+
check_string_obfuscation: bool = True,
|
51
145
|
):
|
52
146
|
"""
|
53
147
|
Execute Python code in a controlled environment with proper error handling.
|
@@ -59,6 +153,7 @@ def _run_python(
|
|
59
153
|
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
154
|
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
155
|
trusted_code: If True, skip security checks. Should only be used for framework code, tools, or default executed code.
|
156
|
+
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
157
|
|
63
158
|
Returns:
|
64
159
|
Dictionary containing execution results
|
@@ -74,7 +169,8 @@ def _run_python(
|
|
74
169
|
# 1. Static safety analysis – refuse code containing dangerous imports or functions
|
75
170
|
# ------------------------------------------------------------------
|
76
171
|
validate_code_safety(code, authorized_imports=authorized_imports,
|
77
|
-
authorized_functions=authorized_functions, trusted_code=trusted_code
|
172
|
+
authorized_functions=authorized_functions, trusted_code=trusted_code,
|
173
|
+
check_string_obfuscation=check_string_obfuscation)
|
78
174
|
|
79
175
|
# Make copies to avoid mutating the original parameters
|
80
176
|
globals_dict = globals_dict or {}
|
@@ -116,22 +212,34 @@ def _run_python(
|
|
116
212
|
#updated_globals['print'] = custom_print
|
117
213
|
|
118
214
|
# Parse the code
|
119
|
-
|
120
|
-
|
215
|
+
try:
|
216
|
+
tree = ast.parse(code, mode="exec")
|
217
|
+
compiled = compile(tree, filename="<ast>", mode="exec")
|
218
|
+
except SyntaxError as e:
|
219
|
+
# Return syntax error without executing
|
220
|
+
return {
|
221
|
+
"printed_output": "",
|
222
|
+
"return_value": None,
|
223
|
+
"stderr": "",
|
224
|
+
"error_traceback": f"Syntax error: {str(e)}",
|
225
|
+
"updated_globals": updated_globals,
|
226
|
+
"updated_locals": updated_locals
|
227
|
+
}
|
228
|
+
|
121
229
|
stdout_buf = io.StringIO()
|
122
230
|
stderr_buf = io.StringIO()
|
123
231
|
# Execute with exception handling
|
124
232
|
error_traceback = None
|
125
233
|
output = None
|
126
234
|
|
235
|
+
# Merge all variables into globals to avoid scoping issues with generator expressions
|
236
|
+
# When exec() is called with both globals and locals, generator expressions can't
|
237
|
+
# access local variables. By using only globals, everything runs in global scope.
|
238
|
+
merged_globals = updated_globals.copy()
|
239
|
+
merged_globals.update(updated_locals)
|
240
|
+
|
127
241
|
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
|
128
242
|
try:
|
129
|
-
# Merge all variables into globals to avoid scoping issues with generator expressions
|
130
|
-
# When exec() is called with both globals and locals, generator expressions can't
|
131
|
-
# access local variables. By using only globals, everything runs in global scope.
|
132
|
-
merged_globals = updated_globals.copy()
|
133
|
-
merged_globals.update(updated_locals)
|
134
|
-
|
135
243
|
# Add 'exec' to authorized_functions for internal use
|
136
244
|
internal_authorized_functions = ['exec','eval']
|
137
245
|
if authorized_functions is not None and not isinstance(authorized_functions, bool):
|
@@ -152,6 +260,18 @@ def _run_python(
|
|
152
260
|
except Exception:
|
153
261
|
# Capture the full traceback as a string
|
154
262
|
error_traceback = traceback.format_exc()
|
263
|
+
|
264
|
+
# CRITICAL FIX: Even when an exception occurs, we need to update the globals and locals
|
265
|
+
# with any variables that were successfully created/modified before the exception
|
266
|
+
for key, value in merged_globals.items():
|
267
|
+
# Skip special variables and modules
|
268
|
+
if key.startswith('__') or key in ['builtins', 'traceback', 'contextlib', 'io', 'ast', 'sys']:
|
269
|
+
continue
|
270
|
+
|
271
|
+
# Update both dictionaries with the current state
|
272
|
+
if key in updated_locals or key not in updated_globals:
|
273
|
+
updated_locals[key] = value
|
274
|
+
updated_globals[key] = value
|
155
275
|
|
156
276
|
# Join all captured output
|
157
277
|
#printed_output = ''.join(output_buffer)
|
tinyagent/hooks/__init__.py
CHANGED
@@ -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
|
-
|
5
|
+
from .token_tracker import TokenTracker, UsageStats, create_token_tracker
|
6
|
+
|
7
|
+
__all__ = ["RichUICallback", "RichCodeUICallback", "LoggingManager", "TokenTracker", "UsageStats", "create_token_tracker"]
|