From 125bbf24fe91fd30aaee8eb704f1181d9a5017ca Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Tue, 12 May 2026 16:45:11 +0100 Subject: [PATCH 01/45] example tool --- lib/crewai-tools/pyproject.toml | 4 +++ lib/crewai-tools/src/crewai_tools/__init__.py | 2 ++ .../src/crewai_tools/tools/__init__.py | 3 ++- .../tools/k8s_agent_sandbox/README.md | 0 .../tools/k8s_agent_sandbox/__init__.py | 6 +++++ .../k8s_sandbox_exec_tool.py | 25 +++++++++++++++++++ 6 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 8cdbdcd743..2705345100 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -148,6 +148,10 @@ e2b = [ "e2b-code-interpreter~=2.6.0", ] +k8s_agent_sandbox = [ + "k8s-agent-sandbox>=0.4.5" +] + [tool.uv] exclude-newer = "3 days" diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 8a922d8881..c2afcdd2a2 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -217,6 +217,7 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool __all__ = [ @@ -330,6 +331,7 @@ "YoutubeVideoSearchTool", "ZapierActionTool", "ZapierActionTools", + "K8sSandboxExecTool" ] __version__ = "1.15.8" diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index dfab952b9f..9917036cf5 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -204,7 +204,7 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools - +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool __all__ = [ "AIMindTool", @@ -312,4 +312,5 @@ "YoutubeChannelSearchTool", "YoutubeVideoSearchTool", "ZapierActionTools", + "K8sSandboxExecTool", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py new file mode 100644 index 0000000000..a79af4f199 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py @@ -0,0 +1,6 @@ +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool + + +__all__ = [ + "K8sSandboxExecTool", +] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py new file mode 100644 index 0000000000..e0b9becd16 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py @@ -0,0 +1,25 @@ +from typing import Any, Optional +from crewai.tools import BaseTool +from pydantic import Field + + +class K8sSandboxExecTool(BaseTool): + name: str = "K8sSandboxExecTool" + description: str = "Executes shell commands inside an isolated Kubernetes pod sandbox." + + # Define your configuration fields (e.g., namespace, pod execution settings) + namespace: str = Field(default="default", description="K8s namespace") + + def _run(self, command: str, **kwargs: Any) -> str: + try: + import k8s_agent_sandbox + except ImportError: + raise ImportError( + "The k8s_agent_sandbox SDK is not installed. " + "Please install it using: uv add 'crewai-tools[k8s_agent_sandbox]'" + ) + + client = k8s_agent_sandbox.SandboxClient() + sandbox = client.create_sandbox("simple-sandbox-template") + response = sandbox.commands.run(command) + return response.stdout From 9fb39eb775d30483f3b1568ac907d9c30f964fc0 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Wed, 13 May 2026 19:44:05 +0100 Subject: [PATCH 02/45] update exec and add python example --- .../src/crewai_tools/tools/__init__.py | 5 +++ .../tools/k8s_agent_sandbox/__init__.py | 4 ++ .../k8s_sandbox_exec_tool.py | 5 ++- .../k8s_sandbox_python_tool.py | 41 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index 9917036cf5..197ad27e8e 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -205,6 +205,9 @@ ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool +# from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool + __all__ = [ "AIMindTool", @@ -313,4 +316,6 @@ "YoutubeVideoSearchTool", "ZapierActionTools", "K8sSandboxExecTool", + "K8sSandboxPythonTool", + "K8sSandboxFileTool", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py index a79af4f199..7c510924e9 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py @@ -1,6 +1,10 @@ from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool +# from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool __all__ = [ "K8sSandboxExecTool", + "K8sSandboxPythonTool", + # "K8sSandboxFileTool", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py index e0b9becd16..f3284d6a58 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py @@ -22,4 +22,7 @@ def _run(self, command: str, **kwargs: Any) -> str: client = k8s_agent_sandbox.SandboxClient() sandbox = client.create_sandbox("simple-sandbox-template") response = sandbox.commands.run(command) - return response.stdout + if response.exit_code == 0: + return response.stdout + else: + return f"Python execution failed (Exit Code {response.exit_code}):\n{response.stderr}" diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py new file mode 100644 index 0000000000..c33ce169d6 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py @@ -0,0 +1,41 @@ +import base64 +from typing import Any +from crewai.tools import BaseTool +from pydantic import Field + + +class K8sSandboxPythonTool(BaseTool): + name: str = "K8sSandboxPythonTool" + description: str = ( + "Executes Python code inside an isolated Kubernetes pod sandbox. " + "Input should be a string containing raw Python code." + ) + + namespace: str = Field(default="default", description="K8s namespace") + + def _run(self, code: str, **kwargs: Any) -> str: + try: + import k8s_agent_sandbox + except ImportError: + raise ImportError( + "The k8s_agent_sandbox SDK is not installed. " + "Please install it using: uv add 'crewai-tools[k8s_agent_sandbox]'" + ) + + client = k8s_agent_sandbox.SandboxClient() + sandbox = client.create_sandbox("simple-sandbox-template") + + # 1. Base64 encode the raw Python code safely + encoded_code = base64.b64encode(code.encode('utf-8')).decode('utf-8') + + # 2. Construct a single-line python -c command that decodes and executes the payload. + # This prevents any single quote (') or double quote (") collisions in the shell. + safe_command = f'python -c "import base64; exec(base64.b64decode(\'{encoded_code}\').decode(\'utf-8\'))"' + + # 3. Execute the Python script + exec_response = sandbox.commands.run(safe_command) + + if exec_response.exit_code == 0: + return exec_response.stdout + else: + return f"Python execution failed (Exit Code {exec_response.exit_code}):\n{exec_response.stderr}" From 07dd6e430dce733b07ec35c26ea4bdf4c5bfa47f Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Thu, 14 May 2026 11:28:05 +0100 Subject: [PATCH 03/45] update --- lib/crewai-tools/src/crewai_tools/__init__.py | 4 + .../tools/k8s_agent_sandbox/README.md | 121 ++++++++++++++++++ .../k8s_sandbox_exec_tool.py | 1 + .../k8s_sandbox_python_tool.py | 7 +- 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index c2afcdd2a2..379cd5d5ab 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -218,6 +218,8 @@ ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool +# from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool __all__ = [ @@ -332,6 +334,8 @@ "ZapierActionTool", "ZapierActionTools", "K8sSandboxExecTool" + "K8sSandboxPythonTool", + # "K8sSandboxFileTool", ] __version__ = "1.15.8" diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index e69de29bb2..7128a4f090 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -0,0 +1,121 @@ +## How to install + +```bash +python -m venv venv +. venv/bin/activate +pip install crewai-tools +pip install -e "./lib/crewai-tools[k8s_agent_sandbox]" # from the root of this repository +``` + +## Example how to use ExecTool + +```python +import os +from crewai import Agent, Task, Crew, Process +from crewai_tools import K8sSandboxExecTool + +# Instantiate your tool +k8s_exec_tool = K8sSandboxExecTool(namespace="default") + +# Define the Agent +devops_agent = Agent( + role='Kubernetes DevOps Engineer', + goal='Execute commands in a secure sandbox to inspect the environment safely.', + backstory=( + "You are a senior DevOps engineer specializing in Kubernetes. " + "You use isolated pod sandboxes to run scripts, check system details, " + "and securely verify environments without risking the host system." + ), + verbose=True, + allow_delegation=False, + tools=[k8s_exec_tool] +) + +# Define the Task +inspect_task = Task( + description=( + "Use your K8s sandbox execution tool to find out what operating system " + "the sandbox pod is running. Run a command like 'cat /etc/os-release' " + "and summarize the results." + ), + expected_output="A brief summary of the sandbox pod's operating system based on the command output.", + # expected_output="Only name of the Operating System.", + agent=devops_agent, +) + +# Assemble the Crew +k8s_crew = Crew( + agents=[devops_agent], + tasks=[inspect_task], + process=Process.sequential +) + +# Run the Crew +if __name__ == "__main__": + if not os.environ.get("OPENAI_API_KEY"): + print("WARNING: OPENAI_API_KEY environment variable is not set.") + + print("Starting the CrewAI execution...") + result = k8s_crew.kickoff() + + print("\n================================================") + print("FINAL RESULT FROM THE AGENT:") + print("================================================") + print(result) +``` + +## Example how to use PythonTool + +```python +import os +from crewai import Agent, Task, Crew, Process +from crewai_tools import K8sSandboxPythonTool + +# Instantiate your tool +k8s_python_tool = K8sSandboxPythonTool(namespace="default") + +# Define the Agent +data_agent = Agent( + role='Secure Environment Data Engineer', + goal='Execute Python scripts in a secure sandbox to process data and return results.', + backstory=( + "You are a Data Engineer who relies on isolated Kubernetes sandboxes to " + "execute raw Python code. You always ensure your code runs successfully " + "and returns the exact mathematical or data-driven results requested." + ), + verbose=True, + allow_delegation=False, + tools=[k8s_python_tool] +) + +# Define the Task +python_task = Task( + description=( + "Use your K8s sandbox Python tool to execute a Python script that " + "calculates the first 20 Fibonacci numbers. The script should print " + "the resulting list. Extract and summarize the printed output." + ), + expected_output="A list of the first 20 Fibonacci numbers generated by the Python script.", + agent=data_agent, +) + +# Assemble the Crew +python_crew = Crew( + agents=[data_agent], + tasks=[python_task], + process=Process.sequential +) + +# Run the Crew +if __name__ == "__main__": + if not os.environ.get("OPENAI_API_KEY"): + print("WARNING: OPENAI_API_KEY environment variable is not set.") + + print("Starting the CrewAI execution for Python Tool...") + result = python_crew.kickoff() + + print("\n================================================") + print("FINAL RESULT FROM THE AGENT:") + print("================================================") + print(result) +``` diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py index f3284d6a58..985e8751d2 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py @@ -22,6 +22,7 @@ def _run(self, command: str, **kwargs: Any) -> str: client = k8s_agent_sandbox.SandboxClient() sandbox = client.create_sandbox("simple-sandbox-template") response = sandbox.commands.run(command) + sandbox.terminate() if response.exit_code == 0: return response.stdout else: diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py index c33ce169d6..17d9bbf0ae 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py @@ -25,16 +25,15 @@ def _run(self, code: str, **kwargs: Any) -> str: client = k8s_agent_sandbox.SandboxClient() sandbox = client.create_sandbox("simple-sandbox-template") - # 1. Base64 encode the raw Python code safely + # Base64 encode the raw Python code safely encoded_code = base64.b64encode(code.encode('utf-8')).decode('utf-8') - # 2. Construct a single-line python -c command that decodes and executes the payload. + # Construct a single-line python -c command that decodes and executes the payload. # This prevents any single quote (') or double quote (") collisions in the shell. safe_command = f'python -c "import base64; exec(base64.b64decode(\'{encoded_code}\').decode(\'utf-8\'))"' - - # 3. Execute the Python script exec_response = sandbox.commands.run(safe_command) + sandbox.terminate() if exec_response.exit_code == 0: return exec_response.stdout else: From 468f48e651e9ee72e0b916d1fbd781cf3da9cb5c Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Thu, 14 May 2026 19:17:26 +0100 Subject: [PATCH 04/45] add FileTool --- lib/crewai-tools/src/crewai_tools/__init__.py | 4 +- .../src/crewai_tools/tools/__init__.py | 2 +- .../tools/k8s_agent_sandbox/README.md | 58 +++++++++++++++++++ .../tools/k8s_agent_sandbox/__init__.py | 4 +- .../k8s_sandbox_file_tool.py | 55 ++++++++++++++++++ 5 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_file_tool.py diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 379cd5d5ab..e9e4fdc82d 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -219,7 +219,7 @@ from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool -# from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool __all__ = [ @@ -335,7 +335,7 @@ "ZapierActionTools", "K8sSandboxExecTool" "K8sSandboxPythonTool", - # "K8sSandboxFileTool", + "K8sSandboxFileTool", ] __version__ = "1.15.8" diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index 197ad27e8e..cca9a6e483 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -206,7 +206,7 @@ from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool -# from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool __all__ = [ diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index 7128a4f090..89653a2b9b 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -119,3 +119,61 @@ if __name__ == "__main__": print("================================================") print(result) ``` + +## Example how to use FileTool + +```python +import os +from crewai import Agent, Task, Crew, Process +from crewai_tools import K8sSandboxFileTool + +# Instantiate your tool +k8s_file_tool = K8sSandboxFileTool(namespace="default") + +# Define the Agent +sys_agent = Agent( + role='Kubernetes Systems Engineer', + goal='Create, modify, and read configuration files securely within pod sandboxes.', + backstory=( + "You are a systems engineer who manages configuration files in containerized " + "environments. You use the K8s sandbox file tool to accurately write necessary " + "files to the filesystem and read them back to verify their integrity." + ), + verbose=True, + allow_delegation=False, + tools=[k8s_file_tool] +) + +# Define the Task +file_task = Task( + description=( + "Perform the following two steps using your K8s sandbox file tool:\n" + "1. Write a file at the path './crewai_test.txt' with the content: " + "'Hello from CrewAI! The sandbox file tool is working perfectly.'\n" + "2. Read the file you just created at './crewai_test.txt' to verify its contents.\n" + "Summarize your actions and output the exact text you read from the file." + ), + expected_output="Confirmation that the file was written, along with the exact text read back from the file.", + agent=sys_agent, +) + +# Assemble the Crew +file_crew = Crew( + agents=[sys_agent], + tasks=[file_task], + process=Process.sequential +) + +# Run the Crew +if __name__ == "__main__": + if not os.environ.get("OPENAI_API_KEY"): + print("WARNING: OPENAI_API_KEY environment variable is not set.") + + print("Starting the CrewAI execution for File Tool...") + result = file_crew.kickoff() + + print("\n================================================") + print("FINAL RESULT FROM THE AGENT:") + print("================================================") + print(result) +``` diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py index 7c510924e9..c24b2e0ccf 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py @@ -1,10 +1,10 @@ from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool -# from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool +from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool __all__ = [ "K8sSandboxExecTool", "K8sSandboxPythonTool", - # "K8sSandboxFileTool", + "K8sSandboxFileTool", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_file_tool.py new file mode 100644 index 0000000000..251101dc82 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_file_tool.py @@ -0,0 +1,55 @@ +import base64 +from typing import Any, Optional +from crewai.tools import BaseTool +from pydantic import Field +try: + import k8s_agent_sandbox +except ImportError: + raise ImportError( + "The k8s_agent_sandbox SDK is not installed. " + "Please install it using: uv add 'crewai-tools[k8s_agent_sandbox]'" + ) + + +class K8sSandboxFileTool(BaseTool): + name: str = "K8sSandboxFileTool" + description: str = ( + "Reads, writes, or lists files inside an isolated Kubernetes pod sandbox. " + "Requires an 'action' ('read', 'write', or 'list') and a 'file_path'." + ) + + namespace: str = Field(default="default", description="K8s namespace") + client: k8s_agent_sandbox.SandboxClient = k8s_agent_sandbox.SandboxClient() + sandbox: k8s_agent_sandbox.sandbox_client.Sandbox | None = client.create_sandbox("simple-sandbox-template") + + + def _run(self, action: str, file_path: str, content: Optional[str] = None, **kwargs: Any) -> str: + action = action.lower() + + if action == "read": + response = self.sandbox.commands.run(f'sh -c "cat {file_path}"') + if response.exit_code == 0: + return response.stdout + return f"Error reading file {file_path}:\n{response.stderr}" + + elif action == "write": + if not content: + return "Error: 'content' parameter is required for the 'write' action." + + # Base64 encode the content to safely write multiline text/symbols + encoded_content = base64.b64encode(content.encode('utf-8')).decode('utf-8') + response = self.sandbox.commands.run(f"sh -c \"echo '{encoded_content}' | base64 -d > {file_path}\"") + + if response.exit_code == 0: + return f"Successfully wrote content to {file_path}." + return f"Error writing to file {file_path}:\n{response.stderr}" + + elif action == "list": + # Also wrapping list in sh -c just to be safe + response = self.sandbox.commands.run(f'sh -c "ls -la {file_path}"') + if response.exit_code == 0: + return response.stdout + return f"Error listing path {file_path}:\n{response.stderr}" + + else: + return f"Error: Unknown action '{action}'. Supported actions are 'read', 'write', and 'list'." From f54f7bb599a1cfb1494d7f79d3ee11da7fa78d0e Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Mon, 18 May 2026 22:00:46 +0100 Subject: [PATCH 05/45] update tools --- lib/crewai-tools/src/crewai_tools/__init__.py | 12 +-- .../src/crewai_tools/tools/__init__.py | 12 +-- .../tools/k8s_agent_sandbox/__init__.py | 12 +-- .../tools/k8s_agent_sandbox/base_tool.py | 102 ++++++++++++++++++ .../tools/k8s_agent_sandbox/exec_tool.py | 19 ++++ .../tools/k8s_agent_sandbox/file_tool.py | 53 +++++++++ .../k8s_sandbox_exec_tool.py | 29 ----- .../k8s_sandbox_file_tool.py | 55 ---------- .../k8s_sandbox_python_tool.py | 40 ------- .../tools/k8s_agent_sandbox/python_tool.py | 31 ++++++ 10 files changed, 223 insertions(+), 142 deletions(-) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py delete mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py delete mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_file_tool.py delete mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index e9e4fdc82d..855f1b7dc6 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -217,9 +217,9 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool +from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool +from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool +from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool __all__ = [ @@ -333,9 +333,9 @@ "YoutubeVideoSearchTool", "ZapierActionTool", "ZapierActionTools", - "K8sSandboxExecTool" - "K8sSandboxPythonTool", - "K8sSandboxFileTool", + "K8sExecTool" + "K8sPythonTool", + "K8sFileTool", ] __version__ = "1.15.8" diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index cca9a6e483..c4ad6f1f13 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -204,9 +204,9 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool +from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool +from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool +from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool __all__ = [ @@ -315,7 +315,7 @@ "YoutubeChannelSearchTool", "YoutubeVideoSearchTool", "ZapierActionTools", - "K8sSandboxExecTool", - "K8sSandboxPythonTool", - "K8sSandboxFileTool", + "K8sExecTool", + "K8sPythonTool", + "K8sFileTool", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py index c24b2e0ccf..da677253af 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py @@ -1,10 +1,10 @@ -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_exec_tool import K8sSandboxExecTool -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_python_tool import K8sSandboxPythonTool -from crewai_tools.tools.k8s_agent_sandbox.k8s_sandbox_file_tool import K8sSandboxFileTool +from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool +from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool +from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool __all__ = [ - "K8sSandboxExecTool", - "K8sSandboxPythonTool", - "K8sSandboxFileTool", + "K8sExecTool", + "K8sPythonTool", + "K8sFileTool", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py new file mode 100644 index 0000000000..0858e2a3af --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -0,0 +1,102 @@ +import atexit +import logging +from typing import Any, Optional +from pydantic import Field +import threading +from typing import ClassVar + +from crewai.tools import BaseTool +from pydantic import ConfigDict, Field, PrivateAttr, SecretStr + + +logger = logging.getLogger(__name__) + + +class K8sBaseTool(BaseTool): + name: str = "K8sBaseTool" + description: str = "Basic tool definition" + + template: str = Field(description="Agent sandbox template name") + namespace: str = Field(default="default", description="K8s namespace") + + + persistent: bool = Field( + default=False, + description=( + "If True, reuse one sandbox across all calls to this tool instance " + "and kill it at process exit. Default False creates and kills a " + "fresh sandbox per call." + ), + ) + + _persistent_sandbox: Any | None = PrivateAttr(default=None) + _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) + _cleanup_registered: bool = PrivateAttr(default=False) + + _sdk_cache: ClassVar[dict[str, Any]] = {} + + @classmethod + def _import_sandbox_client_class(cls) -> Any: + """Returns the Sandbox Client that is used to communicate with k8s.""" + cached = cls._sdk_cache.get("k8s_agent_sandbox.SandboxClient") + if cached is not None: + return cached + try: + from k8s_agent_sandbox import SandboxClient # type: ignore[import-untyped] + except ImportError as exc: + raise ImportError( + "The 'k8s_agent_sandbox' package is required for k8s_agent_sandbox sandbox tools." + ) from exc + cls._sdk_cache["k8s_agent_sandbox.SandboxClient"] = SandboxClient + return SandboxClient + + def _get_sandbox(self) -> tuple[Any, bool]: + """Return (sandbox, should_kill_after_use).""" + SandboxClient = self._import_sandbox_client_class() + client = SandboxClient() + + if self.persistent: + with self._lock: + if self._persistent_sandbox is None: + self._persistent_sandbox = client.create_sandbox( + template=self.template, + namespace=self.namespace + ) + if not self._cleanup_registered: + atexit.register(self.close) + self._cleanup_registered = True + return self._persistent_sandbox, False + + sandbox = client.create_sandbox( + template=self.template, + namespace=self.namespace + ) + return sandbox, True + + def _release_sandbox(self, sandbox, should_terminate) -> None: + if not should_terminate: + return + try: + sandbox.terminate() + except Exception: + logger.debug( + "Best-effort sandbox cleanup failed after ephemeral use; " + "the sandbox may need manual termination.", + exc_info=True, + ) + + def close(self) -> None: + """Kill the cached persistent sandbox if one exists.""" + with self._lock: + sandbox = self._persistent_sandbox + self._persistent_sandbox = None + if sandbox is None: + return + try: + sandbox.terminate() + except Exception: + logger.debug( + "Best-effort persistent sandbox cleanup failed at close(); " + "the sandbox may need manual termination.", + exc_info=True, + ) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py new file mode 100644 index 0000000000..d56832d67f --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -0,0 +1,19 @@ +from typing import Any + +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool + + +class K8sExecTool(K8sBaseTool): + name: str = "K8sExecTool" + description: str = "Executes shell commands inside an isolated Kubernetes pod sandbox." + + def _run(self, command: str, **kwargs: Any) -> str: + sandbox, should_terminate = self._get_sandbox() + try: + response = sandbox.commands.run(command) + if response.exit_code == 0: + return response.stdout + else: + return f"Python execution failed (Exit Code {response.exit_code}):\n{response.stderr}" + finally: + self._release_sandbox(sandbox, should_terminate) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py new file mode 100644 index 0000000000..9e34b7d142 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -0,0 +1,53 @@ +import base64 +from typing import Any, Optional +from pydantic import Field +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool + + +class K8sFileTool(K8sBaseTool): + name: str = "K8sFileTool" + description: str = ( + "Reads, writes, or lists files inside an isolated Kubernetes pod sandbox. " + "Requires an 'action' ('read', 'write', or 'list') and a 'file_path'." + ) + persistent: bool = Field( + default=True, + description=( + "File tool must use a persistent sandbox." + ), + ) + + def _run(self, action: str, file_path: str, content: Optional[str] = None, **kwargs: Any) -> str: + sandbox, should_terminate = self._get_sandbox() + + try: + action = action.lower() + if action == "read": + response = sandbox.commands.run(f'sh -c "cat {file_path}"') + if response.exit_code == 0: + return response.stdout + return f"Error reading file {file_path}:\n{response.stderr}" + + elif action == "write": + if not content: + return "Error: 'content' parameter is required for the 'write' action." + + # Base64 encode the content to safely write multiline text/symbols + encoded_content = base64.b64encode(content.encode('utf-8')).decode('utf-8') + response = sandbox.commands.run(f"sh -c \"echo '{encoded_content}' | base64 -d > {file_path}\"") + + if response.exit_code == 0: + return f"Successfully wrote content to {file_path}." + return f"Error writing to file {file_path}:\n{response.stderr}" + + elif action == "list": + # Also wrapping list in sh -c just to be safe + response = sandbox.commands.run(f'sh -c "ls -la {file_path}"') + if response.exit_code == 0: + return response.stdout + return f"Error listing path {file_path}:\n{response.stderr}" + + else: + return f"Error: Unknown action '{action}'. Supported actions are 'read', 'write', and 'list'." + finally: + self._release_sandbox(sandbox, should_terminate) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py deleted file mode 100644 index 985e8751d2..0000000000 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_exec_tool.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Any, Optional -from crewai.tools import BaseTool -from pydantic import Field - - -class K8sSandboxExecTool(BaseTool): - name: str = "K8sSandboxExecTool" - description: str = "Executes shell commands inside an isolated Kubernetes pod sandbox." - - # Define your configuration fields (e.g., namespace, pod execution settings) - namespace: str = Field(default="default", description="K8s namespace") - - def _run(self, command: str, **kwargs: Any) -> str: - try: - import k8s_agent_sandbox - except ImportError: - raise ImportError( - "The k8s_agent_sandbox SDK is not installed. " - "Please install it using: uv add 'crewai-tools[k8s_agent_sandbox]'" - ) - - client = k8s_agent_sandbox.SandboxClient() - sandbox = client.create_sandbox("simple-sandbox-template") - response = sandbox.commands.run(command) - sandbox.terminate() - if response.exit_code == 0: - return response.stdout - else: - return f"Python execution failed (Exit Code {response.exit_code}):\n{response.stderr}" diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_file_tool.py deleted file mode 100644 index 251101dc82..0000000000 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_file_tool.py +++ /dev/null @@ -1,55 +0,0 @@ -import base64 -from typing import Any, Optional -from crewai.tools import BaseTool -from pydantic import Field -try: - import k8s_agent_sandbox -except ImportError: - raise ImportError( - "The k8s_agent_sandbox SDK is not installed. " - "Please install it using: uv add 'crewai-tools[k8s_agent_sandbox]'" - ) - - -class K8sSandboxFileTool(BaseTool): - name: str = "K8sSandboxFileTool" - description: str = ( - "Reads, writes, or lists files inside an isolated Kubernetes pod sandbox. " - "Requires an 'action' ('read', 'write', or 'list') and a 'file_path'." - ) - - namespace: str = Field(default="default", description="K8s namespace") - client: k8s_agent_sandbox.SandboxClient = k8s_agent_sandbox.SandboxClient() - sandbox: k8s_agent_sandbox.sandbox_client.Sandbox | None = client.create_sandbox("simple-sandbox-template") - - - def _run(self, action: str, file_path: str, content: Optional[str] = None, **kwargs: Any) -> str: - action = action.lower() - - if action == "read": - response = self.sandbox.commands.run(f'sh -c "cat {file_path}"') - if response.exit_code == 0: - return response.stdout - return f"Error reading file {file_path}:\n{response.stderr}" - - elif action == "write": - if not content: - return "Error: 'content' parameter is required for the 'write' action." - - # Base64 encode the content to safely write multiline text/symbols - encoded_content = base64.b64encode(content.encode('utf-8')).decode('utf-8') - response = self.sandbox.commands.run(f"sh -c \"echo '{encoded_content}' | base64 -d > {file_path}\"") - - if response.exit_code == 0: - return f"Successfully wrote content to {file_path}." - return f"Error writing to file {file_path}:\n{response.stderr}" - - elif action == "list": - # Also wrapping list in sh -c just to be safe - response = self.sandbox.commands.run(f'sh -c "ls -la {file_path}"') - if response.exit_code == 0: - return response.stdout - return f"Error listing path {file_path}:\n{response.stderr}" - - else: - return f"Error: Unknown action '{action}'. Supported actions are 'read', 'write', and 'list'." diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py deleted file mode 100644 index 17d9bbf0ae..0000000000 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/k8s_sandbox_python_tool.py +++ /dev/null @@ -1,40 +0,0 @@ -import base64 -from typing import Any -from crewai.tools import BaseTool -from pydantic import Field - - -class K8sSandboxPythonTool(BaseTool): - name: str = "K8sSandboxPythonTool" - description: str = ( - "Executes Python code inside an isolated Kubernetes pod sandbox. " - "Input should be a string containing raw Python code." - ) - - namespace: str = Field(default="default", description="K8s namespace") - - def _run(self, code: str, **kwargs: Any) -> str: - try: - import k8s_agent_sandbox - except ImportError: - raise ImportError( - "The k8s_agent_sandbox SDK is not installed. " - "Please install it using: uv add 'crewai-tools[k8s_agent_sandbox]'" - ) - - client = k8s_agent_sandbox.SandboxClient() - sandbox = client.create_sandbox("simple-sandbox-template") - - # Base64 encode the raw Python code safely - encoded_code = base64.b64encode(code.encode('utf-8')).decode('utf-8') - - # Construct a single-line python -c command that decodes and executes the payload. - # This prevents any single quote (') or double quote (") collisions in the shell. - safe_command = f'python -c "import base64; exec(base64.b64decode(\'{encoded_code}\').decode(\'utf-8\'))"' - exec_response = sandbox.commands.run(safe_command) - - sandbox.terminate() - if exec_response.exit_code == 0: - return exec_response.stdout - else: - return f"Python execution failed (Exit Code {exec_response.exit_code}):\n{exec_response.stderr}" diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py new file mode 100644 index 0000000000..7b827b4e23 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -0,0 +1,31 @@ +import base64 +from typing import Any + +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool + + +class K8sPythonTool(K8sBaseTool): + name: str = "K8sPythonTool" + description: str = ( + "Executes Python code inside an isolated Kubernetes pod sandbox. " + "Input should be a string containing raw Python code." + ) + + def _run(self, code: str, **kwargs: Any) -> str: + sandbox, should_terminate = self._get_sandbox() + try: + # Base64 encode the raw Python code safely + encoded_code = base64.b64encode(code.encode('utf-8')).decode('utf-8') + + # Construct a single-line python -c command that decodes and executes the payload. + # This prevents any single quote (') or double quote (") collisions in the shell. + safe_command = f'python -c "import base64; exec(base64.b64decode(\'{encoded_code}\').decode(\'utf-8\'))"' + exec_response = sandbox.commands.run(safe_command) + + sandbox.terminate() + if exec_response.exit_code == 0: + return exec_response.stdout + else: + return f"Python execution failed (Exit Code {exec_response.exit_code}):\n{exec_response.stderr}" + finally: + self._release_sandbox(sandbox, should_terminate) From eff667e3ba9523d28c1e02a35f7520d3bf1886e5 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Mon, 18 May 2026 22:03:51 +0100 Subject: [PATCH 06/45] update readme --- .../crewai_tools/tools/k8s_agent_sandbox/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index 89653a2b9b..1fa9ea19ad 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -12,10 +12,10 @@ pip install -e "./lib/crewai-tools[k8s_agent_sandbox]" # from the root of this r ```python import os from crewai import Agent, Task, Crew, Process -from crewai_tools import K8sSandboxExecTool +from crewai_tools import K8sExecTool # Instantiate your tool -k8s_exec_tool = K8sSandboxExecTool(namespace="default") +k8s_exec_tool = K8sExecTool(template="simple-sandbox-template", namespace="default") # Define the Agent devops_agent = Agent( @@ -69,10 +69,10 @@ if __name__ == "__main__": ```python import os from crewai import Agent, Task, Crew, Process -from crewai_tools import K8sSandboxPythonTool +from crewai_tools import K8sPythonTool # Instantiate your tool -k8s_python_tool = K8sSandboxPythonTool(namespace="default") +k8s_python_tool = K8sPythonTool(template="simple-sandbox-template", namespace="default") # Define the Agent data_agent = Agent( @@ -125,10 +125,10 @@ if __name__ == "__main__": ```python import os from crewai import Agent, Task, Crew, Process -from crewai_tools import K8sSandboxFileTool +from crewai_tools import K8sFileTool # Instantiate your tool -k8s_file_tool = K8sSandboxFileTool(namespace="default") +k8s_file_tool = K8sFileTool(template="simple-sandbox-template", namespace="default") # Define the Agent sys_agent = Agent( From 02070aa43ab158af9c53e6160ab17398c2612cd7 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Tue, 26 May 2026 14:29:06 +0100 Subject: [PATCH 07/45] update file_tool --- .../tools/k8s_agent_sandbox/file_tool.py | 38 +++++++++++-------- .../tools/k8s_agent_sandbox/python_tool.py | 1 - 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 9e34b7d142..81238dd107 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -1,4 +1,5 @@ import base64 +import shlex from typing import Any, Optional from pydantic import Field from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool @@ -22,32 +23,37 @@ def _run(self, action: str, file_path: str, content: Optional[str] = None, **kwa try: action = action.lower() + safe_path = shlex.quote(file_path) + if action == "read": - response = sandbox.commands.run(f'sh -c "cat {file_path}"') - if response.exit_code == 0: - return response.stdout - return f"Error reading file {file_path}:\n{response.stderr}" + inner_cmd = f"cat {safe_path}" - elif action == "write": + elif action in ["write", "append"]: if not content: return "Error: 'content' parameter is required for the 'write' action." - # Base64 encode the content to safely write multiline text/symbols encoded_content = base64.b64encode(content.encode('utf-8')).decode('utf-8') - response = sandbox.commands.run(f"sh -c \"echo '{encoded_content}' | base64 -d > {file_path}\"") + redirect_operator = ">" if action == "write" else ">>" + inner_cmd = f"echo '{encoded_content}' | base64 -d {redirect_operator} {safe_path}" - if response.exit_code == 0: - return f"Successfully wrote content to {file_path}." - return f"Error writing to file {file_path}:\n{response.stderr}" + elif action == "delete": + inner_cmd = f"rm -rf {safe_path}" elif action == "list": - # Also wrapping list in sh -c just to be safe - response = sandbox.commands.run(f'sh -c "ls -la {file_path}"') - if response.exit_code == 0: - return response.stdout - return f"Error listing path {file_path}:\n{response.stderr}" + inner_cmd = f"ls -la {file_path}" else: - return f"Error: Unknown action '{action}'. Supported actions are 'read', 'write', and 'list'." + return ( + f"Error: Unknown action '{action}'. " + "Supported actions are 'read', 'write', 'append', 'delete', and 'list'." + ) + safe_inner_cmd = shlex.quote(inner_cmd) + response = sandbox.commands.run(f"sh -c {safe_inner_cmd}") + if response.exit_code == 0: + if action in ["read", "list"]: + return response.stdout + return f"Successfully executed '{action}' on {file_path}." + else: + return f"Error executing '{action}' on {file_path}:\n{response.stderr}" finally: self._release_sandbox(sandbox, should_terminate) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index 7b827b4e23..48d3634bf0 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -22,7 +22,6 @@ def _run(self, code: str, **kwargs: Any) -> str: safe_command = f'python -c "import base64; exec(base64.b64decode(\'{encoded_code}\').decode(\'utf-8\'))"' exec_response = sandbox.commands.run(safe_command) - sandbox.terminate() if exec_response.exit_code == 0: return exec_response.stdout else: From 925a4bdacad0672cbef566dcce28ebf6ba6a5936 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Tue, 26 May 2026 15:07:46 +0100 Subject: [PATCH 08/45] add sandbox_claim attribute --- .../src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py index 0858e2a3af..6ba63a0aa0 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -29,6 +29,7 @@ class K8sBaseTool(BaseTool): ), ) + claim_name: Any | None = Field(default=None) _persistent_sandbox: Any | None = PrivateAttr(default=None) _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) _cleanup_registered: bool = PrivateAttr(default=False) @@ -55,6 +56,9 @@ def _get_sandbox(self) -> tuple[Any, bool]: SandboxClient = self._import_sandbox_client_class() client = SandboxClient() + if self.claim_name: + return client.get_sandbox(self.claim_name), False + if self.persistent: with self._lock: if self._persistent_sandbox is None: From 7e447bec9e7d89cb840f865d3827317493a634f9 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Tue, 26 May 2026 15:11:53 +0100 Subject: [PATCH 09/45] export K8sBaseTool --- lib/crewai-tools/src/crewai_tools/__init__.py | 2 ++ lib/crewai-tools/src/crewai_tools/tools/__init__.py | 2 ++ .../src/crewai_tools/tools/k8s_agent_sandbox/__init__.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 855f1b7dc6..74c1c32d64 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -217,6 +217,7 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool @@ -333,6 +334,7 @@ "YoutubeVideoSearchTool", "ZapierActionTool", "ZapierActionTools", + "K8sBaseTool" "K8sExecTool" "K8sPythonTool", "K8sFileTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index c4ad6f1f13..58c0603c9f 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -204,6 +204,7 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool @@ -315,6 +316,7 @@ "YoutubeChannelSearchTool", "YoutubeVideoSearchTool", "ZapierActionTools", + "K8sBaseTool", "K8sExecTool", "K8sPythonTool", "K8sFileTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py index da677253af..f1b1ed4af4 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py @@ -1,9 +1,11 @@ +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool __all__ = [ + "K8sBaseTool", "K8sExecTool", "K8sPythonTool", "K8sFileTool", From c809ed101c5708f9dfbcd02df5a8049ee68150f7 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Tue, 26 May 2026 15:13:10 +0100 Subject: [PATCH 10/45] fix K8sExecTool --- .../src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index d56832d67f..684c59c506 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -14,6 +14,6 @@ def _run(self, command: str, **kwargs: Any) -> str: if response.exit_code == 0: return response.stdout else: - return f"Python execution failed (Exit Code {response.exit_code}):\n{response.stderr}" + return f"Command execution failed (Exit Code {response.exit_code}):\n{response.stderr}" finally: self._release_sandbox(sandbox, should_terminate) From d77b9768ce13b1f31581611a3e965dae9ba85116 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Tue, 2 Jun 2026 20:10:35 +0100 Subject: [PATCH 11/45] add tests --- .../tests/k8s_agent_sandbox/test_exec_tool.py | 84 +++++++++++ .../tests/k8s_agent_sandbox/test_file_tool.py | 140 ++++++++++++++++++ .../k8s_agent_sandbox/test_python_tool.py | 87 +++++++++++ 3 files changed, 311 insertions(+) create mode 100644 lib/crewai-tools/tests/k8s_agent_sandbox/test_exec_tool.py create mode 100644 lib/crewai-tools/tests/k8s_agent_sandbox/test_file_tool.py create mode 100644 lib/crewai-tools/tests/k8s_agent_sandbox/test_python_tool.py diff --git a/lib/crewai-tools/tests/k8s_agent_sandbox/test_exec_tool.py b/lib/crewai-tools/tests/k8s_agent_sandbox/test_exec_tool.py new file mode 100644 index 0000000000..00b9275d6e --- /dev/null +++ b/lib/crewai-tools/tests/k8s_agent_sandbox/test_exec_tool.py @@ -0,0 +1,84 @@ +import pytest +from unittest.mock import MagicMock, patch + +from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool + + +@pytest.fixture +def k8s_exec_tool(): + """Fixture to provide a fresh instance of the tool for each test.""" + return K8sExecTool(template="template") + + +@pytest.fixture +def mock_sandbox(): + """Fixture to provide a mocked sandbox object.""" + return MagicMock() + + +def test_run_success(k8s_exec_tool, mock_sandbox): + """Test that a successful command returns the stdout correctly.""" + + # 1. Setup the mock response to simulate a successful command (exit_code 0) + mock_response = MagicMock() + mock_response.exit_code = 0 + mock_response.stdout = "Hello from the sandbox!\n" + mock_response.stderr = "" + mock_sandbox.commands.run.return_value = mock_response + + # 2. Mock the parent class lifecycle methods + with patch.object(k8s_exec_tool, '_get_sandbox', return_value=(mock_sandbox, True)) as mock_get_sandbox, \ + patch.object(k8s_exec_tool, '_release_sandbox') as mock_release_sandbox: + + # 3. Execute the tool + result = k8s_exec_tool._run("echo 'Hello from the sandbox!'") + + # 4. Assertions + assert result == "Hello from the sandbox!\n" + + # Ensure the SDK was called with the exact string + mock_sandbox.commands.run.assert_called_once_with("echo 'Hello from the sandbox!'") + + # Ensure the sandbox was safely released + mock_release_sandbox.assert_called_once_with(mock_sandbox, True) + + +def test_run_failure(k8s_exec_tool, mock_sandbox): + """Test that a failing command returns the formatted error string.""" + + # 1. Setup the mock response to simulate a failed command (exit_code != 0) + mock_response = MagicMock() + mock_response.exit_code = 127 + mock_response.stdout = "" + mock_response.stderr = "bash: invalid_command: command not found" + mock_sandbox.commands.run.return_value = mock_response + + with patch.object(k8s_exec_tool, '_get_sandbox', return_value=(mock_sandbox, False)), \ + patch.object(k8s_exec_tool, '_release_sandbox') as mock_release_sandbox: + + # 2. Execute the tool + result = k8s_exec_tool._run("invalid_command") + + # 3. Assertions + assert "Command execution failed (Exit Code 127):" in result + assert "bash: invalid_command: command not found" in result + + mock_sandbox.commands.run.assert_called_once_with("invalid_command") + mock_release_sandbox.assert_called_once_with(mock_sandbox, False) + + +def test_run_finally_executes_on_exception(k8s_exec_tool, mock_sandbox): + """Test that the sandbox is safely released even if the SDK crashes.""" + + # 1. Simulate the SDK throwing an unhandled exception (e.g., network timeout) + mock_sandbox.commands.run.side_effect = Exception("SDK Connection Timeout") + + with patch.object(k8s_exec_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ + patch.object(k8s_exec_tool, '_release_sandbox') as mock_release_sandbox: + + # 2. Execute the tool and assert it raises the error up the chain + with pytest.raises(Exception, match="SDK Connection Timeout"): + k8s_exec_tool._run("ls -la") + + # 3. Crucial Assertion: The finally block MUST still fire to prevent cluster resource leaks + mock_release_sandbox.assert_called_once_with(mock_sandbox, True) diff --git a/lib/crewai-tools/tests/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/k8s_agent_sandbox/test_file_tool.py new file mode 100644 index 0000000000..3726fff229 --- /dev/null +++ b/lib/crewai-tools/tests/k8s_agent_sandbox/test_file_tool.py @@ -0,0 +1,140 @@ +import base64 +import shlex +import pytest +from unittest.mock import MagicMock, patch + +from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool + + +@pytest.fixture +def k8s_file_tool(): + """Fixture to provide a fresh instance of the tool for each test.""" + return K8sFileTool(template="template") + + +@pytest.fixture +def mock_sandbox(): + """Fixture to provide a mocked sandbox object.""" + return MagicMock() + + +def test_run_read_success(k8s_file_tool, mock_sandbox): + """Test the 'read' action cleanly extracts stdout and escapes paths.""" + mock_response = MagicMock(exit_code=0, stdout="file contents\n", stderr="") + mock_sandbox.commands.run.return_value = mock_response + + file_path = "my secret file.txt" # Testing space escaping + expected_inner = f"cat {shlex.quote(file_path)}" + expected_cmd = f"sh -c {shlex.quote(expected_inner)}" + + with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ + patch.object(k8s_file_tool, '_release_sandbox') as mock_release: + + result = k8s_file_tool._run(action="read", file_path=file_path) + + assert result == "file contents\n" + mock_sandbox.commands.run.assert_called_once_with(expected_cmd) + mock_release.assert_called_once_with(mock_sandbox, True) + + +def test_run_write_success(k8s_file_tool, mock_sandbox): + """Test the 'write' action properly base64 encodes the payload.""" + mock_response = MagicMock(exit_code=0, stdout="", stderr="") + mock_sandbox.commands.run.return_value = mock_response + + file_path = "/tmp/test.txt" + content = "Hello World!" + encoded = base64.b64encode(content.encode('utf-8')).decode('utf-8') + + expected_inner = f"echo '{encoded}' | base64 -d > {shlex.quote(file_path)}" + expected_cmd = f"sh -c {shlex.quote(expected_inner)}" + + with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ + patch.object(k8s_file_tool, '_release_sandbox') as mock_release: + + result = k8s_file_tool._run(action="write", file_path=file_path, content=content) + + assert result == f"Successfully executed 'write' on {file_path}." + mock_sandbox.commands.run.assert_called_once_with(expected_cmd) + mock_release.assert_called_once_with(mock_sandbox, True) + + +def test_run_append_success(k8s_file_tool, mock_sandbox): + """Test the 'append' action uses the correct redirect operator.""" + mock_response = MagicMock(exit_code=0, stdout="", stderr="") + mock_sandbox.commands.run.return_value = mock_response + + content = "New Line" + encoded = base64.b64encode(content.encode('utf-8')).decode('utf-8') + + expected_inner = f"echo '{encoded}' | base64 -d >> {shlex.quote('log.txt')}" + expected_cmd = f"sh -c {shlex.quote(expected_inner)}" + + with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)): + result = k8s_file_tool._run(action="append", file_path="log.txt", content=content) + + assert "Successfully executed 'append'" in result + mock_sandbox.commands.run.assert_called_once_with(expected_cmd) + + +def test_run_delete_and_list(k8s_file_tool, mock_sandbox): + """Test 'delete' and 'list' actions trigger the correct inner shell commands.""" + mock_response = MagicMock(exit_code=0, stdout="total 0\n", stderr="") + mock_sandbox.commands.run.return_value = mock_response + + with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)): + # Test Delete + k8s_file_tool._run(action="delete", file_path="file.txt") + expected_delete = f"sh -c {shlex.quote('rm -rf ' + shlex.quote('file.txt'))}" + mock_sandbox.commands.run.assert_called_with(expected_delete) + + # Test List + k8s_file_tool._run(action="list", file_path="dir/") + expected_list = f"sh -c {shlex.quote('ls -la ' + shlex.quote('dir/'))}" + mock_sandbox.commands.run.assert_called_with(expected_list) + + +def test_missing_content_error(k8s_file_tool, mock_sandbox): + """Test that writing or appending without content fails immediately.""" + with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ + patch.object(k8s_file_tool, '_release_sandbox') as mock_release: + + result = k8s_file_tool._run(action="write", file_path="test.txt", content=None) + + assert "parameter is required" in result + mock_sandbox.commands.run.assert_not_called() + mock_release.assert_called_once_with(mock_sandbox, True) + + +def test_invalid_action_error(k8s_file_tool, mock_sandbox): + """Test that an unknown action returns an error and doesn't run the shell.""" + with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)): + result = k8s_file_tool._run(action="fly", file_path="test.txt") + + assert "Unknown action 'fly'" in result + mock_sandbox.commands.run.assert_not_called() + + +def test_run_failure_bubbles_stderr(k8s_file_tool, mock_sandbox): + """Test that a non-zero exit code bubbles the stderr to the agent.""" + mock_response = MagicMock(exit_code=1, stdout="", stderr="cat: test.txt: No such file or directory") + mock_sandbox.commands.run.return_value = mock_response + + with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)): + result = k8s_file_tool._run(action="read", file_path="test.txt") + + assert "Error executing 'read' on test.txt:" in result + assert "No such file or directory" in result + + +def test_run_finally_executes_on_exception(k8s_file_tool, mock_sandbox): + """Test that the sandbox is safely released even if the SDK crashes.""" + mock_sandbox.commands.run.side_effect = Exception("Network Disconnected") + + with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ + patch.object(k8s_file_tool, '_release_sandbox') as mock_release: + + with pytest.raises(Exception, match="Network Disconnected"): + k8s_file_tool._run(action="read", file_path="test.txt") + + mock_release.assert_called_once_with(mock_sandbox, True) diff --git a/lib/crewai-tools/tests/k8s_agent_sandbox/test_python_tool.py b/lib/crewai-tools/tests/k8s_agent_sandbox/test_python_tool.py new file mode 100644 index 0000000000..4a4011760e --- /dev/null +++ b/lib/crewai-tools/tests/k8s_agent_sandbox/test_python_tool.py @@ -0,0 +1,87 @@ +import base64 +import pytest +from unittest.mock import MagicMock, patch + +from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool + + +@pytest.fixture +def k8s_python_tool(): + """Fixture to provide a fresh instance of the tool for each test.""" + return K8sPythonTool(template="template") + + +@pytest.fixture +def mock_sandbox(): + """Fixture to provide a mocked sandbox object.""" + return MagicMock() + + +def test_run_success(k8s_python_tool, mock_sandbox): + """Test that a successfully executed Python script returns stdout.""" + + # 1. Setup the mock response + mock_response = MagicMock() + mock_response.exit_code = 0 + mock_response.stdout = "Hello from Python!\n" + mock_response.stderr = "" + mock_sandbox.commands.run.return_value = mock_response + + # 2. Define the input code and calculate what the expected safe command should be + input_code = "print('Hello from Python!')" + expected_encoded = base64.b64encode(input_code.encode('utf-8')).decode('utf-8') + expected_command = f'python -c "import base64; exec(base64.b64decode(\'{expected_encoded}\').decode(\'utf-8\'))"' + + # 3. Mock the lifecycle methods and execute + with patch.object(k8s_python_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ + patch.object(k8s_python_tool, '_release_sandbox') as mock_release_sandbox: + + result = k8s_python_tool._run(input_code) + + # 4. Assertions + assert result == "Hello from Python!\n" + + # Verify the sandbox received the exact base64 wrapped command + mock_sandbox.commands.run.assert_called_once_with(expected_command) + mock_release_sandbox.assert_called_once_with(mock_sandbox, True) + + +def test_run_failure(k8s_python_tool, mock_sandbox): + """Test that a failing Python script returns the formatted error string.""" + + # 1. Setup the mock response for a syntax error + mock_response = MagicMock() + mock_response.exit_code = 1 + mock_response.stdout = "" + mock_response.stderr = "SyntaxError: invalid syntax" + mock_sandbox.commands.run.return_value = mock_response + + with patch.object(k8s_python_tool, '_get_sandbox', return_value=(mock_sandbox, False)), \ + patch.object(k8s_python_tool, '_release_sandbox') as mock_release_sandbox: + + # 2. Execute a broken script + result = k8s_python_tool._run("print('Missing quote)") + + # 3. Assertions + assert "Python execution failed (Exit Code 1):" in result + assert "SyntaxError: invalid syntax" in result + + mock_sandbox.commands.run.assert_called_once() + mock_release_sandbox.assert_called_once_with(mock_sandbox, False) + + +def test_run_finally_executes_on_exception(k8s_python_tool, mock_sandbox): + """Test that the sandbox is safely released even if the SDK crashes.""" + + # 1. Simulate the SDK throwing an unhandled exception + mock_sandbox.commands.run.side_effect = Exception("SDK Connection Timeout") + + with patch.object(k8s_python_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ + patch.object(k8s_python_tool, '_release_sandbox') as mock_release_sandbox: + + # 2. Execute the tool and assert it raises the error + with pytest.raises(Exception, match="SDK Connection Timeout"): + k8s_python_tool._run("x = 10") + + # 3. Ensure the finally block fires + mock_release_sandbox.assert_called_once_with(mock_sandbox, True) From d2f73862020b64d2945e8c075319f6e5873839e6 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Tue, 2 Jun 2026 20:15:09 +0100 Subject: [PATCH 12/45] add test for base tool --- .../tests/k8s_agent_sandbox/test_base_tool.py | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 lib/crewai-tools/tests/k8s_agent_sandbox/test_base_tool.py diff --git a/lib/crewai-tools/tests/k8s_agent_sandbox/test_base_tool.py b/lib/crewai-tools/tests/k8s_agent_sandbox/test_base_tool.py new file mode 100644 index 0000000000..cb079d921f --- /dev/null +++ b/lib/crewai-tools/tests/k8s_agent_sandbox/test_base_tool.py @@ -0,0 +1,170 @@ +import sys +import pytest +from unittest.mock import MagicMock, patch + +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool + + +class DummyK8sTool(K8sBaseTool): + """A concrete subclass to allow instantiation of the BaseTool for testing.""" + def _run(self, *args, **kwargs) -> str: + return "dummy" + + +@pytest.fixture(autouse=True) +def clean_class_state(): + """Ensure the SDK cache and atexit registry are reset between every test.""" + K8sBaseTool._sdk_cache.clear() + yield + K8sBaseTool._sdk_cache.clear() + + +@pytest.fixture +def mock_k8s_sdk(): + """Mocks the k8s_agent_sandbox SDK so we don't need it installed to run tests.""" + with patch.dict("sys.modules"): + mock_sdk = MagicMock() + mock_client_class = MagicMock() + mock_sdk.SandboxClient = mock_client_class + sys.modules["k8s_agent_sandbox"] = mock_sdk + yield mock_client_class + + +def test_import_sandbox_client_success_and_caching(mock_k8s_sdk): + """Test that the SDK is imported correctly and cached on subsequent calls.""" + # First call should import and cache + client1 = K8sBaseTool._import_sandbox_client_class() + assert client1 == mock_k8s_sdk + assert "k8s_agent_sandbox.SandboxClient" in K8sBaseTool._sdk_cache + + # Second call should fetch from cache (we can verify by replacing the sys module) + with patch.dict("sys.modules", {"k8s_agent_sandbox": None}): + client2 = K8sBaseTool._import_sandbox_client_class() + assert client2 == mock_k8s_sdk + + +def test_import_sandbox_client_missing_package(): + """Test that missing the SDK raises the custom, helpful ImportError.""" + with patch.dict("sys.modules"): + # Force an ImportError if the code tries to import it + sys.modules["k8s_agent_sandbox"] = None + + with pytest.raises(ImportError, match="The 'k8s_agent_sandbox' package is required"): + K8sBaseTool._import_sandbox_client_class() + + +def test_get_sandbox_with_claim_name(mock_k8s_sdk): + """Test that providing a claim_name bypasses creation and fetches the sandbox.""" + mock_sandbox = MagicMock() + mock_client_instance = mock_k8s_sdk.return_value + mock_client_instance.get_sandbox.return_value = mock_sandbox + + tool = DummyK8sTool(template="test-template", claim_name="my-claim") + sandbox, should_kill = tool._get_sandbox() + + assert sandbox == mock_sandbox + assert should_kill is False + mock_client_instance.get_sandbox.assert_called_once_with("my-claim") + mock_client_instance.create_sandbox.assert_not_called() + + +def test_get_sandbox_ephemeral_default(mock_k8s_sdk): + """Test the default behavior: create a fresh sandbox and mark for termination.""" + mock_sandbox = MagicMock() + mock_client_instance = mock_k8s_sdk.return_value + mock_client_instance.create_sandbox.return_value = mock_sandbox + + tool = DummyK8sTool(template="test-template", namespace="custom-ns") + sandbox, should_kill = tool._get_sandbox() + + assert sandbox == mock_sandbox + assert should_kill is True + mock_client_instance.create_sandbox.assert_called_once_with( + template="test-template", namespace="custom-ns" + ) + + +@patch("atexit.register") +def test_get_sandbox_persistent(mock_atexit_register, mock_k8s_sdk): + """Test persistent sandbox creation, caching, and atexit registration.""" + mock_sandbox = MagicMock() + mock_client_instance = mock_k8s_sdk.return_value + mock_client_instance.create_sandbox.return_value = mock_sandbox + + tool = DummyK8sTool(template="test-template", persistent=True) + + # First call: creates sandbox, registers atexit + sandbox1, should_kill1 = tool._get_sandbox() + assert sandbox1 == mock_sandbox + assert should_kill1 is False + mock_client_instance.create_sandbox.assert_called_once() + mock_atexit_register.assert_called_once_with(tool.close) + + # Second call: fetches from local _persistent_sandbox cache + sandbox2, should_kill2 = tool._get_sandbox() + assert sandbox2 == mock_sandbox + assert should_kill2 is False + # Verify create_sandbox was NOT called a second time + assert mock_client_instance.create_sandbox.call_count == 1 + + +def test_release_sandbox_should_terminate(): + """Test that release_sandbox terminates when should_terminate is True.""" + tool = DummyK8sTool(template="test") + mock_sandbox = MagicMock() + + tool._release_sandbox(mock_sandbox, should_terminate=True) + mock_sandbox.terminate.assert_called_once() + + +def test_release_sandbox_should_not_terminate(): + """Test that release_sandbox skips termination when should_terminate is False.""" + tool = DummyK8sTool(template="test") + mock_sandbox = MagicMock() + + tool._release_sandbox(mock_sandbox, should_terminate=False) + mock_sandbox.terminate.assert_not_called() + + +@patch("logging.Logger.debug") +def test_release_sandbox_handles_exception(mock_log_debug): + """Test that exceptions during termination are caught and logged.""" + tool = DummyK8sTool(template="test") + mock_sandbox = MagicMock() + mock_sandbox.terminate.side_effect = Exception("API Error") + + # Should not raise an error + tool._release_sandbox(mock_sandbox, should_terminate=True) + mock_sandbox.terminate.assert_called_once() + mock_log_debug.assert_called_once() + assert "Best-effort sandbox cleanup failed" in mock_log_debug.call_args[0][0] + + +def test_close_terminates_persistent_sandbox(mock_k8s_sdk): + """Test that the close() method successfully terminates a persistent sandbox.""" + mock_sandbox = MagicMock() + mock_k8s_sdk.return_value.create_sandbox.return_value = mock_sandbox + + tool = DummyK8sTool(template="test", persistent=True) + tool._persistent_sandbox = mock_sandbox # Simulate an active persistent sandbox + + tool.close() + + mock_sandbox.terminate.assert_called_once() + assert tool._persistent_sandbox is None + + +@patch("logging.Logger.debug") +def test_close_handles_exception(mock_log_debug): + """Test that exceptions during persistent sandbox cleanup are caught and logged.""" + tool = DummyK8sTool(template="test", persistent=True) + mock_sandbox = MagicMock() + mock_sandbox.terminate.side_effect = Exception("API Error") + tool._persistent_sandbox = mock_sandbox + + tool.close() + + mock_sandbox.terminate.assert_called_once() + assert tool._persistent_sandbox is None # Should still nullify the cache + mock_log_debug.assert_called_once() + assert "Best-effort persistent sandbox cleanup failed" in mock_log_debug.call_args[0][0] From 5fdf6a8161ab183e71c0e79f45b27861cf8cfaa8 Mon Sep 17 00:00:00 2001 From: Dima Drogovoz Date: Tue, 9 Jun 2026 16:59:04 +0100 Subject: [PATCH 13/45] update base tool --- .../tools/k8s_agent_sandbox/base_tool.py | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py index 6ba63a0aa0..9c546a906b 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -16,7 +16,7 @@ class K8sBaseTool(BaseTool): name: str = "K8sBaseTool" description: str = "Basic tool definition" - template: str = Field(description="Agent sandbox template name") + warmpool: str = Field(description="Agent sandbox warmpool name") namespace: str = Field(default="default", description="K8s namespace") @@ -51,10 +51,31 @@ def _import_sandbox_client_class(cls) -> Any: cls._sdk_cache["k8s_agent_sandbox.SandboxClient"] = SandboxClient return SandboxClient + @classmethod + def _import_sandbox_local_tunnel_connection_config_class(cls) -> Any: + """Returns the Sandbox Client that is used to communicate with k8s.""" + cached = cls._sdk_cache.get("k8s_agent_sandbox.models.SandboxLocalTunnelConnectionConfig") + if cached is not None: + return cached + try: + from k8s_agent_sandbox.models import SandboxLocalTunnelConnectionConfig # type: ignore[import-untyped] + except ImportError as exc: + raise ImportError( + "The 'k8s_agent_sandbox' package is required for k8s_agent_sandbox sandbox tools." + ) from exc + cls._sdk_cache["k8s_agent_sandbox.models.SandboxLocalTunnelConnectionConfig"] = SandboxLocalTunnelConnectionConfig + return SandboxLocalTunnelConnectionConfig + def _get_sandbox(self) -> tuple[Any, bool]: """Return (sandbox, should_kill_after_use).""" SandboxClient = self._import_sandbox_client_class() - client = SandboxClient() + SandboxLocalTunnelConnectionConfig = self._import_sandbox_local_tunnel_connection_config_class() + + client = SandboxClient( + connection_config=SandboxLocalTunnelConnectionConfig( + router_namespace=self.namespace + ) + ) if self.claim_name: return client.get_sandbox(self.claim_name), False @@ -63,8 +84,8 @@ def _get_sandbox(self) -> tuple[Any, bool]: with self._lock: if self._persistent_sandbox is None: self._persistent_sandbox = client.create_sandbox( - template=self.template, - namespace=self.namespace + warmpool=self.warmpool, + namespace=self.namespace, ) if not self._cleanup_registered: atexit.register(self.close) @@ -72,8 +93,8 @@ def _get_sandbox(self) -> tuple[Any, bool]: return self._persistent_sandbox, False sandbox = client.create_sandbox( - template=self.template, - namespace=self.namespace + warmpool=self.warmpool, + namespace=self.namespace, ) return sandbox, True From 3c502f7302db2b6a1eaacbc579e58f29c9c43901 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Wed, 17 Jun 2026 11:28:03 +0200 Subject: [PATCH 14/45] refactor tools, add lifecycle manager --- lib/crewai-tools/pyproject.toml | 5 +- lib/crewai-tools/src/crewai_tools/__init__.py | 24 +- .../src/crewai_tools/tools/__init__.py | 24 +- .../tools/k8s_agent_sandbox/README.md | 310 +++++++++++------- .../tools/k8s_agent_sandbox/__init__.py | 24 +- .../tools/k8s_agent_sandbox/base_tool.py | 159 +++------ .../tools/k8s_agent_sandbox/exec_tool.py | 48 ++- .../tools/k8s_agent_sandbox/file_tool.py | 296 ++++++++++++++--- .../k8s_agent_sandbox/lifecycle_manager.py | 169 ++++++++++ .../tools/k8s_agent_sandbox/python_tool.py | 55 ++-- .../tools/k8s_agent_sandbox/settings.py | 53 +++ .../tools/k8s_agent_sandbox/toolset.py | 121 +++++++ .../tests/k8s_agent_sandbox/test_base_tool.py | 170 ---------- .../tests/k8s_agent_sandbox/test_exec_tool.py | 84 ----- .../tests/k8s_agent_sandbox/test_file_tool.py | 140 -------- .../k8s_agent_sandbox/test_python_tool.py | 87 ----- .../tests/tools/k8s_agent_sandbox/conftest.py | 102 ++++++ .../tools/k8s_agent_sandbox/test_base_tool.py | 94 ++++++ .../tools/k8s_agent_sandbox/test_exec_tool.py | 31 ++ .../tools/k8s_agent_sandbox/test_file_tool.py | 252 ++++++++++++++ .../test_lifecycle_manager.py | 145 ++++++++ .../k8s_agent_sandbox/test_python_tool.py | 32 ++ .../tools/k8s_agent_sandbox/test_toolset.py | 72 ++++ 23 files changed, 1679 insertions(+), 818 deletions(-) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py delete mode 100644 lib/crewai-tools/tests/k8s_agent_sandbox/test_base_tool.py delete mode 100644 lib/crewai-tools/tests/k8s_agent_sandbox/test_exec_tool.py delete mode 100644 lib/crewai-tools/tests/k8s_agent_sandbox/test_file_tool.py delete mode 100644 lib/crewai-tools/tests/k8s_agent_sandbox/test_python_tool.py create mode 100644 lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py create mode 100644 lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py create mode 100644 lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py create mode 100644 lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py create mode 100644 lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py create mode 100644 lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py create mode 100644 lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 2705345100..0747af9ec6 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -149,9 +149,10 @@ e2b = [ ] k8s_agent_sandbox = [ - "k8s-agent-sandbox>=0.4.5" + "k8s-agent-sandbox @ git+https://github.com/kubernetes-sigs/agent-sandbox.git@b1ef44f5800491a5044a4816fd2e6b8918205f97#subdirectory=clients/python/agentic-sandbox-client", ] - +[tool.hatch.metadata] +allow-direct-references = true [tool.uv] exclude-newer = "3 days" diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 74c1c32d64..7dce37b3fb 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -217,10 +217,15 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools -from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool -from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool -from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool -from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool +from crewai_tools.tools.k8s_agent_sandbox import ( + K8sAgentSandboxToolClientSettings, + K8sAgentSandboxToolSandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxBaseTool, + K8sAgentSandboxExecTool, + K8sAgentSandboxPythonTool, + K8sAgentSandboxFileTool, +) __all__ = [ @@ -276,6 +281,13 @@ "InvokeCrewAIAutomationTool", "JSONSearchTool", "JinaScrapeWebsiteTool", + "K8sAgentSandboxToolClientSettings", + "K8sAgentSandboxToolSandboxSettings", + "K8sAgentSandboxToolset", + "K8sAgentSandboxBaseTool", + "K8sAgentSandboxExecTool", + "K8sAgentSandboxFileTool", + "K8sAgentSandboxPythonTool", "LinkupSearchTool", "LlamaIndexTool", "MCPServerAdapter", @@ -334,10 +346,6 @@ "YoutubeVideoSearchTool", "ZapierActionTool", "ZapierActionTools", - "K8sBaseTool" - "K8sExecTool" - "K8sPythonTool", - "K8sFileTool", ] __version__ = "1.15.8" diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index 58c0603c9f..34fc0add3b 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -204,10 +204,15 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools -from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool -from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool -from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool -from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool +from crewai_tools.tools.k8s_agent_sandbox import ( + K8sAgentSandboxToolClientSettings, + K8sAgentSandboxToolSandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxBaseTool, + K8sAgentSandboxExecTool, + K8sAgentSandboxPythonTool, + K8sAgentSandboxFileTool, +) __all__ = [ @@ -260,6 +265,13 @@ "InvokeCrewAIAutomationTool", "JSONSearchTool", "JinaScrapeWebsiteTool", + "K8sAgentSandboxToolClientSettings", + "K8sAgentSandboxToolSandboxSettings", + "K8sAgentSandboxToolset", + "K8sAgentSandboxBaseTool", + "K8sAgentSandboxExecTool", + "K8sAgentSandboxFileTool", + "K8sAgentSandboxPythonTool", "LinkupSearchTool", "LlamaIndexTool", "MDXSearchTool", @@ -316,8 +328,4 @@ "YoutubeChannelSearchTool", "YoutubeVideoSearchTool", "ZapierActionTools", - "K8sBaseTool", - "K8sExecTool", - "K8sPythonTool", - "K8sFileTool", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index 1fa9ea19ad..1c233ec4ee 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -1,166 +1,245 @@ -## How to install +# K8s Agent Sandbox Tools -```bash -python -m venv venv -. venv/bin/activate -pip install crewai-tools -pip install -e "./lib/crewai-tools[k8s_agent_sandbox]" # from the root of this repository +Run shell commands, execute Python, and manage files inside an [Kubernetes Agent Sandbox](https://github.com/kubernetes-sigs/agent-sandbox). K8s Agent Sandbox enables easy management of isolated, stateful, singleton workloads, ideal for use cases like AI agent runtimes. + +Three tools are provided so you can pick what the agent actually needs: + +- **`K8sAgentSandboxExecTool`** — run a shell command. +- **`K8sAgentSandboxFileTool`** — read / write / list / delete files. +- **`K8sAgentSandboxPythonTool`** — run a Python code. + +## Installation + +```shell +uv add "crewai-tools[k8s_agent_sandbox]" +# or +pip install "crewai-tools[k8s_agent_sandbox]" ``` -## Example how to use ExecTool +## Sandbox toolset -```python -import os -from crewai import Agent, Task, Crew, Process -from crewai_tools import K8sExecTool +Instead of configuring sandbox-related settngs for each of the tools separately, the `K8sAgentSandboxToolset` class must be used. -# Instantiate your tool -k8s_exec_tool = K8sExecTool(template="simple-sandbox-template", namespace="default") +All tools that are meant to share the same sandbox settings and sandbox lifecycle has to be added to the same instance of the +`K8sAgentSandboxToolset` class. -# Define the Agent -devops_agent = Agent( - role='Kubernetes DevOps Engineer', - goal='Execute commands in a secure sandbox to inspect the environment safely.', - backstory=( - "You are a senior DevOps engineer specializing in Kubernetes. " - "You use isolated pod sandboxes to run scripts, check system details, " - "and securely verify environments without risking the host system." - ), - verbose=True, - allow_delegation=False, - tools=[k8s_exec_tool] +### Toolset example + +```python +from crewai_tools import ( + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxExecTool, + K8sAgentSandboxFileTool, ) -# Define the Task -inspect_task = Task( - description=( - "Use your K8s sandbox execution tool to find out what operating system " - "the sandbox pod is running. Run a command like 'cat /etc/os-release' " - "and summarize the results." +toolset = K8sAgentSandboxToolset.create( + # Sandbox settigns for all tools in this toolset. + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", ), - expected_output="A brief summary of the sandbox pod's operating system based on the command output.", - # expected_output="Only name of the Operating System.", - agent=devops_agent, ) -# Assemble the Crew -k8s_crew = Crew( - agents=[devops_agent], - tasks=[inspect_task], - process=Process.sequential -) +# Adding the tools to the toolset. +exec_tool = K8sAgentSandboxExecTool(toolset=toolset) +file_tool = K8sAgentSandboxFileTool(toolset=toolset) +``` -# Run the Crew -if __name__ == "__main__": - if not os.environ.get("OPENAI_API_KEY"): - print("WARNING: OPENAI_API_KEY environment variable is not set.") +### Lifecycle modes - print("Starting the CrewAI execution...") - result = k8s_crew.kickoff() +The `K8sAgentSandboxToolset` supports three sandbox lifecycle modes and they can be set on creation of the toolset instance +with the `K8sAgentSandboxToolset.create` factory method: - print("\n================================================") - print("FINAL RESULT FROM THE AGENT:") - print("================================================") - print(result) -``` +| Mode | When the sandbox is created | When it is killed | +| --- | --- | --- | +| **Ephemeral** (default, `persistent=False`) | On every `_run` call of the tool | At the end of that same call | +| **Persistent** (`persistent=True`) | Lazily on first use | At process exit (via `atexit`), or manually via `toolset.close()` | +| **Attach** (`claim_name="…"`) | Never — the tool attaches to an existing sandbox | Never — the tool will not kill a sandbox it did not create | -## Example how to use PythonTool +Ephemeral mode is the safe default: nothing leaks if the agent forgets to clean up. Use persistent mode when you want filesystem state or installed packages to carry across steps. + +K8s agent sandboxes also auto-expire after an idle timeout. Tune it via `sandbox_timeout` (seconds, default `300`). + +### Sandbox client settings + +By default the `K8sAgentSandboxToolset.create` creates a client in the "Local Tunnel" configuration. To specify another configurations, +the `client_settings` keyword argument has to be used. + +The following example shows setting up the sandbox client to use the "Gateway" configuration (learn more about all configurations [here](https://github.com/kubernetes-sigs/agent-sandbox/tree/main/clients/python/agentic-sandbox-client#usage-examples)): ```python -import os -from crewai import Agent, Task, Crew, Process -from crewai_tools import K8sPythonTool +from crewai_tools import ( + K8sAgentSandboxToolClientSettings as ClientSettings # <- import this. + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxExecTool, + K8sAgentSandboxFileTool, +) +from k8s_agent_sandbox.models import SandboxGatewayConnectionConfig -# Instantiate your tool -k8s_python_tool = K8sPythonTool(template="simple-sandbox-template", namespace="default") -# Define the Agent -data_agent = Agent( - role='Secure Environment Data Engineer', - goal='Execute Python scripts in a secure sandbox to process data and return results.', - backstory=( - "You are a Data Engineer who relies on isolated Kubernetes sandboxes to " - "execute raw Python code. You always ensure your code runs successfully " - "and returns the exact mathematical or data-driven results requested." +toolset = K8sAgentSandboxToolset.create( + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", ), - verbose=True, - allow_delegation=False, - tools=[k8s_python_tool] + client_settings=ClientSettings( + connection_config=SandboxGatewayConnectionConfig( + gateway_name="external-http-gateway", # Name of the Gateway resource + ) + ) ) -# Define the Task -python_task = Task( - description=( - "Use your K8s sandbox Python tool to execute a Python script that " - "calculates the first 20 Fibonacci numbers. The script should print " - "the resulting list. Extract and summarize the printed output." +``` + +## Examples + +### Ephemeral sandboxes + +```python +from crewai_tools import ( + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, +) + +toolset = K8sAgentSandboxToolset.create( + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", ), - expected_output="A list of the first 20 Fibonacci numbers generated by the Python script.", - agent=data_agent, ) -# Assemble the Crew -python_crew = Crew( - agents=[data_agent], - tasks=[python_task], - process=Process.sequential +exec_tool = K8sAgentSandboxExecTool(toolset=toolset) +file_tool = K8sAgentSandboxFileTool(toolset=toolset) +``` + +### Persistent sandboxes + +```python +from crewai_tools import ( + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, ) -# Run the Crew -if __name__ == "__main__": - if not os.environ.get("OPENAI_API_KEY"): - print("WARNING: OPENAI_API_KEY environment variable is not set.") +toolset = K8sAgentSandboxToolset.create( + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", + ), + persistent=True # <-- Add this. +) - print("Starting the CrewAI execution for Python Tool...") - result = python_crew.kickoff() +exec_tool = K8sAgentSandboxExecTool(toolset=toolset) +file_tool = K8sAgentSandboxFileTool(toolset=toolset) +``` - print("\n================================================") - print("FINAL RESULT FROM THE AGENT:") - print("================================================") - print(result) +### Attach to an existing sandbox + +```python +from crewai_tools import ( + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, +) + +toolset = K8sAgentSandboxToolset.create( + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", + ), + claim_name="" # <-- Add this. +) + +exec_tool = K8sAgentSandboxExecTool(toolset=toolset) +file_tool = K8sAgentSandboxFileTool(toolset=toolset) ``` -## Example how to use FileTool +## Tool arguments + +### `K8sAgentSandboxExecTool` +- `command: str` — shell command to run. +- `timeout: float | None` — seconds. + +### `K8sAgentSandboxFileTool` +- `action: "read" | "write" | "append" | "list" | "delete" | "mkdir" | "info" | "exists"` +- `path: str` — absolute path inside the sandbox. +- `content: str | None` — required for `append`; optional for `write`. +- `binary: bool` — if `True`, `content` is base64 on write / returned as base64 on read. + +### `K8sAgentSandboxPythonTool` +- `code: str` — source to execute. +- `timeout: float | None` — seconds. + + +## Security considerations + +These tools hand the LLM arbitrary shell, Python, and filesystem access inside sandbox. The threat model to keep in mind: + +- **Prompt-injection is a code-execution vector.** If the agent ingests untrusted content (web pages, scraped documents, user-supplied files, emails, search results), a malicious instruction hidden in that content can coerce the agent into issuing commands to `K8sAgentSandboxExecTool` / `K8sAgentSandboxPythonTool`. Treat any pipeline that feeds untrusted text into an agent that also has these tools as equivalent to remote code execution — the LLM is the attacker's shell. +- **Ephemeral mode (the default) is the main blast-radius control.** A fresh sandbox is created per call and killed at the end, so injected commands cannot persist state, exfiltrate long-lived secrets, or build up tooling across turns. Leave `persistent=False` unless you have a concrete reason to change it. +- **Avoid this specific combination:** + - untrusted content in the agent's context, **plus** + - `persistent=True` or an explicit long-lived `claim_name`, **plus** + - a large `sandbox_timeout`. + + +## Full Example ```python import os from crewai import Agent, Task, Crew, Process -from crewai_tools import K8sFileTool +from crewai_tools import ( + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxPythonTool, + K8sAgentSandboxExecTool, + K8sAgentSandboxFileTool, +) -# Instantiate your tool -k8s_file_tool = K8sFileTool(template="simple-sandbox-template", namespace="default") +# Create your toolset +toolset = K8sAgentSandboxToolset.create( + sandbox_settings=SandboxSettings( + warmpool="your-warmpool", + namespace="default", + ), + persistent=True, +) + +# Instantiate your tools +exec_tool = K8sAgentSandboxExecTool(toolset=toolset) +file_tool = K8sAgentSandboxFileTool(toolset=toolset) +python_tool = K8sAgentSandboxPythonTool(toolset=toolset) # Define the Agent -sys_agent = Agent( - role='Kubernetes Systems Engineer', - goal='Create, modify, and read configuration files securely within pod sandboxes.', +devops_agent = Agent( + role='Kubernetes DevOps Engineer', + goal='Execute commands in a secure sandbox to inspect the environment safely.', backstory=( - "You are a systems engineer who manages configuration files in containerized " - "environments. You use the K8s sandbox file tool to accurately write necessary " - "files to the filesystem and read them back to verify their integrity." + "You are a senior DevOps engineer specializing in Kubernetes. " + "You use isolated pod sandboxes to run scripts, check system details, " + "and securely verify environments without risking the host system." ), verbose=True, allow_delegation=False, - tools=[k8s_file_tool] + tools=[exec_tool, file_tool, python_tool] ) # Define the Task -file_task = Task( +inspect_task = Task( description=( - "Perform the following two steps using your K8s sandbox file tool:\n" - "1. Write a file at the path './crewai_test.txt' with the content: " - "'Hello from CrewAI! The sandbox file tool is working perfectly.'\n" - "2. Read the file you just created at './crewai_test.txt' to verify its contents.\n" - "Summarize your actions and output the exact text you read from the file." + "Use your K8s sandbox execution tool to find out what operating system " + "the sandbox pod is running. Run a command like 'cat /etc/os-release' " + "and summarize the results." ), - expected_output="Confirmation that the file was written, along with the exact text read back from the file.", - agent=sys_agent, + expected_output="A brief summary of the sandbox pod's operating system based on the command output.", + # expected_output="Only name of the Operating System.", + agent=devops_agent, ) # Assemble the Crew -file_crew = Crew( - agents=[sys_agent], - tasks=[file_task], +k8s_crew = Crew( + agents=[devops_agent], + tasks=[inspect_task], process=Process.sequential ) @@ -169,11 +248,12 @@ if __name__ == "__main__": if not os.environ.get("OPENAI_API_KEY"): print("WARNING: OPENAI_API_KEY environment variable is not set.") - print("Starting the CrewAI execution for File Tool...") - result = file_crew.kickoff() + print("Starting the CrewAI execution...") + result = k8s_crew.kickoff() print("\n================================================") print("FINAL RESULT FROM THE AGENT:") print("================================================") print(result) ``` + diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py index f1b1ed4af4..9ea6d3098d 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py @@ -1,12 +1,20 @@ -from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool -from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool -from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool -from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool +from crewai_tools.tools.k8s_agent_sandbox.settings import ( + K8sAgentSandboxToolClientSettings, + K8sAgentSandboxToolSandboxSettings, +) +from crewai_tools.tools.k8s_agent_sandbox.toolset import K8sAgentSandboxToolset +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sAgentSandboxBaseTool +from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sAgentSandboxExecTool +from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sAgentSandboxPythonTool +from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sAgentSandboxFileTool __all__ = [ - "K8sBaseTool", - "K8sExecTool", - "K8sPythonTool", - "K8sFileTool", + "K8sAgentSandboxToolClientSettings", + "K8sAgentSandboxToolSandboxSettings", + "K8sAgentSandboxToolset", + "K8sAgentSandboxBaseTool", + "K8sAgentSandboxExecTool", + "K8sAgentSandboxPythonTool", + "K8sAgentSandboxFileTool", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py index 9c546a906b..20500e3739 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -1,127 +1,60 @@ -import atexit +from typing import Any, Callable, Optional +import time import logging -from typing import Any, Optional -from pydantic import Field -import threading -from typing import ClassVar +from pydantic import ( + Field, + SkipValidation, +) +from abc import abstractmethod +from pydantic import Field from crewai.tools import BaseTool -from pydantic import ConfigDict, Field, PrivateAttr, SecretStr - -logger = logging.getLogger(__name__) +from .toolset import K8sAgentSandboxToolset -class K8sBaseTool(BaseTool): - name: str = "K8sBaseTool" - description: str = "Basic tool definition" +logger = logging.getLogger(__name__) - warmpool: str = Field(description="Agent sandbox warmpool name") - namespace: str = Field(default="default", description="K8s namespace") +DEFAULT_TOOL_TIMEOUT_SEC = 60 - persistent: bool = Field( - default=False, - description=( - "If True, reuse one sandbox across all calls to this tool instance " - "and kill it at process exit. Default False creates and kills a " - "fresh sandbox per call." - ), +class K8sAgentSandboxBaseTool(BaseTool, arbitrary_types_allowed=True): + name: str + description: str + toolset: SkipValidation[K8sAgentSandboxToolset] = Field( + exclude=True, + description="The instance of the ``K8sAgentSandboxToolset``." ) - claim_name: Any | None = Field(default=None) - _persistent_sandbox: Any | None = PrivateAttr(default=None) - _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) - _cleanup_registered: bool = PrivateAttr(default=False) + def model_post_init(self, __context: Any) -> None: + self.toolset.add_tool(self) + super().model_post_init(__context) - _sdk_cache: ClassVar[dict[str, Any]] = {} - @classmethod - def _import_sandbox_client_class(cls) -> Any: - """Returns the Sandbox Client that is used to communicate with k8s.""" - cached = cls._sdk_cache.get("k8s_agent_sandbox.SandboxClient") - if cached is not None: - return cached - try: - from k8s_agent_sandbox import SandboxClient # type: ignore[import-untyped] - except ImportError as exc: - raise ImportError( - "The 'k8s_agent_sandbox' package is required for k8s_agent_sandbox sandbox tools." - ) from exc - cls._sdk_cache["k8s_agent_sandbox.SandboxClient"] = SandboxClient - return SandboxClient - - @classmethod - def _import_sandbox_local_tunnel_connection_config_class(cls) -> Any: - """Returns the Sandbox Client that is used to communicate with k8s.""" - cached = cls._sdk_cache.get("k8s_agent_sandbox.models.SandboxLocalTunnelConnectionConfig") - if cached is not None: - return cached - try: - from k8s_agent_sandbox.models import SandboxLocalTunnelConnectionConfig # type: ignore[import-untyped] - except ImportError as exc: - raise ImportError( - "The 'k8s_agent_sandbox' package is required for k8s_agent_sandbox sandbox tools." - ) from exc - cls._sdk_cache["k8s_agent_sandbox.models.SandboxLocalTunnelConnectionConfig"] = SandboxLocalTunnelConnectionConfig - return SandboxLocalTunnelConnectionConfig - - def _get_sandbox(self) -> tuple[Any, bool]: - """Return (sandbox, should_kill_after_use).""" - SandboxClient = self._import_sandbox_client_class() - SandboxLocalTunnelConnectionConfig = self._import_sandbox_local_tunnel_connection_config_class() - - client = SandboxClient( - connection_config=SandboxLocalTunnelConnectionConfig( - router_namespace=self.namespace - ) - ) - - if self.claim_name: - return client.get_sandbox(self.claim_name), False - - if self.persistent: - with self._lock: - if self._persistent_sandbox is None: - self._persistent_sandbox = client.create_sandbox( - warmpool=self.warmpool, - namespace=self.namespace, - ) - if not self._cleanup_registered: - atexit.register(self.close) - self._cleanup_registered = True - return self._persistent_sandbox, False - - sandbox = client.create_sandbox( - warmpool=self.warmpool, - namespace=self.namespace, - ) - return sandbox, True - - def _release_sandbox(self, sandbox, should_terminate) -> None: - if not should_terminate: - return + def _run(self, *args, **kwargs: Any) -> dict[str, Any]: try: - sandbox.terminate() - except Exception: - logger.debug( - "Best-effort sandbox cleanup failed after ephemeral use; " - "the sandbox may need manual termination.", - exc_info=True, - ) - - def close(self) -> None: - """Kill the cached persistent sandbox if one exists.""" - with self._lock: - sandbox = self._persistent_sandbox - self._persistent_sandbox = None - if sandbox is None: - return - try: - sandbox.terminate() - except Exception: - logger.debug( - "Best-effort persistent sandbox cleanup failed at close(); " - "the sandbox may need manual termination.", - exc_info=True, - ) + sandbox = self.toolset.lifecycle_manager.acquire_sandbox() + return self._run_with_sandbox(sandbox, *args, **kwargs) + finally: + self.toolset.lifecycle_manager.release_sandbox() + + @abstractmethod + def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: + pass + + +def create_timeout_tracker(timeout: int) -> Callable[[], int]: + """ + This returns a helper tracker to track one global timeout + in case where we run multple sandbox commands in one tool action. + """ + + start_time = int(time.time()) + + def get_remaining(): + remaining_time = timeout - (int(time.time()) - start_time) + if remaining_time < 0: + raise TimeoutError("Timeout. Cannot continue the tool action.") + return remaining_time if remaining_time >= 0 else 0 + + return get_remaining diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index 684c59c506..bf93aeec9c 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -1,19 +1,41 @@ from typing import Any -from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool +from pydantic import BaseModel, Field +from k8s_agent_sandbox.sandbox import Sandbox +from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( + K8sAgentSandboxBaseTool, + DEFAULT_TOOL_TIMEOUT_SEC, +) -class K8sExecTool(K8sBaseTool): - name: str = "K8sExecTool" + +class k8sAgentSandboxExecToolSchema(BaseModel): + command: str = Field(..., description="Shell command to execute in the sandbox.") + timeout: int = Field( + default=DEFAULT_TOOL_TIMEOUT_SEC, + description="Maximum seconds to wait for the command to finish.", + ) + + +class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): + name: str = "K8s Agent Sandbox Exec Tool" description: str = "Executes shell commands inside an isolated Kubernetes pod sandbox." + args_schema: type[BaseModel] = k8sAgentSandboxExecToolSchema + + def _run_with_sandbox(self, sandbox: Sandbox, *args, **kwargs) -> dict[str, Any]: + return self._run_command(sandbox, *args, **kwargs) + + def _run_command( + self, + sandbox: Sandbox, + command: str, + timeout: int, + ) -> dict[str, Any]: + result = sandbox.commands.run(command, timeout=timeout) + return { + "exit_code": result.exit_code, + "stdout": result.stdout, + "stderr": result.stderr, + } + - def _run(self, command: str, **kwargs: Any) -> str: - sandbox, should_terminate = self._get_sandbox() - try: - response = sandbox.commands.run(command) - if response.exit_code == 0: - return response.stdout - else: - return f"Command execution failed (Exit Code {response.exit_code}):\n{response.stderr}" - finally: - self._release_sandbox(sandbox, should_terminate) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 81238dd107..18ab85c9e2 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -1,59 +1,259 @@ +from typing import Callable, Literal import base64 import shlex -from typing import Any, Optional -from pydantic import Field -from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool +from typing import Any +import posixpath +import logging +from textwrap import dedent +from pydantic import ( + Field, + BaseModel, + model_validator, +) +from k8s_agent_sandbox.models import FileEntry +from k8s_agent_sandbox.sandbox import Sandbox -class K8sFileTool(K8sBaseTool): - name: str = "K8sFileTool" - description: str = ( - "Reads, writes, or lists files inside an isolated Kubernetes pod sandbox. " - "Requires an 'action' ('read', 'write', or 'list') and a 'file_path'." +from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( + DEFAULT_TOOL_TIMEOUT_SEC, + K8sAgentSandboxBaseTool, + create_timeout_tracker, +) + + +logger = logging.getLogger(__name__) + +FileAction = Literal[ + "read", "write", "append", "list", "delete", "mkdir", "info", "exists" +] + +class K8sAgentSandboxFileToolSchema(BaseModel): + action: FileAction = Field( + ..., + description=dedent(""" + The filesystem action to perform:" + - read: returns file contents. + - write: create or replace a file with content. + - append: append content to an existing file — use this for + writing large files in chunks to avoid hitting tool-call + size limits. + - list: lists a directory. + - delete: removes a file/directory. + - mkdir: creates a directory. + - info: Not currntly supported. Returns file metadata. + - exists: returns a boolean for whether the path exists. + """) ) - persistent: bool = Field( - default=True, + path: str = Field(..., description="The path inside the sandbox which is relative to a sandbox's working directory.") + content: str | None = Field( + default=None, description=( - "File tool must use a persistent sandbox." + "Content to write or append. If omitted for 'write', an empty file " + "is created. For files larger than a few KB, prefer one 'write' " + "with empty content followed by multiple 'append' calls of ~4KB " + "each to stay within tool-call payload limits." ), ) + binary: bool = Field( + default=False, + description=( + "For 'write'/'append': treat content as base64 and upload raw " + "bytes. For 'read': return contents as base64 instead of decoded " + "utf-8." + ), + ) + + timeout: int | None = Field( + default=DEFAULT_TOOL_TIMEOUT_SEC, + description="Maximum seconds to wait for the action to finish.", + ) + + @model_validator(mode="after") + def _validate_action_args(self) -> 'K8sAgentSandboxFileToolSchema': + if self.action == "append" and self.content is None: + raise ValueError( + "action='append' requires 'content'. Pass the chunk to append " + "in the 'content' field." + ) + return self + + +class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): + """Read, write, and manage files inside an K8s agent sandbox. + + Notes: + - Most useful with `persistent=True` or an explicit `sandbox_id`. With + the default ephemeral mode, files disappear when this tool call + finishes. + """ + + name: str = "K8s Agent Sandbox Files Tool" + description: str = ( + "Perform filesystem operations inside an K8s agent sandbox: read a file, " + "write content to a path, append content to an existing file, list a " + "directory, delete a path, make a directory, fetch file metadata, or " + "check whether a path exists. For files larger than a few KB, create " + "the file with action='write' and empty content, then send the body " + "via multiple 'append' calls of ~4KB each to stay within tool-call " + "payload limits." + ) + args_schema: type[BaseModel] = K8sAgentSandboxFileToolSchema + + def _run_with_sandbox( + self, + sandbox: Sandbox, + action: FileAction, + path: str, + timeout: int, + binary: bool, + content: str | None = None, + ) -> dict[str, Any]: + + if action == "read": + return self._read(sandbox, path, binary=binary, timeout=timeout) + if action == "write": + return self._write( + sandbox, + path, + content or "", + binary=binary, + timeout_tracker=create_timeout_tracker(timeout), + ) + if action == "append": + return self._append( + sandbox, + path, + content or "", + binary=binary, + timeout_tracker=create_timeout_tracker(timeout), + ) + if action == "list": + return self._list(sandbox, path, timeout=timeout) + if action == "delete": + return self._delete(sandbox, path, timeout=timeout) + if action == "mkdir": + self._mkdir(sandbox, path, timeout=timeout) + return {"status": "created", "path": path} + if action == "info": + return self._info(sandbox, path) + if action == "exists": + result = sandbox.files.exists(path, timeout=timeout) + return {"path": path, "exists": result} - def _run(self, action: str, file_path: str, content: Optional[str] = None, **kwargs: Any) -> str: - sandbox, should_terminate = self._get_sandbox() + raise ValueError(f"Unknown action: {action}") + def _read( + self, sandbox: Sandbox, path: str, *, binary: bool, timeout: int, + ) -> dict[str, Any]: + + data: bytes = sandbox.files.read(path, timeout=timeout) + if binary: + return { + "path": path, + "encoding": "base64", + "content": base64.b64encode(data).decode("ascii"), + } + try: + return {"path": path, "encoding": "utf-8", "content": data.decode("utf-8")} + except UnicodeDecodeError: + return { + "path": path, + "encoding": "base64", + "content": base64.b64encode(data).decode("ascii"), + "note": "File was not valid utf-8; returned as base64.", + } + + def _write( + self, + sandbox: Sandbox, + path: str, + content: str, + *, + binary: bool, + timeout_tracker: Callable[[], int], + ) -> dict[str, Any]: + + payload = base64.b64decode(content) if binary else content.encode("utf-8") + self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) + sandbox.files.write(path, payload, timeout=timeout_tracker()) + return {"status": "written", "path": path, "bytes": len(payload)} + + def _append( + self, + sandbox: Sandbox, + path: str, + content: str, + *, + binary: bool, + timeout_tracker: Callable[[], int], + ) -> dict[str, Any]: + chunk: bytes = base64.b64decode(content) if binary else content.encode("utf-8") + self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) + + existing = sandbox.files.read(path, timeout=timeout_tracker()) + payload = existing + chunk + sandbox.files.write(path, payload, timeout=timeout_tracker()) + return { + "status": "appended", + "path": path, + "appended_bytes": len(chunk), + "total_bytes": len(payload), + } + + def _list(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any]: + entries = sandbox.files.list(path, timeout=timeout) + return { + "path": path, + "entries": [self._entry_to_dict(e) for e in entries], + } + + def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any]: + # TODO: Fall back to deleting with shell command. + # Use normal file delete API when it is available in SDK. + command = f"rm -r {shlex.quote(path)}" + try: + result = sandbox.commands.run(command, timeout=timeout) + except Exception as e: + raise RuntimeError(f"Unexpected error during the run of the deletion command '{command}'. Error: {e}.") + + if result.exit_code != 0: + raise RuntimeError(f"Cannot delete directory {path}. Error: {result.stderr}.") + + return {"status": "deleted", "path": path} + + def _info(self, sandbox: Sandbox, path: str) -> dict[str, Any]: + raise NotImplementedError("The info action is not currently supoported.") + + def _mkdir(self, sandbox: Sandbox, path: str, *, timeout: int): try: - action = action.lower() - safe_path = shlex.quote(file_path) - - if action == "read": - inner_cmd = f"cat {safe_path}" - - elif action in ["write", "append"]: - if not content: - return "Error: 'content' parameter is required for the 'write' action." - # Base64 encode the content to safely write multiline text/symbols - encoded_content = base64.b64encode(content.encode('utf-8')).decode('utf-8') - redirect_operator = ">" if action == "write" else ">>" - inner_cmd = f"echo '{encoded_content}' | base64 -d {redirect_operator} {safe_path}" - - elif action == "delete": - inner_cmd = f"rm -rf {safe_path}" - - elif action == "list": - inner_cmd = f"ls -la {file_path}" - - else: - return ( - f"Error: Unknown action '{action}'. " - "Supported actions are 'read', 'write', 'append', 'delete', and 'list'." - ) - safe_inner_cmd = shlex.quote(inner_cmd) - response = sandbox.commands.run(f"sh -c {safe_inner_cmd}") - if response.exit_code == 0: - if action in ["read", "list"]: - return response.stdout - return f"Successfully executed '{action}' on {file_path}." - else: - return f"Error executing '{action}' on {file_path}:\n{response.stderr}" - finally: - self._release_sandbox(sandbox, should_terminate) + result = sandbox.commands.run( + f"mkdir -p {shlex.quote(path)}", + timeout=timeout, + ) + except Exception as e: + raise RuntimeError(f"Unexpected error during the creation of a directory {path}. Error: {e}.") + + if result.exit_code != 0: + raise RuntimeError(f"Cannot create directory {path}. Error: {result.stderr}.") + + def _ensure_parent_dir(self, sandbox: Sandbox, path: str, timeout: int): + parent = posixpath.dirname(path) + if not parent or parent in ("/", "."): + return + + return self._mkdir(sandbox, parent, timeout=timeout) + + @staticmethod + def _entry_to_dict(entry: FileEntry) -> dict[str, Any]: + + fields = ( + "name", + "type", + "size", + "mod_time", + ) + result: dict[str, Any] = {} + for field in fields: + value = getattr(entry, field, None) + result[field] = value + return result diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py new file mode 100644 index 0000000000..f009fad422 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -0,0 +1,169 @@ +from threading import Lock +from abc import ABC, abstractmethod +import logging + +from k8s_agent_sandbox.exceptions import SandboxNotFoundError +from k8s_agent_sandbox.sandbox import Sandbox + + +from .settings import ( + K8sAgentSandboxToolClientSettings, + K8sAgentSandboxToolSandboxSettings, +) + + +logger = logging.getLogger(__name__) + +class K8sAgentSandboxLifecycleManager(ABC): + """ + The base lifecycle manager of the K8s Agent Sandbox. + """ + def __init__( + self, + client_settings: K8sAgentSandboxToolClientSettings, + sandbox_settings: K8sAgentSandboxToolSandboxSettings, + ): + self._sandbox_settings = sandbox_settings + self._client_settings = client_settings or K8sAgentSandboxToolClientSettings() + + self._client = self._client_settings.client + + self._lock = Lock() + self._closed = False + + self._sandbox: Sandbox | None = None + self._sandbox_acquired: bool = False + + + def acquire_sandbox(self) -> Sandbox: + """ + Acquires a sandbox based on this implementation and returns it. + In order tto be acquired again by someone else, it has to be + released first by the :meth:`release_sandbox` method. + """ + if self._closed: + raise RuntimeError("Attempt to acquire a sandbox from a closed helper.") + self._lock.acquire() + self._sandbox_acquired = True + return self._acquire_sandbox() + + def release_sandbox(self): + """ + Releases a sandbox that is previously acquired. + """ + if self._closed: + return + if not self._sandbox_acquired: + return + try: + self._release_sandbox() + finally: + self._lock.release() + + def close(self): + """Closes the lifecycle manager.""" + if self._closed: + return + + self._close() + + @abstractmethod + def _acquire_sandbox(self) -> Sandbox: + pass + + @abstractmethod + def _release_sandbox(self): + pass + + @abstractmethod + def _close(self): + pass + + def _terminate_sandbox(self): + if self._sandbox is None: + return + + self._sandbox.terminate() + self._sandbox = None + + def _create_sandbox(self) -> Sandbox: + return self._client.create_sandbox( + warmpool=self._sandbox_settings.warmpool, + namespace=self._sandbox_settings.namespace, + shutdown_after_seconds=self._sandbox_settings.sandbox_timeout, + ) + + +class EphemeralModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleManager): + """ + Lifecycle manager that creates new sandbox on each call of the `acquire_sandbox` + method and terminates it on `release_sandbox`. + """ + def _acquire_sandbox(self) -> Sandbox: + self._sandbox = self._create_sandbox() + return self._sandbox + + def _release_sandbox(self): + self._terminate_sandbox() + + def _close(self): + self._terminate_sandbox() + + +class AttachModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleManager): + """ + Lifecycle manager that attaches to existing sandbox by its claim name without creating a new sandbox. + Sandbox is also not terminated on the `release_sandbox`. + """ + def __init__( + self, + client_settings: K8sAgentSandboxToolClientSettings, + sandbox_settings: K8sAgentSandboxToolSandboxSettings, + claim_name: str, + ): + super().__init__(client_settings, sandbox_settings) + self._claim_name = claim_name + + def _acquire_sandbox(self) -> Sandbox: + try: + self._sandbox = self._client.get_sandbox( + self._claim_name, + namespace=self._sandbox_settings.namespace, + ) + except SandboxNotFoundError: + self._sandbox = None + + if self._sandbox is not None: + return self._sandbox + + raise SandboxNotFoundError( + f"A sandbox with sandbox claim '{self._claim_name}' " + "is expected to exist, but cannot be found." + ) + + def _release_sandbox(self): + pass + + def _close(self): + pass + + +class PersistentModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleManager): + """ + Lifecycle manager that manages a sandbox which remains the same across all calls + of the`acquire_sandbox` and `release_sandbox`. + """ + def _acquire_sandbox(self): + if self._sandbox is not None: + return self._sandbox + + self._sandbox = self._create_sandbox() + return self._sandbox + + def _release_sandbox(self): + pass + + def _close(self): + self._terminate_sandbox() + + diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index 48d3634bf0..e15e3d4688 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -1,30 +1,41 @@ -import base64 from typing import Any -from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool +from pydantic import ( + BaseModel, + Field, +) +from k8s_agent_sandbox.sandbox import Sandbox +from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( + K8sAgentSandboxBaseTool, + DEFAULT_TOOL_TIMEOUT_SEC, + create_timeout_tracker, +) -class K8sPythonTool(K8sBaseTool): - name: str = "K8sPythonTool" + +class k8sAgentSandboxPythonToolSchema(BaseModel): + code: str = Field(..., description="Shell command to execute in the sandbox.") + timeout: int = Field( + default=DEFAULT_TOOL_TIMEOUT_SEC, + description="Maximum seconds to wait for the command to finish.", + ) + +class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): + name: str = "K8s Agent Sandbox Python Tool" description: str = ( - "Executes Python code inside an isolated Kubernetes pod sandbox. " + "Executes Python code inside an isolated K8s Agent Sandbox. " "Input should be a string containing raw Python code." ) + args_schema: type[BaseModel] = k8sAgentSandboxPythonToolSchema + + def _run_with_sandbox(self, sandbox: Sandbox, code: str, timeout: int) -> dict[str, Any]: + + timeout_tracker = create_timeout_tracker(timeout) + sandbox.files.write("main.py", code, timeout=timeout_tracker()) + result = sandbox.commands.run("python3 main.py", timeout=timeout_tracker()) - def _run(self, code: str, **kwargs: Any) -> str: - sandbox, should_terminate = self._get_sandbox() - try: - # Base64 encode the raw Python code safely - encoded_code = base64.b64encode(code.encode('utf-8')).decode('utf-8') - - # Construct a single-line python -c command that decodes and executes the payload. - # This prevents any single quote (') or double quote (") collisions in the shell. - safe_command = f'python -c "import base64; exec(base64.b64decode(\'{encoded_code}\').decode(\'utf-8\'))"' - exec_response = sandbox.commands.run(safe_command) - - if exec_response.exit_code == 0: - return exec_response.stdout - else: - return f"Python execution failed (Exit Code {exec_response.exit_code}):\n{exec_response.stderr}" - finally: - self._release_sandbox(sandbox, should_terminate) + return { + "exit_code": result.exit_code, + "stdout": result.stdout, + "stderr": result.stderr, + } diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py new file mode 100644 index 0000000000..e20cb84d52 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py @@ -0,0 +1,53 @@ +from dataclasses import ( + dataclass, + field, +) + +from k8s_agent_sandbox.models import ( + SandboxConnectionConfig, + SandboxTracerConfig, +) +from k8s_agent_sandbox.sandbox_client import SandboxClient + + +@dataclass +class K8sAgentSandboxToolClientSettings: + """ + The dataclass that stores data required to created an Agent Sandbox Client. + It basically has the same arguments as the `k8s_agent_sandbox.SandboxClient` + class. + """ + connection_config: SandboxConnectionConfig | None = None + tracer_config: SandboxTracerConfig | None = None + cleanup: bool = False + + cache_client: bool = True + _client: SandboxClient | None = field(default=None, init=False, repr=False) + + @property + def client(self) -> SandboxClient: + if self._client is not None: + return self._client + + self._client = SandboxClient( + connection_config=self.connection_config, + tracer_config=self.tracer_config, + cleanup=self.cleanup, + ) + return self._client + + +@dataclass +class K8sAgentSandboxToolSandboxSettings: + """ + The dataclass that stores sandbox settings. + + Args: + warmpool: Name of the warm pool where a sandbox should be created. + namespace: Name of the namespace where a sandbox should be created. + sandbox_timeout: The timeout in seconds after which the sandbox will be automatically killed. + """ + warmpool: str + namespace: str + sandbox_timeout: int = 300 + diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py new file mode 100644 index 0000000000..0cb4983250 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py @@ -0,0 +1,121 @@ +import atexit +import typing +from typing_extensions import Self + +from .settings import ( + K8sAgentSandboxToolClientSettings, + K8sAgentSandboxToolSandboxSettings, +) +from .lifecycle_manager import ( + K8sAgentSandboxLifecycleManager, + EphemeralModeK8sAgentSandboxLifecycleManager, + AttachModeK8sAgentSandboxLifecycleManager, + PersistentModeK8sAgentSandboxLifecycleManager, +) + +if typing.TYPE_CHECKING: + # Avoiding a circular dependency. + from .base_tool import K8sAgentSandboxBaseTool + + + +class K8sAgentSandboxToolset: + """ + The toolset is responsible for sharing the settings among many K8s Agent Sandbox tools. + The tools that are added to a same toolset will share the sandbox settings and the sandbox itself. + + It is recommended to use the factory method :meth:`create` insteat of the constructor. + + Args: + lifecycle_manager: The instanse of a sandbox lifecycle manager that is responsible for + managing a sandbox. + cleanup_on_exit: When True, registers its :meth:`close` method at the `atexit` module + to be called when the programm exits. + """ + def __init__( + self, + lifecycle_manager: K8sAgentSandboxLifecycleManager, + cleanup_on_exit: bool = True, + ): + self.lifecycle_manager = lifecycle_manager + + if cleanup_on_exit: + atexit.register(self.close) + + + + self._all_tools: dict[str, 'K8sAgentSandboxBaseTool'] = {} + + def add_tool(self, tool: 'K8sAgentSandboxBaseTool'): + name = tool.name + if name in self._all_tools: + raise ValueError(f"The tool '{name}' is already in the toolset.") + + self._all_tools[name] = tool + + + @property + def tools(self) -> list['K8sAgentSandboxBaseTool']: + return list(self._all_tools.values()) + + def close(self): + self.lifecycle_manager.close() + + + @classmethod + def create( + cls, + sandbox_settings: K8sAgentSandboxToolSandboxSettings, + client_settings: K8sAgentSandboxToolClientSettings | None = None, + persistent: bool = False, + claim_name: str | None = None, + cleanup_on_exit: bool = True, + ) -> Self: + + """ + Create a toolset by using Agent Sandbox client and sandbox settings. + + Args: + sandbox_settings: Settings for the sandbox instanse that will be + managed by this toolset. + client_settings: Settings for K8s Agent Sandbox client. If None, + the default settings with Local Tunnel Mode is used. + claim_name: Attach to an existing sandbox by its claim_name instead of + creating a new one. The tool will never kill a sandbox it did not + create. Mutually exclusive with the `persistent` argument. + persistent: If True, reuses one sandbox across all calls of the tools in this toolset, + and this sandbox can be killed by closing the toolset. Mutually exclusive with + the `claim_name` argument. + cleanup_on_exit: Same as in the constructor. + + """ + if persistent and claim_name is not None: + raise ValueError("The persistent and attach modes are mutually exclusive.") + + client_settings = client_settings or K8sAgentSandboxToolClientSettings() + + + if claim_name is not None: + lifecycle_manager = AttachModeK8sAgentSandboxLifecycleManager( + client_settings, + sandbox_settings, + claim_name, + ) + + elif persistent: + lifecycle_manager = PersistentModeK8sAgentSandboxLifecycleManager( + client_settings, + sandbox_settings, + ) + + else: + lifecycle_manager = EphemeralModeK8sAgentSandboxLifecycleManager( + client_settings, + sandbox_settings, + ) + + return cls( + lifecycle_manager, + cleanup_on_exit=cleanup_on_exit, + ) + diff --git a/lib/crewai-tools/tests/k8s_agent_sandbox/test_base_tool.py b/lib/crewai-tools/tests/k8s_agent_sandbox/test_base_tool.py deleted file mode 100644 index cb079d921f..0000000000 --- a/lib/crewai-tools/tests/k8s_agent_sandbox/test_base_tool.py +++ /dev/null @@ -1,170 +0,0 @@ -import sys -import pytest -from unittest.mock import MagicMock, patch - -from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sBaseTool - - -class DummyK8sTool(K8sBaseTool): - """A concrete subclass to allow instantiation of the BaseTool for testing.""" - def _run(self, *args, **kwargs) -> str: - return "dummy" - - -@pytest.fixture(autouse=True) -def clean_class_state(): - """Ensure the SDK cache and atexit registry are reset between every test.""" - K8sBaseTool._sdk_cache.clear() - yield - K8sBaseTool._sdk_cache.clear() - - -@pytest.fixture -def mock_k8s_sdk(): - """Mocks the k8s_agent_sandbox SDK so we don't need it installed to run tests.""" - with patch.dict("sys.modules"): - mock_sdk = MagicMock() - mock_client_class = MagicMock() - mock_sdk.SandboxClient = mock_client_class - sys.modules["k8s_agent_sandbox"] = mock_sdk - yield mock_client_class - - -def test_import_sandbox_client_success_and_caching(mock_k8s_sdk): - """Test that the SDK is imported correctly and cached on subsequent calls.""" - # First call should import and cache - client1 = K8sBaseTool._import_sandbox_client_class() - assert client1 == mock_k8s_sdk - assert "k8s_agent_sandbox.SandboxClient" in K8sBaseTool._sdk_cache - - # Second call should fetch from cache (we can verify by replacing the sys module) - with patch.dict("sys.modules", {"k8s_agent_sandbox": None}): - client2 = K8sBaseTool._import_sandbox_client_class() - assert client2 == mock_k8s_sdk - - -def test_import_sandbox_client_missing_package(): - """Test that missing the SDK raises the custom, helpful ImportError.""" - with patch.dict("sys.modules"): - # Force an ImportError if the code tries to import it - sys.modules["k8s_agent_sandbox"] = None - - with pytest.raises(ImportError, match="The 'k8s_agent_sandbox' package is required"): - K8sBaseTool._import_sandbox_client_class() - - -def test_get_sandbox_with_claim_name(mock_k8s_sdk): - """Test that providing a claim_name bypasses creation and fetches the sandbox.""" - mock_sandbox = MagicMock() - mock_client_instance = mock_k8s_sdk.return_value - mock_client_instance.get_sandbox.return_value = mock_sandbox - - tool = DummyK8sTool(template="test-template", claim_name="my-claim") - sandbox, should_kill = tool._get_sandbox() - - assert sandbox == mock_sandbox - assert should_kill is False - mock_client_instance.get_sandbox.assert_called_once_with("my-claim") - mock_client_instance.create_sandbox.assert_not_called() - - -def test_get_sandbox_ephemeral_default(mock_k8s_sdk): - """Test the default behavior: create a fresh sandbox and mark for termination.""" - mock_sandbox = MagicMock() - mock_client_instance = mock_k8s_sdk.return_value - mock_client_instance.create_sandbox.return_value = mock_sandbox - - tool = DummyK8sTool(template="test-template", namespace="custom-ns") - sandbox, should_kill = tool._get_sandbox() - - assert sandbox == mock_sandbox - assert should_kill is True - mock_client_instance.create_sandbox.assert_called_once_with( - template="test-template", namespace="custom-ns" - ) - - -@patch("atexit.register") -def test_get_sandbox_persistent(mock_atexit_register, mock_k8s_sdk): - """Test persistent sandbox creation, caching, and atexit registration.""" - mock_sandbox = MagicMock() - mock_client_instance = mock_k8s_sdk.return_value - mock_client_instance.create_sandbox.return_value = mock_sandbox - - tool = DummyK8sTool(template="test-template", persistent=True) - - # First call: creates sandbox, registers atexit - sandbox1, should_kill1 = tool._get_sandbox() - assert sandbox1 == mock_sandbox - assert should_kill1 is False - mock_client_instance.create_sandbox.assert_called_once() - mock_atexit_register.assert_called_once_with(tool.close) - - # Second call: fetches from local _persistent_sandbox cache - sandbox2, should_kill2 = tool._get_sandbox() - assert sandbox2 == mock_sandbox - assert should_kill2 is False - # Verify create_sandbox was NOT called a second time - assert mock_client_instance.create_sandbox.call_count == 1 - - -def test_release_sandbox_should_terminate(): - """Test that release_sandbox terminates when should_terminate is True.""" - tool = DummyK8sTool(template="test") - mock_sandbox = MagicMock() - - tool._release_sandbox(mock_sandbox, should_terminate=True) - mock_sandbox.terminate.assert_called_once() - - -def test_release_sandbox_should_not_terminate(): - """Test that release_sandbox skips termination when should_terminate is False.""" - tool = DummyK8sTool(template="test") - mock_sandbox = MagicMock() - - tool._release_sandbox(mock_sandbox, should_terminate=False) - mock_sandbox.terminate.assert_not_called() - - -@patch("logging.Logger.debug") -def test_release_sandbox_handles_exception(mock_log_debug): - """Test that exceptions during termination are caught and logged.""" - tool = DummyK8sTool(template="test") - mock_sandbox = MagicMock() - mock_sandbox.terminate.side_effect = Exception("API Error") - - # Should not raise an error - tool._release_sandbox(mock_sandbox, should_terminate=True) - mock_sandbox.terminate.assert_called_once() - mock_log_debug.assert_called_once() - assert "Best-effort sandbox cleanup failed" in mock_log_debug.call_args[0][0] - - -def test_close_terminates_persistent_sandbox(mock_k8s_sdk): - """Test that the close() method successfully terminates a persistent sandbox.""" - mock_sandbox = MagicMock() - mock_k8s_sdk.return_value.create_sandbox.return_value = mock_sandbox - - tool = DummyK8sTool(template="test", persistent=True) - tool._persistent_sandbox = mock_sandbox # Simulate an active persistent sandbox - - tool.close() - - mock_sandbox.terminate.assert_called_once() - assert tool._persistent_sandbox is None - - -@patch("logging.Logger.debug") -def test_close_handles_exception(mock_log_debug): - """Test that exceptions during persistent sandbox cleanup are caught and logged.""" - tool = DummyK8sTool(template="test", persistent=True) - mock_sandbox = MagicMock() - mock_sandbox.terminate.side_effect = Exception("API Error") - tool._persistent_sandbox = mock_sandbox - - tool.close() - - mock_sandbox.terminate.assert_called_once() - assert tool._persistent_sandbox is None # Should still nullify the cache - mock_log_debug.assert_called_once() - assert "Best-effort persistent sandbox cleanup failed" in mock_log_debug.call_args[0][0] diff --git a/lib/crewai-tools/tests/k8s_agent_sandbox/test_exec_tool.py b/lib/crewai-tools/tests/k8s_agent_sandbox/test_exec_tool.py deleted file mode 100644 index 00b9275d6e..0000000000 --- a/lib/crewai-tools/tests/k8s_agent_sandbox/test_exec_tool.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest -from unittest.mock import MagicMock, patch - -from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sExecTool - - -@pytest.fixture -def k8s_exec_tool(): - """Fixture to provide a fresh instance of the tool for each test.""" - return K8sExecTool(template="template") - - -@pytest.fixture -def mock_sandbox(): - """Fixture to provide a mocked sandbox object.""" - return MagicMock() - - -def test_run_success(k8s_exec_tool, mock_sandbox): - """Test that a successful command returns the stdout correctly.""" - - # 1. Setup the mock response to simulate a successful command (exit_code 0) - mock_response = MagicMock() - mock_response.exit_code = 0 - mock_response.stdout = "Hello from the sandbox!\n" - mock_response.stderr = "" - mock_sandbox.commands.run.return_value = mock_response - - # 2. Mock the parent class lifecycle methods - with patch.object(k8s_exec_tool, '_get_sandbox', return_value=(mock_sandbox, True)) as mock_get_sandbox, \ - patch.object(k8s_exec_tool, '_release_sandbox') as mock_release_sandbox: - - # 3. Execute the tool - result = k8s_exec_tool._run("echo 'Hello from the sandbox!'") - - # 4. Assertions - assert result == "Hello from the sandbox!\n" - - # Ensure the SDK was called with the exact string - mock_sandbox.commands.run.assert_called_once_with("echo 'Hello from the sandbox!'") - - # Ensure the sandbox was safely released - mock_release_sandbox.assert_called_once_with(mock_sandbox, True) - - -def test_run_failure(k8s_exec_tool, mock_sandbox): - """Test that a failing command returns the formatted error string.""" - - # 1. Setup the mock response to simulate a failed command (exit_code != 0) - mock_response = MagicMock() - mock_response.exit_code = 127 - mock_response.stdout = "" - mock_response.stderr = "bash: invalid_command: command not found" - mock_sandbox.commands.run.return_value = mock_response - - with patch.object(k8s_exec_tool, '_get_sandbox', return_value=(mock_sandbox, False)), \ - patch.object(k8s_exec_tool, '_release_sandbox') as mock_release_sandbox: - - # 2. Execute the tool - result = k8s_exec_tool._run("invalid_command") - - # 3. Assertions - assert "Command execution failed (Exit Code 127):" in result - assert "bash: invalid_command: command not found" in result - - mock_sandbox.commands.run.assert_called_once_with("invalid_command") - mock_release_sandbox.assert_called_once_with(mock_sandbox, False) - - -def test_run_finally_executes_on_exception(k8s_exec_tool, mock_sandbox): - """Test that the sandbox is safely released even if the SDK crashes.""" - - # 1. Simulate the SDK throwing an unhandled exception (e.g., network timeout) - mock_sandbox.commands.run.side_effect = Exception("SDK Connection Timeout") - - with patch.object(k8s_exec_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ - patch.object(k8s_exec_tool, '_release_sandbox') as mock_release_sandbox: - - # 2. Execute the tool and assert it raises the error up the chain - with pytest.raises(Exception, match="SDK Connection Timeout"): - k8s_exec_tool._run("ls -la") - - # 3. Crucial Assertion: The finally block MUST still fire to prevent cluster resource leaks - mock_release_sandbox.assert_called_once_with(mock_sandbox, True) diff --git a/lib/crewai-tools/tests/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/k8s_agent_sandbox/test_file_tool.py deleted file mode 100644 index 3726fff229..0000000000 --- a/lib/crewai-tools/tests/k8s_agent_sandbox/test_file_tool.py +++ /dev/null @@ -1,140 +0,0 @@ -import base64 -import shlex -import pytest -from unittest.mock import MagicMock, patch - -from crewai_tools.tools.k8s_agent_sandbox.file_tool import K8sFileTool - - -@pytest.fixture -def k8s_file_tool(): - """Fixture to provide a fresh instance of the tool for each test.""" - return K8sFileTool(template="template") - - -@pytest.fixture -def mock_sandbox(): - """Fixture to provide a mocked sandbox object.""" - return MagicMock() - - -def test_run_read_success(k8s_file_tool, mock_sandbox): - """Test the 'read' action cleanly extracts stdout and escapes paths.""" - mock_response = MagicMock(exit_code=0, stdout="file contents\n", stderr="") - mock_sandbox.commands.run.return_value = mock_response - - file_path = "my secret file.txt" # Testing space escaping - expected_inner = f"cat {shlex.quote(file_path)}" - expected_cmd = f"sh -c {shlex.quote(expected_inner)}" - - with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ - patch.object(k8s_file_tool, '_release_sandbox') as mock_release: - - result = k8s_file_tool._run(action="read", file_path=file_path) - - assert result == "file contents\n" - mock_sandbox.commands.run.assert_called_once_with(expected_cmd) - mock_release.assert_called_once_with(mock_sandbox, True) - - -def test_run_write_success(k8s_file_tool, mock_sandbox): - """Test the 'write' action properly base64 encodes the payload.""" - mock_response = MagicMock(exit_code=0, stdout="", stderr="") - mock_sandbox.commands.run.return_value = mock_response - - file_path = "/tmp/test.txt" - content = "Hello World!" - encoded = base64.b64encode(content.encode('utf-8')).decode('utf-8') - - expected_inner = f"echo '{encoded}' | base64 -d > {shlex.quote(file_path)}" - expected_cmd = f"sh -c {shlex.quote(expected_inner)}" - - with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ - patch.object(k8s_file_tool, '_release_sandbox') as mock_release: - - result = k8s_file_tool._run(action="write", file_path=file_path, content=content) - - assert result == f"Successfully executed 'write' on {file_path}." - mock_sandbox.commands.run.assert_called_once_with(expected_cmd) - mock_release.assert_called_once_with(mock_sandbox, True) - - -def test_run_append_success(k8s_file_tool, mock_sandbox): - """Test the 'append' action uses the correct redirect operator.""" - mock_response = MagicMock(exit_code=0, stdout="", stderr="") - mock_sandbox.commands.run.return_value = mock_response - - content = "New Line" - encoded = base64.b64encode(content.encode('utf-8')).decode('utf-8') - - expected_inner = f"echo '{encoded}' | base64 -d >> {shlex.quote('log.txt')}" - expected_cmd = f"sh -c {shlex.quote(expected_inner)}" - - with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)): - result = k8s_file_tool._run(action="append", file_path="log.txt", content=content) - - assert "Successfully executed 'append'" in result - mock_sandbox.commands.run.assert_called_once_with(expected_cmd) - - -def test_run_delete_and_list(k8s_file_tool, mock_sandbox): - """Test 'delete' and 'list' actions trigger the correct inner shell commands.""" - mock_response = MagicMock(exit_code=0, stdout="total 0\n", stderr="") - mock_sandbox.commands.run.return_value = mock_response - - with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)): - # Test Delete - k8s_file_tool._run(action="delete", file_path="file.txt") - expected_delete = f"sh -c {shlex.quote('rm -rf ' + shlex.quote('file.txt'))}" - mock_sandbox.commands.run.assert_called_with(expected_delete) - - # Test List - k8s_file_tool._run(action="list", file_path="dir/") - expected_list = f"sh -c {shlex.quote('ls -la ' + shlex.quote('dir/'))}" - mock_sandbox.commands.run.assert_called_with(expected_list) - - -def test_missing_content_error(k8s_file_tool, mock_sandbox): - """Test that writing or appending without content fails immediately.""" - with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ - patch.object(k8s_file_tool, '_release_sandbox') as mock_release: - - result = k8s_file_tool._run(action="write", file_path="test.txt", content=None) - - assert "parameter is required" in result - mock_sandbox.commands.run.assert_not_called() - mock_release.assert_called_once_with(mock_sandbox, True) - - -def test_invalid_action_error(k8s_file_tool, mock_sandbox): - """Test that an unknown action returns an error and doesn't run the shell.""" - with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)): - result = k8s_file_tool._run(action="fly", file_path="test.txt") - - assert "Unknown action 'fly'" in result - mock_sandbox.commands.run.assert_not_called() - - -def test_run_failure_bubbles_stderr(k8s_file_tool, mock_sandbox): - """Test that a non-zero exit code bubbles the stderr to the agent.""" - mock_response = MagicMock(exit_code=1, stdout="", stderr="cat: test.txt: No such file or directory") - mock_sandbox.commands.run.return_value = mock_response - - with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)): - result = k8s_file_tool._run(action="read", file_path="test.txt") - - assert "Error executing 'read' on test.txt:" in result - assert "No such file or directory" in result - - -def test_run_finally_executes_on_exception(k8s_file_tool, mock_sandbox): - """Test that the sandbox is safely released even if the SDK crashes.""" - mock_sandbox.commands.run.side_effect = Exception("Network Disconnected") - - with patch.object(k8s_file_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ - patch.object(k8s_file_tool, '_release_sandbox') as mock_release: - - with pytest.raises(Exception, match="Network Disconnected"): - k8s_file_tool._run(action="read", file_path="test.txt") - - mock_release.assert_called_once_with(mock_sandbox, True) diff --git a/lib/crewai-tools/tests/k8s_agent_sandbox/test_python_tool.py b/lib/crewai-tools/tests/k8s_agent_sandbox/test_python_tool.py deleted file mode 100644 index 4a4011760e..0000000000 --- a/lib/crewai-tools/tests/k8s_agent_sandbox/test_python_tool.py +++ /dev/null @@ -1,87 +0,0 @@ -import base64 -import pytest -from unittest.mock import MagicMock, patch - -from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sPythonTool - - -@pytest.fixture -def k8s_python_tool(): - """Fixture to provide a fresh instance of the tool for each test.""" - return K8sPythonTool(template="template") - - -@pytest.fixture -def mock_sandbox(): - """Fixture to provide a mocked sandbox object.""" - return MagicMock() - - -def test_run_success(k8s_python_tool, mock_sandbox): - """Test that a successfully executed Python script returns stdout.""" - - # 1. Setup the mock response - mock_response = MagicMock() - mock_response.exit_code = 0 - mock_response.stdout = "Hello from Python!\n" - mock_response.stderr = "" - mock_sandbox.commands.run.return_value = mock_response - - # 2. Define the input code and calculate what the expected safe command should be - input_code = "print('Hello from Python!')" - expected_encoded = base64.b64encode(input_code.encode('utf-8')).decode('utf-8') - expected_command = f'python -c "import base64; exec(base64.b64decode(\'{expected_encoded}\').decode(\'utf-8\'))"' - - # 3. Mock the lifecycle methods and execute - with patch.object(k8s_python_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ - patch.object(k8s_python_tool, '_release_sandbox') as mock_release_sandbox: - - result = k8s_python_tool._run(input_code) - - # 4. Assertions - assert result == "Hello from Python!\n" - - # Verify the sandbox received the exact base64 wrapped command - mock_sandbox.commands.run.assert_called_once_with(expected_command) - mock_release_sandbox.assert_called_once_with(mock_sandbox, True) - - -def test_run_failure(k8s_python_tool, mock_sandbox): - """Test that a failing Python script returns the formatted error string.""" - - # 1. Setup the mock response for a syntax error - mock_response = MagicMock() - mock_response.exit_code = 1 - mock_response.stdout = "" - mock_response.stderr = "SyntaxError: invalid syntax" - mock_sandbox.commands.run.return_value = mock_response - - with patch.object(k8s_python_tool, '_get_sandbox', return_value=(mock_sandbox, False)), \ - patch.object(k8s_python_tool, '_release_sandbox') as mock_release_sandbox: - - # 2. Execute a broken script - result = k8s_python_tool._run("print('Missing quote)") - - # 3. Assertions - assert "Python execution failed (Exit Code 1):" in result - assert "SyntaxError: invalid syntax" in result - - mock_sandbox.commands.run.assert_called_once() - mock_release_sandbox.assert_called_once_with(mock_sandbox, False) - - -def test_run_finally_executes_on_exception(k8s_python_tool, mock_sandbox): - """Test that the sandbox is safely released even if the SDK crashes.""" - - # 1. Simulate the SDK throwing an unhandled exception - mock_sandbox.commands.run.side_effect = Exception("SDK Connection Timeout") - - with patch.object(k8s_python_tool, '_get_sandbox', return_value=(mock_sandbox, True)), \ - patch.object(k8s_python_tool, '_release_sandbox') as mock_release_sandbox: - - # 2. Execute the tool and assert it raises the error - with pytest.raises(Exception, match="SDK Connection Timeout"): - k8s_python_tool._run("x = 10") - - # 3. Ensure the finally block fires - mock_release_sandbox.assert_called_once_with(mock_sandbox, True) diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py new file mode 100644 index 0000000000..0eba637969 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock + +import pytest + +from crewai_tools.tools.k8s_agent_sandbox.settings import ( + K8sAgentSandboxToolSandboxSettings, +) +from crewai_tools.tools.k8s_agent_sandbox.lifecycle_manager import ( + EphemeralModeK8sAgentSandboxLifecycleManager, + AttachModeK8sAgentSandboxLifecycleManager, + PersistentModeK8sAgentSandboxLifecycleManager, +) +from crewai_tools.tools.k8s_agent_sandbox.toolset import K8sAgentSandboxToolset + + +@pytest.fixture +def mock_sandbox(): + sandbox = MagicMock() + return sandbox + +@pytest.fixture +def mock_sandbox_client(): + client = MagicMock() + + return client + +@pytest.fixture +def mock_client_returns_mock_sandbox_in_create_sandbox(mock_sandbox, mock_sandbox_client): + mock_sandbox_client.create_sandbox.return_value = mock_sandbox + +@pytest.fixture +def mock_client_returns_mock_sandbox_in_get_sandbox(mock_sandbox, mock_sandbox_client): + mock_sandbox_client.get_sandbox.return_value = mock_sandbox + +@pytest.fixture +def sample_sandbox_settings() -> K8sAgentSandboxToolSandboxSettings: + return K8sAgentSandboxToolSandboxSettings( + "my-warmpool", + "my-namespace", + ) + +@pytest.fixture +def mock_client_settings(mock_sandbox_client): + settings = MagicMock() + settings.client = mock_sandbox_client + return settings + +@pytest.fixture(params=["ephemeral", "attach", "persistent"]) +def lifecycle_mode_name(request): + return request.param + +@pytest.fixture +def ephemeral_mode_lifecycle_manager(request, mock_client_settings, sample_sandbox_settings): + request.getfixturevalue("mock_client_returns_mock_sandbox_in_create_sandbox") + return EphemeralModeK8sAgentSandboxLifecycleManager( + mock_client_settings, + sample_sandbox_settings, + ) + +@pytest.fixture +def attach_mode_lifecycle_manager( + request, + mock_client_settings, + sample_sandbox_settings, +): + request.getfixturevalue("mock_client_returns_mock_sandbox_in_get_sandbox") + return AttachModeK8sAgentSandboxLifecycleManager( + mock_client_settings, + sample_sandbox_settings, + "my-claim", + ) + +@pytest.fixture +def persistent_mode_lifecycle_manager(request, mock_client_settings, sample_sandbox_settings): + request.getfixturevalue("mock_client_returns_mock_sandbox_in_create_sandbox") + request.getfixturevalue("mock_client_returns_mock_sandbox_in_get_sandbox") + return PersistentModeK8sAgentSandboxLifecycleManager( + mock_client_settings, + sample_sandbox_settings, + ) + +@pytest.fixture +def lifecycle_manager(request, lifecycle_mode_name): + return request.getfixturevalue(f"{lifecycle_mode_name}_mode_lifecycle_manager") + + +@pytest.fixture +def sample_toolset(lifecycle_manager): + toolset = K8sAgentSandboxToolset( + lifecycle_manager=lifecycle_manager, + ) + return toolset + +@pytest.fixture +def lifecycle_mode_sandbox_termination_expected(lifecycle_manager): + manager_type = type(lifecycle_manager) + + if manager_type is EphemeralModeK8sAgentSandboxLifecycleManager: + return True + else: + return False + diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py new file mode 100644 index 0000000000..fbc1ac86ce --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py @@ -0,0 +1,94 @@ +from unittest.mock import MagicMock, patch +from typing import Any + +from pydantic import BaseModel, Field +import pytest + +from crewai_tools.tools.k8s_agent_sandbox.base_tool import K8sAgentSandboxBaseTool + + +class DummyToolInputSchema(BaseModel): + test_arg: str = Field(description="some positional argument") + test_kwarg: str | None = Field(default=None, description="some keyword argument") + +class DummyK8sTool(K8sAgentSandboxBaseTool): + name: str = "dummy_tool" + description: str = "Dummy testing tool." + args_schema: type[BaseModel] = DummyToolInputSchema + + def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: + return self._dummy_work(sandbox, *args, **kwargs) + + def _dummy_work(self, sandbox, test_arg: str, test_kwarg: str | None = None) -> dict[str, Any]: + return { + "sandbox-claim": sandbox.claim_name, + "arg": test_arg, + "kwarg": test_kwarg, + } + + +def test_tool_added_to_toolset(sample_toolset): + assert len(sample_toolset.tools) == 0 + + _ = DummyK8sTool( + toolset=sample_toolset, + ) + + assert len(sample_toolset.tools) == 1 + +def test_run_with_sandbox( + sample_toolset, mock_sandbox, + lifecycle_mode_sandbox_termination_expected, +): + claim_name = "some-claim" + mock_sandbox.claim_name = claim_name + + tool = DummyK8sTool( + toolset=sample_toolset, + ) + + assert not mock_sandbox.terminate.called + + result = tool.run( + "some-arg", + test_kwarg="some-kwarg", + ) + + assert result == { + "sandbox-claim": claim_name, + "arg": "some-arg", + "kwarg": "some-kwarg", + } + + if lifecycle_mode_sandbox_termination_expected: + assert mock_sandbox.terminate.called + else: + assert not mock_sandbox.terminate.called + + +def test_sandbox_released_after_error( + sample_toolset, + mock_sandbox, + lifecycle_mode_sandbox_termination_expected, +): + class FailingTool(DummyK8sTool): + def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: + raise Exception("some error") + + + tool = FailingTool(toolset=sample_toolset) + + assert not mock_sandbox.terminate.called + + with patch.object(sample_toolset, 'lifecycle_manager', MagicMock(wraps=sample_toolset.lifecycle_manager)) as m: + with pytest.raises(Exception, match="some error"): + tool.run( + "some-arg", + test_kwarg="some-kwarg", + ) + + if lifecycle_mode_sandbox_termination_expected: + assert mock_sandbox.terminate.called + else: + assert not mock_sandbox.terminate.called + diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py new file mode 100644 index 0000000000..886628f366 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py @@ -0,0 +1,31 @@ +import pytest +from k8s_agent_sandbox.models import ExecutionResult + +from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sAgentSandboxExecTool +from crewai_tools.tools.k8s_agent_sandbox.toolset import K8sAgentSandboxToolset + + +@pytest.fixture +def k8s_exec_tool(sample_toolset: K8sAgentSandboxToolset): + return K8sAgentSandboxExecTool(toolset=sample_toolset) + + +@pytest.mark.parametrize("exit_code", [0, 127]) +@pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") +def test_run_success(exit_code, k8s_exec_tool, mock_sandbox): + + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=exit_code, + stdout="some-output", + stderr="some-logs", + ) + + result = k8s_exec_tool.run("some-command", timeout=120) + + assert result == { + "exit_code": exit_code, + "stdout": "some-output", + "stderr": "some-logs", + } + + mock_sandbox.commands.run.assert_called_once_with("some-command", timeout=120) diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py new file mode 100644 index 0000000000..63ed3e9229 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py @@ -0,0 +1,252 @@ +import base64 +import shlex +import time + +import pytest +from k8s_agent_sandbox.models import ( + ExecutionResult, + FileEntry, +) + +from crewai_tools.tools.k8s_agent_sandbox.file_tool import ( + K8sAgentSandboxFileTool, +) + + +@pytest.fixture +def k8s_file_tool(sample_toolset): + return K8sAgentSandboxFileTool(toolset=sample_toolset) + +class TestFileToolReadAction: + @pytest.mark.parametrize("binary", [False, True]) + def test_success(self, k8s_file_tool, mock_sandbox, binary): + content = b"some-content" + mock_sandbox.files.read.return_value = content + + result = k8s_file_tool.run(action="read", path="some/path", binary=binary, timeout=120) + + if binary: + assert result == { + "content": base64.b64encode(content).decode("ascii"), + "path": "some/path", + "encoding": "base64", + } + else: + assert result == { + "content": "some-content", + "path": "some/path", + "encoding": "utf-8", + } + + mock_sandbox.files.read.assert_called_once_with("some/path", timeout=120) + + + def test_invalid_utf(self, k8s_file_tool, mock_sandbox): + content = b"Some malformed \xFF content" + mock_sandbox.files.read.return_value = content + result = k8s_file_tool.run(action="read", path="some/path") + + assert result == { + "content": base64.b64encode(content).decode("ascii"), + "path": "some/path", + "encoding": "base64", + "note": 'File was not valid utf-8; returned as base64.', + } + + +class TestFileToolWriteAction: + @pytest.mark.parametrize("binary", [False, True]) + def test_success(self, k8s_file_tool, mock_sandbox, binary): + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=0, + stdout="", + stderr="", + ) + + content_to_write = "Hello World!" + + if binary: + content = base64.b64encode(content_to_write.encode("ascii")) + else: + content = content_to_write + + result = k8s_file_tool.run( + action="write", + path="parent/file.txt", + content=content, + binary=binary, + timeout=120, + ) + + assert result == { + "bytes": 12, + "path": "parent/file.txt", + "status": "written", + } + + mock_sandbox.commands.run.assert_called_once_with("mkdir -p parent", timeout=120) + + assert mock_sandbox.files.write.call_args.args == ("parent/file.txt", b"Hello World!") + assert 0 <= mock_sandbox.files.write.call_args.kwargs["timeout"] <= 120 + + def test_missing_content(self, k8s_file_tool, mock_sandbox): + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=0, + stdout="", + stderr="", + ) + + k8s_file_tool.run( + action="write", + path="parent/file.txt", + ) + + assert mock_sandbox.files.write.call_args.args == ("parent/file.txt", b"") + + + def test_mkdir_parent_error(self, k8s_file_tool, mock_sandbox): + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=1, + stdout="", + stderr="some parent dir creation error", + ) + + content = "Hello World!" + + with pytest.raises(Exception, match="some parent dir creation error"): + k8s_file_tool.run( + action="write", + path="parent/file.txt", + content=content, + ) + +class TestFileToolAppendAction: + @pytest.mark.parametrize("binary", [False, True]) + def test_success(self, k8s_file_tool, mock_sandbox, binary): + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=0, + stdout="", + stderr="", + ) + + mock_sandbox.files.read.return_value = b"Hello" + + content_to_append = " World" + + if binary: + content = base64.b64encode(content_to_append.encode("ascii")) + else: + content = content_to_append + + + result = k8s_file_tool.run( + action="append", + path="parent/file.txt", + content=content, + binary=binary, + timeout=120, + ) + + assert result == { + "path": "parent/file.txt", + "status": "appended", + "appended_bytes": 6, + "total_bytes": 11, + } + + mock_sandbox.files.read.assert_called_once() + assert mock_sandbox.files.read.call_args.args == ("parent/file.txt",) + assert mock_sandbox.files.read.call_args.kwargs["timeout"] == 120 + + mock_sandbox.files.write.assert_called_once() + assert mock_sandbox.files.write.call_args.args == ("parent/file.txt", b"Hello World") + assert 0 <= mock_sandbox.files.write.call_args.kwargs["timeout"] <= 120 + + +class TestFileToolListAction: + def test_success(self, k8s_file_tool, mock_sandbox): + modification_time = time.time() + mock_sandbox.files.list.return_value = [ + FileEntry(name="test.txt", size=1000000, type="file", mod_time=modification_time), + FileEntry(name="subdir", size=4096, type="directory", mod_time=modification_time), + ] + + result = k8s_file_tool.run(action="list", path="some/directory") + + assert result == { + "entries": [ + { + "mod_time": modification_time, + "name": "test.txt", + "size": 1000000, + "type": "file", + }, + { + "mod_time": modification_time, + "name": "subdir", + "size": 4096, + "type": "directory", + }, + ], + "path": "some/directory", + } + + +class TestFileToolDeleteAction: + def test_success(self, k8s_file_tool, mock_sandbox): + file_path = "parent/file.txt" + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=0, + stdout="", + stderr="" + ) + result = k8s_file_tool.run(action="delete", path=file_path) + + assert result == { + "path": file_path, + "status": "deleted", + } + + mock_sandbox.commands.run.assert_called_once() + assert mock_sandbox.commands.run.call_args.args[0] == f"rm -r {shlex.quote(file_path)}" + + +class TestFileToolMkdirAction: + def test_success(self, k8s_file_tool, mock_sandbox): + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=0, + stdout="", + stderr="" + ) + + result = k8s_file_tool.run(action="mkdir", path="parent/subfolder", timeout=120) + + assert result == { + "path": "parent/subfolder", + "status": "created", + } + + mock_sandbox.commands.run.assert_called_once_with( + f"mkdir -p {shlex.quote('parent/subfolder')}", + timeout=120, + ) + +class TestFileToolInfoAction: + def test_not_implemented_error(self, k8s_file_tool): + with pytest.raises(NotImplementedError, match="is not currently supoported"): + k8s_file_tool.run(action="info", path="parent/file.txt") + + +class TestFileToolExistsAction: + def test_success(self, k8s_file_tool, mock_sandbox): + mock_sandbox.files.exists.return_value = True + + result = k8s_file_tool.run(action="exists", path="parent/file.txt", timeout=120) + + assert result == { + "exists": True, + "path": "parent/file.txt" + } + + mock_sandbox.files.exists.assert_called_once_with("parent/file.txt", timeout=120) + diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py new file mode 100644 index 0000000000..187d4e0010 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py @@ -0,0 +1,145 @@ +import pytest + +from k8s_agent_sandbox.exceptions import SandboxNotFoundError + +from crewai_tools.tools.k8s_agent_sandbox.lifecycle_manager import ( + EphemeralModeK8sAgentSandboxLifecycleManager, + AttachModeK8sAgentSandboxLifecycleManager, + PersistentModeK8sAgentSandboxLifecycleManager, +) + + +class TestEphemeralModeManager: + @pytest.fixture + def manager(self, mock_client_settings, sample_sandbox_settings): + return EphemeralModeK8sAgentSandboxLifecycleManager( + mock_client_settings, + sample_sandbox_settings, + ) + + def test_main(self, manager, mock_sandbox_client): + + sandbox = manager.acquire_sandbox() + assert mock_sandbox_client.create_sandbox.call_count == 1 + + assert sandbox.terminate.call_count == 0 + manager.release_sandbox() + assert sandbox.terminate.call_count == 1 + + sandbox.reset_mock() + + sandbox = manager.acquire_sandbox() + assert mock_sandbox_client.create_sandbox.call_count == 2 + + assert not sandbox.terminate.called + manager.release_sandbox() + assert sandbox.terminate.call_count == 1 + + assert mock_sandbox_client.get_sandbox.call_count == 0 + manager.close() + assert sandbox.terminate.call_count == 1 + + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") + def test_close_non_acquired(self, manager, mock_sandbox): + assert mock_sandbox.terminate.call_count == 0 + manager.close() + assert mock_sandbox.terminate.call_count == 0 + + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") + def test_close_acquired(self, manager): + sandbox = manager.acquire_sandbox() + assert sandbox.terminate.call_count == 0 + manager.close() + assert sandbox.terminate.call_count == 1 + + +class TestAttachModeManager: + @pytest.fixture + def manager(self, mock_client_settings, sample_sandbox_settings): + return AttachModeK8sAgentSandboxLifecycleManager( + mock_client_settings, + sample_sandbox_settings, + "my-claim", + ) + + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") + def test_acquire_and_release(self, manager, mock_sandbox_client, sample_sandbox_settings): + + sandbox = manager.acquire_sandbox() + assert not mock_sandbox_client.create_sandbox.called + + assert mock_sandbox_client.get_sandbox.call_count == 1 + + assert mock_sandbox_client.get_sandbox.call_args.args[0] == "my-claim" + assert mock_sandbox_client.get_sandbox.call_args.kwargs["namespace"] == sample_sandbox_settings.namespace + + manager.release_sandbox() + assert not sandbox.terminate.called + + manager.close() + assert not sandbox.terminate.called + + def test_non_existing_sandbox(self, manager, mock_sandbox_client): + + mock_sandbox_client.get_sandbox.side_effect = SandboxNotFoundError + + with pytest.raises(SandboxNotFoundError): + manager.acquire_sandbox() + + manager.release_sandbox() + manager.close() + + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") + def test_close_non_acquired(self, manager, mock_sandbox): + assert mock_sandbox.terminate.call_count == 0 + manager.close() + assert mock_sandbox.terminate.call_count == 0 + + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") + def test_close_acquired(self, manager): + sandbox = manager.acquire_sandbox() + assert sandbox.terminate.call_count == 0 + manager.close() + assert sandbox.terminate.call_count == 0 + + +class TestPersistentModeManager: + @pytest.fixture + def manager(self, mock_client_settings, sample_sandbox_settings): + return PersistentModeK8sAgentSandboxLifecycleManager( + mock_client_settings, + sample_sandbox_settings, + ) + + def test_acquire_and_release(self, manager, mock_sandbox_client): + sandbox = manager.acquire_sandbox() + + assert mock_sandbox_client.create_sandbox.call_count == 1 + + manager.release_sandbox() + + assert not sandbox.terminate.called + + sandbox = manager.acquire_sandbox() + + assert mock_sandbox_client.create_sandbox.call_count == 1 + + manager.release_sandbox() + + assert not sandbox.terminate.called + + manager.close() + assert sandbox.terminate.called + + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") + def test_close_non_acquired(self, manager, mock_sandbox): + assert mock_sandbox.terminate.call_count == 0 + manager.close() + assert mock_sandbox.terminate.call_count == 0 + + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") + def test_close_acquired(self, manager): + sandbox = manager.acquire_sandbox() + assert sandbox.terminate.call_count == 0 + manager.close() + assert sandbox.terminate.call_count == 1 diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py new file mode 100644 index 0000000000..30be16f778 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py @@ -0,0 +1,32 @@ +import pytest + +from k8s_agent_sandbox.models import ExecutionResult + +from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sAgentSandboxPythonTool + + +@pytest.fixture +def k8s_python_tool(sample_toolset): + return K8sAgentSandboxPythonTool(toolset=sample_toolset) + + +@pytest.mark.parametrize("exit_code", [0, 127]) +def test_run(k8s_python_tool, mock_sandbox, exit_code): + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=exit_code, + stdout="some-output", + stderr="some-logs", + ) + + result = k8s_python_tool.run(code="some-code", timeout=120) + + assert result == { + "exit_code": exit_code, + "stdout": "some-output", + "stderr": "some-logs", + } + + mock_sandbox.files.write.assert_called_once_with("main.py", "some-code", timeout=120) + + assert mock_sandbox.commands.run.call_args.args == ("python3 main.py",) + assert 0 <= mock_sandbox.commands.run.call_args.kwargs["timeout"] <= 120 diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py new file mode 100644 index 0000000000..336aab53e7 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py @@ -0,0 +1,72 @@ +from unittest.mock import MagicMock + +import pytest + +from crewai_tools.tools.k8s_agent_sandbox.lifecycle_manager import ( + EphemeralModeK8sAgentSandboxLifecycleManager, + AttachModeK8sAgentSandboxLifecycleManager, + PersistentModeK8sAgentSandboxLifecycleManager, +) +from crewai_tools.tools.k8s_agent_sandbox.toolset import K8sAgentSandboxToolset + + +def test_add_tool(lifecycle_manager): + toolset = K8sAgentSandboxToolset( + lifecycle_manager, + ) + + assert len(toolset.tools) == 0 + + tool = MagicMock() + tool.name = "my_tool" + + toolset.add_tool(tool) + + assert len(toolset.tools) == 1 + + with pytest.raises(ValueError, match="already in the toolset"): + toolset.add_tool(tool) + + assert len(toolset.tools) == 1 + + another_tool = MagicMock() + another_tool.name = "another_tool" + toolset.add_tool(another_tool) + + assert len(toolset.tools) == 2 + + +class TestSandboxLifecycleModesSelection: + @pytest.mark.parametrize( + "extra_kwargs, expected_manager_class", + [ + ({}, EphemeralModeK8sAgentSandboxLifecycleManager), + (dict(claim_name="my_claim"), AttachModeK8sAgentSandboxLifecycleManager), + (dict(persistent=True), PersistentModeK8sAgentSandboxLifecycleManager), + ] + ) + def test_lifecycle_modes( + self, + extra_kwargs, + expected_manager_class, + mock_client_settings, + sample_sandbox_settings, + ): + toolset = K8sAgentSandboxToolset.create( + sandbox_settings=sample_sandbox_settings, + client_settings=mock_client_settings, + **extra_kwargs, + ) + + assert type(toolset.lifecycle_manager) is expected_manager_class + + + def test_persistent_and_attach_error(self, mock_client_settings, sample_sandbox_settings): + with pytest.raises(ValueError, match="persistent and attach modes are mutually exclusive"): + _ = K8sAgentSandboxToolset.create( + sandbox_settings=sample_sandbox_settings, + client_settings=mock_client_settings, + persistent=True, + claim_name="my_claim", + ) + From e68a0cd5884202d39503c21ed77b997ac1430d73 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Thu, 25 Jun 2026 09:56:56 +0200 Subject: [PATCH 15/45] reformat --- lib/crewai-tools/pyproject.toml | 4 +- .../tools/k8s_agent_sandbox/base_tool.py | 4 +- .../tools/k8s_agent_sandbox/exec_tool.py | 8 +-- .../tools/k8s_agent_sandbox/file_tool.py | 33 +++++++--- .../k8s_agent_sandbox/lifecycle_manager.py | 34 ++++++----- .../tools/k8s_agent_sandbox/python_tool.py | 5 +- .../tools/k8s_agent_sandbox/settings.py | 10 +-- .../tools/k8s_agent_sandbox/toolset.py | 15 ++--- .../tests/tools/k8s_agent_sandbox/conftest.py | 26 ++++++-- .../tools/k8s_agent_sandbox/test_base_tool.py | 17 ++++-- .../tools/k8s_agent_sandbox/test_file_tool.py | 61 +++++++++++-------- .../test_lifecycle_manager.py | 37 ++++++----- .../k8s_agent_sandbox/test_python_tool.py | 4 +- .../tools/k8s_agent_sandbox/test_toolset.py | 24 ++++---- 14 files changed, 168 insertions(+), 114 deletions(-) diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 0747af9ec6..e9b1a77cd3 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -149,10 +149,8 @@ e2b = [ ] k8s_agent_sandbox = [ - "k8s-agent-sandbox @ git+https://github.com/kubernetes-sigs/agent-sandbox.git@b1ef44f5800491a5044a4816fd2e6b8918205f97#subdirectory=clients/python/agentic-sandbox-client", + "k8s-agent-sandbox~=0.5.0", ] -[tool.hatch.metadata] -allow-direct-references = true [tool.uv] exclude-newer = "3 days" diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py index 20500e3739..0c311b0f3d 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -22,15 +22,13 @@ class K8sAgentSandboxBaseTool(BaseTool, arbitrary_types_allowed=True): name: str description: str toolset: SkipValidation[K8sAgentSandboxToolset] = Field( - exclude=True, - description="The instance of the ``K8sAgentSandboxToolset``." + exclude=True, description="The instance of the ``K8sAgentSandboxToolset``." ) def model_post_init(self, __context: Any) -> None: self.toolset.add_tool(self) super().model_post_init(__context) - def _run(self, *args, **kwargs: Any) -> dict[str, Any]: try: sandbox = self.toolset.lifecycle_manager.acquire_sandbox() diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index bf93aeec9c..e0a4aa2168 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -19,11 +19,13 @@ class k8sAgentSandboxExecToolSchema(BaseModel): class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): name: str = "K8s Agent Sandbox Exec Tool" - description: str = "Executes shell commands inside an isolated Kubernetes pod sandbox." + description: str = ( + "Executes shell commands inside an isolated Kubernetes pod sandbox." + ) args_schema: type[BaseModel] = k8sAgentSandboxExecToolSchema def _run_with_sandbox(self, sandbox: Sandbox, *args, **kwargs) -> dict[str, Any]: - return self._run_command(sandbox, *args, **kwargs) + return self._run_command(sandbox, *args, **kwargs) def _run_command( self, @@ -37,5 +39,3 @@ def _run_command( "stdout": result.stdout, "stderr": result.stderr, } - - diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 18ab85c9e2..5ec1b612dc 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -27,6 +27,7 @@ "read", "write", "append", "list", "delete", "mkdir", "info", "exists" ] + class K8sAgentSandboxFileToolSchema(BaseModel): action: FileAction = Field( ..., @@ -42,9 +43,12 @@ class K8sAgentSandboxFileToolSchema(BaseModel): - mkdir: creates a directory. - info: Not currntly supported. Returns file metadata. - exists: returns a boolean for whether the path exists. - """) + """), + ) + path: str = Field( + ..., + description="The path inside the sandbox which is relative to a sandbox's working directory.", ) - path: str = Field(..., description="The path inside the sandbox which is relative to a sandbox's working directory.") content: str | None = Field( default=None, description=( @@ -69,7 +73,7 @@ class K8sAgentSandboxFileToolSchema(BaseModel): ) @model_validator(mode="after") - def _validate_action_args(self) -> 'K8sAgentSandboxFileToolSchema': + def _validate_action_args(self) -> "K8sAgentSandboxFileToolSchema": if self.action == "append" and self.content is None: raise ValueError( "action='append' requires 'content'. Pass the chunk to append " @@ -143,7 +147,12 @@ def _run_with_sandbox( raise ValueError(f"Unknown action: {action}") def _read( - self, sandbox: Sandbox, path: str, *, binary: bool, timeout: int, + self, + sandbox: Sandbox, + path: str, + *, + binary: bool, + timeout: int, ) -> dict[str, Any]: data: bytes = sandbox.files.read(path, timeout=timeout) @@ -214,10 +223,14 @@ def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any try: result = sandbox.commands.run(command, timeout=timeout) except Exception as e: - raise RuntimeError(f"Unexpected error during the run of the deletion command '{command}'. Error: {e}.") + raise RuntimeError( + f"Unexpected error during the run of the deletion command '{command}'. Error: {e}." + ) if result.exit_code != 0: - raise RuntimeError(f"Cannot delete directory {path}. Error: {result.stderr}.") + raise RuntimeError( + f"Cannot delete directory {path}. Error: {result.stderr}." + ) return {"status": "deleted", "path": path} @@ -231,10 +244,14 @@ def _mkdir(self, sandbox: Sandbox, path: str, *, timeout: int): timeout=timeout, ) except Exception as e: - raise RuntimeError(f"Unexpected error during the creation of a directory {path}. Error: {e}.") + raise RuntimeError( + f"Unexpected error during the creation of a directory {path}. Error: {e}." + ) if result.exit_code != 0: - raise RuntimeError(f"Cannot create directory {path}. Error: {result.stderr}.") + raise RuntimeError( + f"Cannot create directory {path}. Error: {result.stderr}." + ) def _ensure_parent_dir(self, sandbox: Sandbox, path: str, timeout: int): parent = posixpath.dirname(path) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index f009fad422..d6a623f8c0 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -14,10 +14,12 @@ logger = logging.getLogger(__name__) + class K8sAgentSandboxLifecycleManager(ABC): """ The base lifecycle manager of the K8s Agent Sandbox. """ + def __init__( self, client_settings: K8sAgentSandboxToolClientSettings, @@ -34,7 +36,6 @@ def __init__( self._sandbox: Sandbox | None = None self._sandbox_acquired: bool = False - def acquire_sandbox(self) -> Sandbox: """ Acquires a sandbox based on this implementation and returns it. @@ -47,7 +48,7 @@ def acquire_sandbox(self) -> Sandbox: self._sandbox_acquired = True return self._acquire_sandbox() - def release_sandbox(self): + def release_sandbox(self) -> None: """ Releases a sandbox that is previously acquired. """ @@ -60,7 +61,7 @@ def release_sandbox(self): finally: self._lock.release() - def close(self): + def close(self) -> None: """Closes the lifecycle manager.""" if self._closed: return @@ -72,14 +73,14 @@ def _acquire_sandbox(self) -> Sandbox: pass @abstractmethod - def _release_sandbox(self): + def _release_sandbox(self) -> None: pass @abstractmethod - def _close(self): + def _close(self) -> None: pass - def _terminate_sandbox(self): + def _terminate_sandbox(self) -> None: if self._sandbox is None: return @@ -99,14 +100,15 @@ class EphemeralModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleManag Lifecycle manager that creates new sandbox on each call of the `acquire_sandbox` method and terminates it on `release_sandbox`. """ + def _acquire_sandbox(self) -> Sandbox: self._sandbox = self._create_sandbox() return self._sandbox - def _release_sandbox(self): + def _release_sandbox(self) -> None: self._terminate_sandbox() - def _close(self): + def _close(self) -> None: self._terminate_sandbox() @@ -115,6 +117,7 @@ class AttachModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleManager) Lifecycle manager that attaches to existing sandbox by its claim name without creating a new sandbox. Sandbox is also not terminated on the `release_sandbox`. """ + def __init__( self, client_settings: K8sAgentSandboxToolClientSettings, @@ -141,10 +144,10 @@ def _acquire_sandbox(self) -> Sandbox: "is expected to exist, but cannot be found." ) - def _release_sandbox(self): + def _release_sandbox(self) -> None: pass - def _close(self): + def _close(self) -> None: pass @@ -153,17 +156,16 @@ class PersistentModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleMana Lifecycle manager that manages a sandbox which remains the same across all calls of the`acquire_sandbox` and `release_sandbox`. """ - def _acquire_sandbox(self): + + def _acquire_sandbox(self) -> Sandbox: if self._sandbox is not None: return self._sandbox self._sandbox = self._create_sandbox() return self._sandbox - def _release_sandbox(self): - pass + def _release_sandbox(self) -> None: + pass - def _close(self): + def _close(self) -> None: self._terminate_sandbox() - - diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index e15e3d4688..7f6b01cb06 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -20,6 +20,7 @@ class k8sAgentSandboxPythonToolSchema(BaseModel): description="Maximum seconds to wait for the command to finish.", ) + class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): name: str = "K8s Agent Sandbox Python Tool" description: str = ( @@ -28,7 +29,9 @@ class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = k8sAgentSandboxPythonToolSchema - def _run_with_sandbox(self, sandbox: Sandbox, code: str, timeout: int) -> dict[str, Any]: + def _run_with_sandbox( + self, sandbox: Sandbox, code: str, timeout: int + ) -> dict[str, Any]: timeout_tracker = create_timeout_tracker(timeout) sandbox.files.write("main.py", code, timeout=timeout_tracker()) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py index e20cb84d52..69c32d8469 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py @@ -17,11 +17,11 @@ class K8sAgentSandboxToolClientSettings: It basically has the same arguments as the `k8s_agent_sandbox.SandboxClient` class. """ + connection_config: SandboxConnectionConfig | None = None tracer_config: SandboxTracerConfig | None = None cleanup: bool = False - cache_client: bool = True _client: SandboxClient | None = field(default=None, init=False, repr=False) @property @@ -30,9 +30,9 @@ def client(self) -> SandboxClient: return self._client self._client = SandboxClient( - connection_config=self.connection_config, - tracer_config=self.tracer_config, - cleanup=self.cleanup, + connection_config=self.connection_config, + tracer_config=self.tracer_config, + cleanup=self.cleanup, ) return self._client @@ -47,7 +47,7 @@ class K8sAgentSandboxToolSandboxSettings: namespace: Name of the namespace where a sandbox should be created. sandbox_timeout: The timeout in seconds after which the sandbox will be automatically killed. """ + warmpool: str namespace: str sandbox_timeout: int = 300 - diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py index 0cb4983250..2be723239a 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py @@ -18,7 +18,6 @@ from .base_tool import K8sAgentSandboxBaseTool - class K8sAgentSandboxToolset: """ The toolset is responsible for sharing the settings among many K8s Agent Sandbox tools. @@ -32,6 +31,7 @@ class K8sAgentSandboxToolset: cleanup_on_exit: When True, registers its :meth:`close` method at the `atexit` module to be called when the programm exits. """ + def __init__( self, lifecycle_manager: K8sAgentSandboxLifecycleManager, @@ -42,26 +42,22 @@ def __init__( if cleanup_on_exit: atexit.register(self.close) + self._all_tools: dict[str, "K8sAgentSandboxBaseTool"] = {} - - self._all_tools: dict[str, 'K8sAgentSandboxBaseTool'] = {} - - def add_tool(self, tool: 'K8sAgentSandboxBaseTool'): + def add_tool(self, tool: "K8sAgentSandboxBaseTool"): name = tool.name if name in self._all_tools: raise ValueError(f"The tool '{name}' is already in the toolset.") self._all_tools[name] = tool - @property - def tools(self) -> list['K8sAgentSandboxBaseTool']: + def tools(self) -> list["K8sAgentSandboxBaseTool"]: return list(self._all_tools.values()) def close(self): self.lifecycle_manager.close() - @classmethod def create( cls, @@ -71,7 +67,6 @@ def create( claim_name: str | None = None, cleanup_on_exit: bool = True, ) -> Self: - """ Create a toolset by using Agent Sandbox client and sandbox settings. @@ -94,7 +89,6 @@ def create( client_settings = client_settings or K8sAgentSandboxToolClientSettings() - if claim_name is not None: lifecycle_manager = AttachModeK8sAgentSandboxLifecycleManager( client_settings, @@ -118,4 +112,3 @@ def create( lifecycle_manager, cleanup_on_exit=cleanup_on_exit, ) - diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py index 0eba637969..1000315d32 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py @@ -18,20 +18,26 @@ def mock_sandbox(): sandbox = MagicMock() return sandbox + @pytest.fixture def mock_sandbox_client(): client = MagicMock() return client + @pytest.fixture -def mock_client_returns_mock_sandbox_in_create_sandbox(mock_sandbox, mock_sandbox_client): +def mock_client_returns_mock_sandbox_in_create_sandbox( + mock_sandbox, mock_sandbox_client +): mock_sandbox_client.create_sandbox.return_value = mock_sandbox + @pytest.fixture def mock_client_returns_mock_sandbox_in_get_sandbox(mock_sandbox, mock_sandbox_client): mock_sandbox_client.get_sandbox.return_value = mock_sandbox + @pytest.fixture def sample_sandbox_settings() -> K8sAgentSandboxToolSandboxSettings: return K8sAgentSandboxToolSandboxSettings( @@ -39,24 +45,30 @@ def sample_sandbox_settings() -> K8sAgentSandboxToolSandboxSettings: "my-namespace", ) + @pytest.fixture def mock_client_settings(mock_sandbox_client): settings = MagicMock() settings.client = mock_sandbox_client return settings + @pytest.fixture(params=["ephemeral", "attach", "persistent"]) def lifecycle_mode_name(request): return request.param + @pytest.fixture -def ephemeral_mode_lifecycle_manager(request, mock_client_settings, sample_sandbox_settings): +def ephemeral_mode_lifecycle_manager( + request, mock_client_settings, sample_sandbox_settings +): request.getfixturevalue("mock_client_returns_mock_sandbox_in_create_sandbox") return EphemeralModeK8sAgentSandboxLifecycleManager( mock_client_settings, sample_sandbox_settings, ) + @pytest.fixture def attach_mode_lifecycle_manager( request, @@ -70,8 +82,11 @@ def attach_mode_lifecycle_manager( "my-claim", ) + @pytest.fixture -def persistent_mode_lifecycle_manager(request, mock_client_settings, sample_sandbox_settings): +def persistent_mode_lifecycle_manager( + request, mock_client_settings, sample_sandbox_settings +): request.getfixturevalue("mock_client_returns_mock_sandbox_in_create_sandbox") request.getfixturevalue("mock_client_returns_mock_sandbox_in_get_sandbox") return PersistentModeK8sAgentSandboxLifecycleManager( @@ -79,6 +94,7 @@ def persistent_mode_lifecycle_manager(request, mock_client_settings, sample_sand sample_sandbox_settings, ) + @pytest.fixture def lifecycle_manager(request, lifecycle_mode_name): return request.getfixturevalue(f"{lifecycle_mode_name}_mode_lifecycle_manager") @@ -91,6 +107,7 @@ def sample_toolset(lifecycle_manager): ) return toolset + @pytest.fixture def lifecycle_mode_sandbox_termination_expected(lifecycle_manager): manager_type = type(lifecycle_manager) @@ -98,5 +115,4 @@ def lifecycle_mode_sandbox_termination_expected(lifecycle_manager): if manager_type is EphemeralModeK8sAgentSandboxLifecycleManager: return True else: - return False - + return False diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py index fbc1ac86ce..5332db3d73 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py @@ -11,6 +11,7 @@ class DummyToolInputSchema(BaseModel): test_arg: str = Field(description="some positional argument") test_kwarg: str | None = Field(default=None, description="some keyword argument") + class DummyK8sTool(K8sAgentSandboxBaseTool): name: str = "dummy_tool" description: str = "Dummy testing tool." @@ -19,7 +20,9 @@ class DummyK8sTool(K8sAgentSandboxBaseTool): def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: return self._dummy_work(sandbox, *args, **kwargs) - def _dummy_work(self, sandbox, test_arg: str, test_kwarg: str | None = None) -> dict[str, Any]: + def _dummy_work( + self, sandbox, test_arg: str, test_kwarg: str | None = None + ) -> dict[str, Any]: return { "sandbox-claim": sandbox.claim_name, "arg": test_arg, @@ -36,8 +39,10 @@ def test_tool_added_to_toolset(sample_toolset): assert len(sample_toolset.tools) == 1 + def test_run_with_sandbox( - sample_toolset, mock_sandbox, + sample_toolset, + mock_sandbox, lifecycle_mode_sandbox_termination_expected, ): claim_name = "some-claim" @@ -75,12 +80,15 @@ class FailingTool(DummyK8sTool): def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: raise Exception("some error") - tool = FailingTool(toolset=sample_toolset) assert not mock_sandbox.terminate.called - with patch.object(sample_toolset, 'lifecycle_manager', MagicMock(wraps=sample_toolset.lifecycle_manager)) as m: + with patch.object( + sample_toolset, + "lifecycle_manager", + MagicMock(wraps=sample_toolset.lifecycle_manager), + ) as m: with pytest.raises(Exception, match="some error"): tool.run( "some-arg", @@ -91,4 +99,3 @@ def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: assert mock_sandbox.terminate.called else: assert not mock_sandbox.terminate.called - diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py index 63ed3e9229..af9e4621f3 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py @@ -17,13 +17,16 @@ def k8s_file_tool(sample_toolset): return K8sAgentSandboxFileTool(toolset=sample_toolset) + class TestFileToolReadAction: @pytest.mark.parametrize("binary", [False, True]) def test_success(self, k8s_file_tool, mock_sandbox, binary): content = b"some-content" mock_sandbox.files.read.return_value = content - result = k8s_file_tool.run(action="read", path="some/path", binary=binary, timeout=120) + result = k8s_file_tool.run( + action="read", path="some/path", binary=binary, timeout=120 + ) if binary: assert result == { @@ -40,9 +43,8 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): mock_sandbox.files.read.assert_called_once_with("some/path", timeout=120) - def test_invalid_utf(self, k8s_file_tool, mock_sandbox): - content = b"Some malformed \xFF content" + content = b"Some malformed \xff content" mock_sandbox.files.read.return_value = content result = k8s_file_tool.run(action="read", path="some/path") @@ -50,7 +52,7 @@ def test_invalid_utf(self, k8s_file_tool, mock_sandbox): "content": base64.b64encode(content).decode("ascii"), "path": "some/path", "encoding": "base64", - "note": 'File was not valid utf-8; returned as base64.', + "note": "File was not valid utf-8; returned as base64.", } @@ -84,9 +86,14 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): "status": "written", } - mock_sandbox.commands.run.assert_called_once_with("mkdir -p parent", timeout=120) + mock_sandbox.commands.run.assert_called_once_with( + "mkdir -p parent", timeout=120 + ) - assert mock_sandbox.files.write.call_args.args == ("parent/file.txt", b"Hello World!") + assert mock_sandbox.files.write.call_args.args == ( + "parent/file.txt", + b"Hello World!", + ) assert 0 <= mock_sandbox.files.write.call_args.kwargs["timeout"] <= 120 def test_missing_content(self, k8s_file_tool, mock_sandbox): @@ -103,7 +110,6 @@ def test_missing_content(self, k8s_file_tool, mock_sandbox): assert mock_sandbox.files.write.call_args.args == ("parent/file.txt", b"") - def test_mkdir_parent_error(self, k8s_file_tool, mock_sandbox): mock_sandbox.commands.run.return_value = ExecutionResult( exit_code=1, @@ -120,6 +126,7 @@ def test_mkdir_parent_error(self, k8s_file_tool, mock_sandbox): content=content, ) + class TestFileToolAppendAction: @pytest.mark.parametrize("binary", [False, True]) def test_success(self, k8s_file_tool, mock_sandbox, binary): @@ -138,7 +145,6 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): else: content = content_to_append - result = k8s_file_tool.run( action="append", path="parent/file.txt", @@ -159,7 +165,10 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): assert mock_sandbox.files.read.call_args.kwargs["timeout"] == 120 mock_sandbox.files.write.assert_called_once() - assert mock_sandbox.files.write.call_args.args == ("parent/file.txt", b"Hello World") + assert mock_sandbox.files.write.call_args.args == ( + "parent/file.txt", + b"Hello World", + ) assert 0 <= mock_sandbox.files.write.call_args.kwargs["timeout"] <= 120 @@ -167,8 +176,12 @@ class TestFileToolListAction: def test_success(self, k8s_file_tool, mock_sandbox): modification_time = time.time() mock_sandbox.files.list.return_value = [ - FileEntry(name="test.txt", size=1000000, type="file", mod_time=modification_time), - FileEntry(name="subdir", size=4096, type="directory", mod_time=modification_time), + FileEntry( + name="test.txt", size=1000000, type="file", mod_time=modification_time + ), + FileEntry( + name="subdir", size=4096, type="directory", mod_time=modification_time + ), ] result = k8s_file_tool.run(action="list", path="some/directory") @@ -196,9 +209,7 @@ class TestFileToolDeleteAction: def test_success(self, k8s_file_tool, mock_sandbox): file_path = "parent/file.txt" mock_sandbox.commands.run.return_value = ExecutionResult( - exit_code=0, - stdout="", - stderr="" + exit_code=0, stdout="", stderr="" ) result = k8s_file_tool.run(action="delete", path=file_path) @@ -208,15 +219,16 @@ def test_success(self, k8s_file_tool, mock_sandbox): } mock_sandbox.commands.run.assert_called_once() - assert mock_sandbox.commands.run.call_args.args[0] == f"rm -r {shlex.quote(file_path)}" + assert ( + mock_sandbox.commands.run.call_args.args[0] + == f"rm -r {shlex.quote(file_path)}" + ) class TestFileToolMkdirAction: def test_success(self, k8s_file_tool, mock_sandbox): mock_sandbox.commands.run.return_value = ExecutionResult( - exit_code=0, - stdout="", - stderr="" + exit_code=0, stdout="", stderr="" ) result = k8s_file_tool.run(action="mkdir", path="parent/subfolder", timeout=120) @@ -229,7 +241,8 @@ def test_success(self, k8s_file_tool, mock_sandbox): mock_sandbox.commands.run.assert_called_once_with( f"mkdir -p {shlex.quote('parent/subfolder')}", timeout=120, - ) + ) + class TestFileToolInfoAction: def test_not_implemented_error(self, k8s_file_tool): @@ -243,10 +256,8 @@ def test_success(self, k8s_file_tool, mock_sandbox): result = k8s_file_tool.run(action="exists", path="parent/file.txt", timeout=120) - assert result == { - "exists": True, - "path": "parent/file.txt" - } - - mock_sandbox.files.exists.assert_called_once_with("parent/file.txt", timeout=120) + assert result == {"exists": True, "path": "parent/file.txt"} + mock_sandbox.files.exists.assert_called_once_with( + "parent/file.txt", timeout=120 + ) diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py index 187d4e0010..8a01800bf1 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py @@ -19,25 +19,25 @@ def manager(self, mock_client_settings, sample_sandbox_settings): def test_main(self, manager, mock_sandbox_client): - sandbox = manager.acquire_sandbox() - assert mock_sandbox_client.create_sandbox.call_count == 1 + sandbox = manager.acquire_sandbox() + assert mock_sandbox_client.create_sandbox.call_count == 1 - assert sandbox.terminate.call_count == 0 - manager.release_sandbox() - assert sandbox.terminate.call_count == 1 + assert sandbox.terminate.call_count == 0 + manager.release_sandbox() + assert sandbox.terminate.call_count == 1 - sandbox.reset_mock() + sandbox.reset_mock() - sandbox = manager.acquire_sandbox() - assert mock_sandbox_client.create_sandbox.call_count == 2 + sandbox = manager.acquire_sandbox() + assert mock_sandbox_client.create_sandbox.call_count == 2 - assert not sandbox.terminate.called - manager.release_sandbox() - assert sandbox.terminate.call_count == 1 + assert not sandbox.terminate.called + manager.release_sandbox() + assert sandbox.terminate.call_count == 1 - assert mock_sandbox_client.get_sandbox.call_count == 0 - manager.close() - assert sandbox.terminate.call_count == 1 + assert mock_sandbox_client.get_sandbox.call_count == 0 + manager.close() + assert sandbox.terminate.call_count == 1 @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") def test_close_non_acquired(self, manager, mock_sandbox): @@ -63,7 +63,9 @@ def manager(self, mock_client_settings, sample_sandbox_settings): ) @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") - def test_acquire_and_release(self, manager, mock_sandbox_client, sample_sandbox_settings): + def test_acquire_and_release( + self, manager, mock_sandbox_client, sample_sandbox_settings + ): sandbox = manager.acquire_sandbox() assert not mock_sandbox_client.create_sandbox.called @@ -71,7 +73,10 @@ def test_acquire_and_release(self, manager, mock_sandbox_client, sample_sandbox_ assert mock_sandbox_client.get_sandbox.call_count == 1 assert mock_sandbox_client.get_sandbox.call_args.args[0] == "my-claim" - assert mock_sandbox_client.get_sandbox.call_args.kwargs["namespace"] == sample_sandbox_settings.namespace + assert ( + mock_sandbox_client.get_sandbox.call_args.kwargs["namespace"] + == sample_sandbox_settings.namespace + ) manager.release_sandbox() assert not sandbox.terminate.called diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py index 30be16f778..48d5e89eab 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py @@ -26,7 +26,9 @@ def test_run(k8s_python_tool, mock_sandbox, exit_code): "stderr": "some-logs", } - mock_sandbox.files.write.assert_called_once_with("main.py", "some-code", timeout=120) + mock_sandbox.files.write.assert_called_once_with( + "main.py", "some-code", timeout=120 + ) assert mock_sandbox.commands.run.call_args.args == ("python3 main.py",) assert 0 <= mock_sandbox.commands.run.call_args.kwargs["timeout"] <= 120 diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py index 336aab53e7..8767a6ccf5 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py @@ -43,7 +43,7 @@ class TestSandboxLifecycleModesSelection: ({}, EphemeralModeK8sAgentSandboxLifecycleManager), (dict(claim_name="my_claim"), AttachModeK8sAgentSandboxLifecycleManager), (dict(persistent=True), PersistentModeK8sAgentSandboxLifecycleManager), - ] + ], ) def test_lifecycle_modes( self, @@ -60,13 +60,15 @@ def test_lifecycle_modes( assert type(toolset.lifecycle_manager) is expected_manager_class - - def test_persistent_and_attach_error(self, mock_client_settings, sample_sandbox_settings): - with pytest.raises(ValueError, match="persistent and attach modes are mutually exclusive"): - _ = K8sAgentSandboxToolset.create( - sandbox_settings=sample_sandbox_settings, - client_settings=mock_client_settings, - persistent=True, - claim_name="my_claim", - ) - + def test_persistent_and_attach_error( + self, mock_client_settings, sample_sandbox_settings + ): + with pytest.raises( + ValueError, match="persistent and attach modes are mutually exclusive" + ): + _ = K8sAgentSandboxToolset.create( + sandbox_settings=sample_sandbox_settings, + client_settings=mock_client_settings, + persistent=True, + claim_name="my_claim", + ) From cbf51ee8ddb2d5ad421232608a782ff5f594c9fb Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Wed, 8 Jul 2026 11:22:10 +0200 Subject: [PATCH 16/45] update readme, small fixes --- lib/crewai-tools/src/crewai_tools/py.typed | 0 .../src/crewai_tools/tools/k8s_agent_sandbox/README.md | 2 ++ 2 files changed, 2 insertions(+) delete mode 100644 lib/crewai-tools/src/crewai_tools/py.typed diff --git a/lib/crewai-tools/src/crewai_tools/py.typed b/lib/crewai-tools/src/crewai_tools/py.typed deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index 1c233ec4ee..2eca0b20b1 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -25,6 +25,8 @@ All tools that are meant to share the same sandbox settings and sandbox lifecycl ### Toolset example +This example expects that you have a cluster with the Agent Sandbox installed and all remaining prerequisites like sandbox router, sandbox template and a sandbox warmpool deployed. Learn more [here](https://github.com/kubernetes-sigs/agent-sandbox/tree/main#installation). + ```python from crewai_tools import ( K8sAgentSandboxToolSandboxSettings as SandboxSettings, From 2a8cfe21282bcaa20b528fd7934d8ba5decfa44c Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 13 Jul 2026 12:48:36 +0200 Subject: [PATCH 17/45] remove append instruction --- .../src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 5ec1b612dc..61231e97ee 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -96,11 +96,7 @@ class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): "Perform filesystem operations inside an K8s agent sandbox: read a file, " "write content to a path, append content to an existing file, list a " "directory, delete a path, make a directory, fetch file metadata, or " - "check whether a path exists. For files larger than a few KB, create " - "the file with action='write' and empty content, then send the body " - "via multiple 'append' calls of ~4KB each to stay within tool-call " - "payload limits." - ) + "check whether a path exists.") args_schema: type[BaseModel] = K8sAgentSandboxFileToolSchema def _run_with_sandbox( From c31b305abd61dd2bf081611e2f4b7af6ec63ed75 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 13 Jul 2026 12:52:22 +0200 Subject: [PATCH 18/45] run python tools codein tmp file --- .../tools/k8s_agent_sandbox/python_tool.py | 31 ++++++++++++------- .../k8s_agent_sandbox/test_python_tool.py | 22 +++++++------ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index 7f6b01cb06..04e9fbd9d2 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -1,5 +1,4 @@ -from typing import Any - +import time from pydantic import ( BaseModel, Field, @@ -21,6 +20,12 @@ class k8sAgentSandboxPythonToolSchema(BaseModel): ) +class K8sAgentSandboxPythonToolOutput(BaseModel): + exit_code: int | None = Field(default=None, description="The exit code of the Python script execution.") + stdout: str | None = Field(default=None, description="The standard output produced by the Python script.") + stderr: str | None = Field(default=None, description="The standard error output produced by the Python script.") + + class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): name: str = "K8s Agent Sandbox Python Tool" description: str = ( @@ -31,14 +36,18 @@ class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): def _run_with_sandbox( self, sandbox: Sandbox, code: str, timeout: int - ) -> dict[str, Any]: + ) -> K8sAgentSandboxPythonToolOutput: timeout_tracker = create_timeout_tracker(timeout) - sandbox.files.write("main.py", code, timeout=timeout_tracker()) - result = sandbox.commands.run("python3 main.py", timeout=timeout_tracker()) - - return { - "exit_code": result.exit_code, - "stdout": result.stdout, - "stderr": result.stderr, - } + + tmp_file_path = f"/tmp/crewai-{int(time.time())}.py" + + sandbox.files.write(tmp_file_path, code.encode("utf-8"), timeout=timeout_tracker()) + + result = sandbox.commands.run(f"python3 {tmp_file_path}", timeout=timeout_tracker()) + + return K8sAgentSandboxPythonToolOutput( + exit_code=result.exit_code, + stdout=result.stdout, + stderr=result.stderr, + ) diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py index 48d5e89eab..df3246a590 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py @@ -1,8 +1,12 @@ +import base64 import pytest from k8s_agent_sandbox.models import ExecutionResult -from crewai_tools.tools.k8s_agent_sandbox.python_tool import K8sAgentSandboxPythonTool +from crewai_tools.tools.k8s_agent_sandbox.python_tool import ( + K8sAgentSandboxPythonTool, + K8sAgentSandboxPythonToolOutput, +) @pytest.fixture @@ -20,15 +24,13 @@ def test_run(k8s_python_tool, mock_sandbox, exit_code): result = k8s_python_tool.run(code="some-code", timeout=120) - assert result == { - "exit_code": exit_code, - "stdout": "some-output", - "stderr": "some-logs", - } - - mock_sandbox.files.write.assert_called_once_with( - "main.py", "some-code", timeout=120 + assert result == K8sAgentSandboxPythonToolOutput( + exit_code=exit_code, + stdout="some-output", + stderr="some-logs", ) - assert mock_sandbox.commands.run.call_args.args == ("python3 main.py",) + assert mock_sandbox.files.write.called + + assert mock_sandbox.commands.run.call_args.args[0].startswith("python3 /tmp",) assert 0 <= mock_sandbox.commands.run.call_args.kwargs["timeout"] <= 120 From a5faa4e1991cdcf7c0ddefa98c020cfb58fe4ef4 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 13 Jul 2026 12:55:37 +0200 Subject: [PATCH 19/45] Add timings note in the Ephemeral mode readme --- .../src/crewai_tools/tools/k8s_agent_sandbox/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index 2eca0b20b1..e56a8a6123 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -59,7 +59,7 @@ with the `K8sAgentSandboxToolset.create` factory method: | **Persistent** (`persistent=True`) | Lazily on first use | At process exit (via `atexit`), or manually via `toolset.close()` | | **Attach** (`claim_name="…"`) | Never — the tool attaches to an existing sandbox | Never — the tool will not kill a sandbox it did not create | -Ephemeral mode is the safe default: nothing leaks if the agent forgets to clean up. Use persistent mode when you want filesystem state or installed packages to carry across steps. +Ephemeral mode is the safe default: nothing leaks if the agent forgets to clean up, but since it has to create a new sandbox on each new run, the increased execution time is expected. Use persistent mode when you want filesystem state or installed packages to carry across steps. K8s agent sandboxes also auto-expire after an idle timeout. Tune it via `sandbox_timeout` (seconds, default `300`). From eff8ebd2cdc52b051a7b45c9a1e08151dd504b47 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 13 Jul 2026 12:57:29 +0200 Subject: [PATCH 20/45] check path of file tool delete action --- .../tools/k8s_agent_sandbox/file_tool.py | 6 +++++ .../tools/k8s_agent_sandbox/test_file_tool.py | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 61231e97ee..ea5e8b10f3 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -215,6 +215,12 @@ def _list(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any]: def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any]: # TODO: Fall back to deleting with shell command. # Use normal file delete API when it is available in SDK. + + path = posixpath.normpath(path) + + if posixpath.dirname(path) == "/": + raise RuntimeError(f"The path {path} cannot be deleted.") + command = f"rm -r {shlex.quote(path)}" try: result = sandbox.commands.run(command, timeout=timeout) diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py index af9e4621f3..8c006f68f7 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py @@ -224,6 +224,33 @@ def test_success(self, k8s_file_tool, mock_sandbox): == f"rm -r {shlex.quote(file_path)}" ) + @pytest.mark.parametrize( + "path,expect_error", + [ + ("/", True), + ("/usr", True), + ("/etc", True), + ("/app", True), + ("/app/some-path", False), + ] + ) + def test_delete_root_error( + self, + k8s_file_tool, + mock_sandbox, + path, + expect_error, + ): + mock_sandbox.commands.run.return_value = ExecutionResult( + exit_code=0, stdout="", stderr="" + ) + + if expect_error: + with pytest.raises(RuntimeError, match="cannot be deleted"): + k8s_file_tool.run(action="delete", path=path) + else: + k8s_file_tool.run(action="delete", path=path) + class TestFileToolMkdirAction: def test_success(self, k8s_file_tool, mock_sandbox): From 3e7c1fab88fe54ab7d90a6513d66710182dcea6c Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 13 Jul 2026 13:01:33 +0200 Subject: [PATCH 21/45] remove info action from file tool --- .../src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py | 4 ---- .../tests/tools/k8s_agent_sandbox/test_file_tool.py | 6 ------ 2 files changed, 10 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index ea5e8b10f3..b86a451d5f 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -41,7 +41,6 @@ class K8sAgentSandboxFileToolSchema(BaseModel): - list: lists a directory. - delete: removes a file/directory. - mkdir: creates a directory. - - info: Not currntly supported. Returns file metadata. - exists: returns a boolean for whether the path exists. """), ) @@ -236,9 +235,6 @@ def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any return {"status": "deleted", "path": path} - def _info(self, sandbox: Sandbox, path: str) -> dict[str, Any]: - raise NotImplementedError("The info action is not currently supoported.") - def _mkdir(self, sandbox: Sandbox, path: str, *, timeout: int): try: result = sandbox.commands.run( diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py index 8c006f68f7..ba9e831f27 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py @@ -271,12 +271,6 @@ def test_success(self, k8s_file_tool, mock_sandbox): ) -class TestFileToolInfoAction: - def test_not_implemented_error(self, k8s_file_tool): - with pytest.raises(NotImplementedError, match="is not currently supoported"): - k8s_file_tool.run(action="info", path="parent/file.txt") - - class TestFileToolExistsAction: def test_success(self, k8s_file_tool, mock_sandbox): mock_sandbox.files.exists.return_value = True From a7d63e4e6e143f3fa4ffc15006f102f92179f4fb Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 13 Jul 2026 13:04:20 +0200 Subject: [PATCH 22/45] replace tool dict output with pydantic models. Typo fixes --- .../tools/k8s_agent_sandbox/README.md | 8 +- .../tools/k8s_agent_sandbox/base_tool.py | 10 +- .../tools/k8s_agent_sandbox/exec_tool.py | 20 ++-- .../tools/k8s_agent_sandbox/file_tool.py | 110 ++++++++++-------- .../k8s_agent_sandbox/lifecycle_manager.py | 3 +- .../tools/k8s_agent_sandbox/toolset.py | 8 +- .../tools/k8s_agent_sandbox/test_base_tool.py | 32 ++--- .../tools/k8s_agent_sandbox/test_exec_tool.py | 15 ++- .../tools/k8s_agent_sandbox/test_file_tool.py | 106 ++++++++--------- 9 files changed, 171 insertions(+), 141 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index e56a8a6123..450b6d4048 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -1,6 +1,6 @@ # K8s Agent Sandbox Tools -Run shell commands, execute Python, and manage files inside an [Kubernetes Agent Sandbox](https://github.com/kubernetes-sigs/agent-sandbox). K8s Agent Sandbox enables easy management of isolated, stateful, singleton workloads, ideal for use cases like AI agent runtimes. +Run shell commands, execute Python, and manage files inside a [Kubernetes Agent Sandbox](https://github.com/kubernetes-sigs/agent-sandbox). K8s Agent Sandbox enables easy management of isolated, stateful, singleton workloads, ideal for use cases like AI agent runtimes. Three tools are provided so you can pick what the agent actually needs: @@ -18,7 +18,7 @@ pip install "crewai-tools[k8s_agent_sandbox]" ## Sandbox toolset -Instead of configuring sandbox-related settngs for each of the tools separately, the `K8sAgentSandboxToolset` class must be used. +Instead of configuring sandbox-related settings for each of the tools separately, the `K8sAgentSandboxToolset` class must be used. All tools that are meant to share the same sandbox settings and sandbox lifecycle has to be added to the same instance of the `K8sAgentSandboxToolset` class. @@ -36,7 +36,7 @@ from crewai_tools import ( ) toolset = K8sAgentSandboxToolset.create( - # Sandbox settigns for all tools in this toolset. + # Sandbox settings for all tools in this toolset. sandbox_settings=SandboxSettings( warmpool="my-warmpool", namespace="my-namespace", @@ -72,7 +72,7 @@ The following example shows setting up the sandbox client to use the "Gateway" c ```python from crewai_tools import ( - K8sAgentSandboxToolClientSettings as ClientSettings # <- import this. + K8sAgentSandboxToolClientSettings as ClientSettings, # <- import this. K8sAgentSandboxToolSandboxSettings as SandboxSettings, K8sAgentSandboxToolset, K8sAgentSandboxExecTool, diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py index 0c311b0f3d..43271940e2 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -1,13 +1,13 @@ -from typing import Any, Callable, Optional +from typing import Any, Callable import time import logging from pydantic import ( + BaseModel, Field, SkipValidation, ) from abc import abstractmethod -from pydantic import Field from crewai.tools import BaseTool from .toolset import K8sAgentSandboxToolset @@ -29,7 +29,7 @@ def model_post_init(self, __context: Any) -> None: self.toolset.add_tool(self) super().model_post_init(__context) - def _run(self, *args, **kwargs: Any) -> dict[str, Any]: + def _run(self, *args, **kwargs: Any) -> BaseModel: try: sandbox = self.toolset.lifecycle_manager.acquire_sandbox() return self._run_with_sandbox(sandbox, *args, **kwargs) @@ -37,14 +37,14 @@ def _run(self, *args, **kwargs: Any) -> dict[str, Any]: self.toolset.lifecycle_manager.release_sandbox() @abstractmethod - def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: + def _run_with_sandbox(self, sandbox, *args, **kwargs) -> BaseModel: pass def create_timeout_tracker(timeout: int) -> Callable[[], int]: """ This returns a helper tracker to track one global timeout - in case where we run multple sandbox commands in one tool action. + in case where we run multiple sandbox commands in one tool action. """ start_time = int(time.time()) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index e0a4aa2168..05664fe16c 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -17,6 +17,12 @@ class k8sAgentSandboxExecToolSchema(BaseModel): ) +class K8sAgentSandboxExecToolOutput(BaseModel): + exit_code: int | None = Field(default=None, description="The exit code of the executed command.") + stdout: str | None = Field(default=None, description="The standard output produced by the command.") + stderr: str | None = Field(default=None, description="The standard error output produced by the command.") + + class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): name: str = "K8s Agent Sandbox Exec Tool" description: str = ( @@ -24,7 +30,7 @@ class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = k8sAgentSandboxExecToolSchema - def _run_with_sandbox(self, sandbox: Sandbox, *args, **kwargs) -> dict[str, Any]: + def _run_with_sandbox(self, sandbox: Sandbox, *args, **kwargs) -> K8sAgentSandboxExecToolOutput: return self._run_command(sandbox, *args, **kwargs) def _run_command( @@ -32,10 +38,10 @@ def _run_command( sandbox: Sandbox, command: str, timeout: int, - ) -> dict[str, Any]: + ) -> K8sAgentSandboxExecToolOutput: result = sandbox.commands.run(command, timeout=timeout) - return { - "exit_code": result.exit_code, - "stdout": result.stdout, - "stderr": result.stderr, - } + return K8sAgentSandboxExecToolOutput( + exit_code=result.exit_code, + stdout=result.stdout, + stderr=result.stderr, + ) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index b86a451d5f..f85f5be383 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -81,8 +81,28 @@ def _validate_action_args(self) -> "K8sAgentSandboxFileToolSchema": return self +class FileEntryModel(BaseModel): + name: str | None = Field(default=None, description="The name of the file or directory.") + type: str | None = Field(default=None, description="The type of the entry, typically 'file' or 'directory'.") + size: int | None = Field(default=None, description="The size of the file in bytes.") + mod_time: float | None = Field(default=None, description="The last modification time of the entry as a Unix timestamp.") + + +class K8sAgentSandboxFileToolOutput(BaseModel): + path: str | None = Field(default=None, description="The target path of the file or directory.") + status: str | None = Field(default=None, description="The status of the file operation, e.g., 'written', 'appended', 'created', 'deleted'.") + exists: bool | None = Field(default=None, description="Indicates whether the file or directory exists. Only returned for the 'exists' action.") + content: str | None = Field(default=None, description="The content of the file. Returned for the 'read' action.") + encoding: str | None = Field(default=None, description="The encoding of the content, e.g., 'utf-8' or 'base64'.") + note: str | None = Field(default=None, description="An optional note providing additional context about the operation (e.g., regarding non-utf8 files).") + bytes: int | None = Field(default=None, description="The number of bytes written. Returned for the 'write' action.") + appended_bytes: int | None = Field(default=None, description="The number of bytes appended to the file. Returned for the 'append' action.") + total_bytes: int | None = Field(default=None, description="The total size of the file in bytes after appending. Returned for the 'append' action.") + entries: list[FileEntryModel] | None = Field(default=None, description="A list of file and directory entries. Returned for the 'list' action.") + + class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): - """Read, write, and manage files inside an K8s agent sandbox. + """Read, write, and manage files inside a K8s agent sandbox. Notes: - Most useful with `persistent=True` or an explicit `sandbox_id`. With @@ -92,7 +112,7 @@ class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): name: str = "K8s Agent Sandbox Files Tool" description: str = ( - "Perform filesystem operations inside an K8s agent sandbox: read a file, " + "Perform filesystem operations inside a K8s agent sandbox: read a file, " "write content to a path, append content to an existing file, list a " "directory, delete a path, make a directory, fetch file metadata, or " "check whether a path exists.") @@ -106,7 +126,7 @@ def _run_with_sandbox( timeout: int, binary: bool, content: str | None = None, - ) -> dict[str, Any]: + ) -> K8sAgentSandboxFileToolOutput: if action == "read": return self._read(sandbox, path, binary=binary, timeout=timeout) @@ -132,12 +152,10 @@ def _run_with_sandbox( return self._delete(sandbox, path, timeout=timeout) if action == "mkdir": self._mkdir(sandbox, path, timeout=timeout) - return {"status": "created", "path": path} - if action == "info": - return self._info(sandbox, path) + return K8sAgentSandboxFileToolOutput(status="created", path=path) if action == "exists": result = sandbox.files.exists(path, timeout=timeout) - return {"path": path, "exists": result} + return K8sAgentSandboxFileToolOutput(path=path, exists=result) raise ValueError(f"Unknown action: {action}") @@ -148,24 +166,24 @@ def _read( *, binary: bool, timeout: int, - ) -> dict[str, Any]: + ) -> K8sAgentSandboxFileToolOutput: data: bytes = sandbox.files.read(path, timeout=timeout) if binary: - return { - "path": path, - "encoding": "base64", - "content": base64.b64encode(data).decode("ascii"), - } + return K8sAgentSandboxFileToolOutput( + path=path, + encoding="base64", + content=base64.b64encode(data).decode("ascii"), + ) try: - return {"path": path, "encoding": "utf-8", "content": data.decode("utf-8")} + return K8sAgentSandboxFileToolOutput(path=path, encoding="utf-8", content=data.decode("utf-8")) except UnicodeDecodeError: - return { - "path": path, - "encoding": "base64", - "content": base64.b64encode(data).decode("ascii"), - "note": "File was not valid utf-8; returned as base64.", - } + return K8sAgentSandboxFileToolOutput( + path=path, + encoding="base64", + content=base64.b64encode(data).decode("ascii"), + note="File was not valid utf-8; returned as base64.", + ) def _write( self, @@ -175,12 +193,12 @@ def _write( *, binary: bool, timeout_tracker: Callable[[], int], - ) -> dict[str, Any]: + ) -> K8sAgentSandboxFileToolOutput: payload = base64.b64decode(content) if binary else content.encode("utf-8") self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) sandbox.files.write(path, payload, timeout=timeout_tracker()) - return {"status": "written", "path": path, "bytes": len(payload)} + return K8sAgentSandboxFileToolOutput(status="written", path=path, bytes=len(payload)) def _append( self, @@ -190,28 +208,28 @@ def _append( *, binary: bool, timeout_tracker: Callable[[], int], - ) -> dict[str, Any]: + ) -> K8sAgentSandboxFileToolOutput: chunk: bytes = base64.b64decode(content) if binary else content.encode("utf-8") self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) existing = sandbox.files.read(path, timeout=timeout_tracker()) payload = existing + chunk sandbox.files.write(path, payload, timeout=timeout_tracker()) - return { - "status": "appended", - "path": path, - "appended_bytes": len(chunk), - "total_bytes": len(payload), - } - - def _list(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any]: + return K8sAgentSandboxFileToolOutput( + status="appended", + path=path, + appended_bytes=len(chunk), + total_bytes=len(payload), + ) + + def _list(self, sandbox: Sandbox, path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: entries = sandbox.files.list(path, timeout=timeout) - return { - "path": path, - "entries": [self._entry_to_dict(e) for e in entries], - } + return K8sAgentSandboxFileToolOutput( + path=path, + entries=[self._entry_to_dict(e) for e in entries], + ) - def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any]: + def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: # TODO: Fall back to deleting with shell command. # Use normal file delete API when it is available in SDK. @@ -233,7 +251,7 @@ def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> dict[str, Any f"Cannot delete directory {path}. Error: {result.stderr}." ) - return {"status": "deleted", "path": path} + return K8sAgentSandboxFileToolOutput(status="deleted", path=path) def _mkdir(self, sandbox: Sandbox, path: str, *, timeout: int): try: @@ -259,16 +277,10 @@ def _ensure_parent_dir(self, sandbox: Sandbox, path: str, timeout: int): return self._mkdir(sandbox, parent, timeout=timeout) @staticmethod - def _entry_to_dict(entry: FileEntry) -> dict[str, Any]: - - fields = ( - "name", - "type", - "size", - "mod_time", + def _entry_to_dict(entry: FileEntry) -> FileEntryModel: + return FileEntryModel( + name=getattr(entry, "name", None), + type=getattr(entry, "type", None), + size=getattr(entry, "size", None), + mod_time=getattr(entry, "mod_time", None), ) - result: dict[str, Any] = {} - for field in fields: - value = getattr(entry, field, None) - result[field] = value - return result diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index d6a623f8c0..8c2e387c09 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -39,7 +39,7 @@ def __init__( def acquire_sandbox(self) -> Sandbox: """ Acquires a sandbox based on this implementation and returns it. - In order tto be acquired again by someone else, it has to be + In order to be acquired again by someone else, it has to be released first by the :meth:`release_sandbox` method. """ if self._closed: @@ -67,6 +67,7 @@ def close(self) -> None: return self._close() + self._closed = True @abstractmethod def _acquire_sandbox(self) -> Sandbox: diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py index 2be723239a..bff02ef59c 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py @@ -23,13 +23,13 @@ class K8sAgentSandboxToolset: The toolset is responsible for sharing the settings among many K8s Agent Sandbox tools. The tools that are added to a same toolset will share the sandbox settings and the sandbox itself. - It is recommended to use the factory method :meth:`create` insteat of the constructor. + It is recommended to use the factory method :meth:`create` instead of the constructor. Args: - lifecycle_manager: The instanse of a sandbox lifecycle manager that is responsible for + lifecycle_manager: The instance of a sandbox lifecycle manager that is responsible for managing a sandbox. cleanup_on_exit: When True, registers its :meth:`close` method at the `atexit` module - to be called when the programm exits. + to be called when the program exits. """ def __init__( @@ -71,7 +71,7 @@ def create( Create a toolset by using Agent Sandbox client and sandbox settings. Args: - sandbox_settings: Settings for the sandbox instanse that will be + sandbox_settings: Settings for the sandbox instance that will be managed by this toolset. client_settings: Settings for K8s Agent Sandbox client. If None, the default settings with Local Tunnel Mode is used. diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py index 5332db3d73..aeb93580d5 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py @@ -12,22 +12,28 @@ class DummyToolInputSchema(BaseModel): test_kwarg: str | None = Field(default=None, description="some keyword argument") +class DummyK8sToolOutput(BaseModel): + sandbox_claim: str + arg: str + kwarg: str | None + + class DummyK8sTool(K8sAgentSandboxBaseTool): name: str = "dummy_tool" description: str = "Dummy testing tool." args_schema: type[BaseModel] = DummyToolInputSchema - def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: + def _run_with_sandbox(self, sandbox, *args, **kwargs) -> DummyK8sToolOutput: return self._dummy_work(sandbox, *args, **kwargs) def _dummy_work( self, sandbox, test_arg: str, test_kwarg: str | None = None - ) -> dict[str, Any]: - return { - "sandbox-claim": sandbox.claim_name, - "arg": test_arg, - "kwarg": test_kwarg, - } + ) -> DummyK8sToolOutput: + return DummyK8sToolOutput( + sandbox_claim=sandbox.claim_name, + arg=test_arg, + kwarg=test_kwarg, + ) def test_tool_added_to_toolset(sample_toolset): @@ -59,11 +65,11 @@ def test_run_with_sandbox( test_kwarg="some-kwarg", ) - assert result == { - "sandbox-claim": claim_name, - "arg": "some-arg", - "kwarg": "some-kwarg", - } + assert result == DummyK8sToolOutput( + sandbox_claim=claim_name, + arg="some-arg", + kwarg="some-kwarg", + ) if lifecycle_mode_sandbox_termination_expected: assert mock_sandbox.terminate.called @@ -77,7 +83,7 @@ def test_sandbox_released_after_error( lifecycle_mode_sandbox_termination_expected, ): class FailingTool(DummyK8sTool): - def _run_with_sandbox(self, sandbox, *args, **kwargs) -> dict[str, Any]: + def _run_with_sandbox(self, sandbox, *args, **kwargs) -> DummyK8sToolOutput: raise Exception("some error") tool = FailingTool(toolset=sample_toolset) diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py index 886628f366..bfd045140a 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py @@ -1,7 +1,10 @@ import pytest from k8s_agent_sandbox.models import ExecutionResult -from crewai_tools.tools.k8s_agent_sandbox.exec_tool import K8sAgentSandboxExecTool +from crewai_tools.tools.k8s_agent_sandbox.exec_tool import ( + K8sAgentSandboxExecTool, + K8sAgentSandboxExecToolOutput, +) from crewai_tools.tools.k8s_agent_sandbox.toolset import K8sAgentSandboxToolset @@ -22,10 +25,10 @@ def test_run_success(exit_code, k8s_exec_tool, mock_sandbox): result = k8s_exec_tool.run("some-command", timeout=120) - assert result == { - "exit_code": exit_code, - "stdout": "some-output", - "stderr": "some-logs", - } + assert result == K8sAgentSandboxExecToolOutput( + exit_code=exit_code, + stdout="some-output", + stderr="some-logs", + ) mock_sandbox.commands.run.assert_called_once_with("some-command", timeout=120) diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py index ba9e831f27..b15b5af24d 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py @@ -10,6 +10,8 @@ from crewai_tools.tools.k8s_agent_sandbox.file_tool import ( K8sAgentSandboxFileTool, + K8sAgentSandboxFileToolOutput, + FileEntryModel, ) @@ -29,17 +31,17 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): ) if binary: - assert result == { - "content": base64.b64encode(content).decode("ascii"), - "path": "some/path", - "encoding": "base64", - } + assert result == K8sAgentSandboxFileToolOutput( + content=base64.b64encode(content).decode("ascii"), + path="some/path", + encoding="base64", + ) else: - assert result == { - "content": "some-content", - "path": "some/path", - "encoding": "utf-8", - } + assert result == K8sAgentSandboxFileToolOutput( + content="some-content", + path="some/path", + encoding="utf-8", + ) mock_sandbox.files.read.assert_called_once_with("some/path", timeout=120) @@ -48,12 +50,12 @@ def test_invalid_utf(self, k8s_file_tool, mock_sandbox): mock_sandbox.files.read.return_value = content result = k8s_file_tool.run(action="read", path="some/path") - assert result == { - "content": base64.b64encode(content).decode("ascii"), - "path": "some/path", - "encoding": "base64", - "note": "File was not valid utf-8; returned as base64.", - } + assert result == K8sAgentSandboxFileToolOutput( + content=base64.b64encode(content).decode("ascii"), + path="some/path", + encoding="base64", + note="File was not valid utf-8; returned as base64.", + ) class TestFileToolWriteAction: @@ -80,11 +82,11 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): timeout=120, ) - assert result == { - "bytes": 12, - "path": "parent/file.txt", - "status": "written", - } + assert result == K8sAgentSandboxFileToolOutput( + bytes=12, + path="parent/file.txt", + status="written", + ) mock_sandbox.commands.run.assert_called_once_with( "mkdir -p parent", timeout=120 @@ -153,12 +155,12 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): timeout=120, ) - assert result == { - "path": "parent/file.txt", - "status": "appended", - "appended_bytes": 6, - "total_bytes": 11, - } + assert result == K8sAgentSandboxFileToolOutput( + path="parent/file.txt", + status="appended", + appended_bytes=6, + total_bytes=11, + ) mock_sandbox.files.read.assert_called_once() assert mock_sandbox.files.read.call_args.args == ("parent/file.txt",) @@ -186,23 +188,23 @@ def test_success(self, k8s_file_tool, mock_sandbox): result = k8s_file_tool.run(action="list", path="some/directory") - assert result == { - "entries": [ - { - "mod_time": modification_time, - "name": "test.txt", - "size": 1000000, - "type": "file", - }, - { - "mod_time": modification_time, - "name": "subdir", - "size": 4096, - "type": "directory", - }, + assert result == K8sAgentSandboxFileToolOutput( + entries=[ + FileEntryModel( + mod_time=modification_time, + name="test.txt", + size=1000000, + type="file", + ), + FileEntryModel( + mod_time=modification_time, + name="subdir", + size=4096, + type="directory", + ), ], - "path": "some/directory", - } + path="some/directory", + ) class TestFileToolDeleteAction: @@ -213,10 +215,10 @@ def test_success(self, k8s_file_tool, mock_sandbox): ) result = k8s_file_tool.run(action="delete", path=file_path) - assert result == { - "path": file_path, - "status": "deleted", - } + assert result == K8sAgentSandboxFileToolOutput( + path=file_path, + status="deleted", + ) mock_sandbox.commands.run.assert_called_once() assert ( @@ -260,10 +262,10 @@ def test_success(self, k8s_file_tool, mock_sandbox): result = k8s_file_tool.run(action="mkdir", path="parent/subfolder", timeout=120) - assert result == { - "path": "parent/subfolder", - "status": "created", - } + assert result == K8sAgentSandboxFileToolOutput( + path="parent/subfolder", + status="created", + ) mock_sandbox.commands.run.assert_called_once_with( f"mkdir -p {shlex.quote('parent/subfolder')}", @@ -277,7 +279,7 @@ def test_success(self, k8s_file_tool, mock_sandbox): result = k8s_file_tool.run(action="exists", path="parent/file.txt", timeout=120) - assert result == {"exists": True, "path": "parent/file.txt"} + assert result == K8sAgentSandboxFileToolOutput(exists=True, path="parent/file.txt") mock_sandbox.files.exists.assert_called_once_with( "parent/file.txt", timeout=120 From 1d33af4c9339abb44b048e240109f137beed4b48 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 13 Jul 2026 13:17:15 +0200 Subject: [PATCH 23/45] Add note about syncronization lock in readme --- .../src/crewai_tools/tools/k8s_agent_sandbox/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index 450b6d4048..38a42b614d 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -63,6 +63,8 @@ Ephemeral mode is the safe default: nothing leaks if the agent forgets to clean K8s agent sandboxes also auto-expire after an idle timeout. Tune it via `sandbox_timeout` (seconds, default `300`). +All tool operations to a sandbox are syncronized with a lock to prevent races between different tools. + ### Sandbox client settings By default the `K8sAgentSandboxToolset.create` creates a client in the "Local Tunnel" configuration. To specify another configurations, From 5f3a30a45abbc092f59001eddee30e140abca866 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 10:58:48 +0200 Subject: [PATCH 24/45] remove info action literal --- .../src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index f85f5be383..db43a006b8 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) FileAction = Literal[ - "read", "write", "append", "list", "delete", "mkdir", "info", "exists" + "read", "write", "append", "list", "delete", "mkdir", "exists" ] From 8c7e9feab325fd029c957e988482fd92dadee0e4 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 11:27:35 +0200 Subject: [PATCH 25/45] add additional checks for path --- .../src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index db43a006b8..3689231a50 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -235,6 +235,9 @@ def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> K8sAgentSandb path = posixpath.normpath(path) + if path in {".", "..", ""}: + raise RuntimeError(f"Invalid path {path}.") + if posixpath.dirname(path) == "/": raise RuntimeError(f"The path {path} cannot be deleted.") From 163d070589ee48462b60b8ccd060a16450fa27d7 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 15:27:48 +0200 Subject: [PATCH 26/45] add lazy import --- .../tools/k8s_agent_sandbox/exec_tool.py | 10 +++--- .../tools/k8s_agent_sandbox/file_tool.py | 31 +++++++++------- .../k8s_agent_sandbox/lifecycle_manager.py | 27 ++++++++------ .../tools/k8s_agent_sandbox/python_tool.py | 7 ++-- .../tools/k8s_agent_sandbox/settings.py | 28 +++++++++------ .../tools/k8s_agent_sandbox/utils.py | 36 +++++++++++++++++++ 6 files changed, 99 insertions(+), 40 deletions(-) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index 05664fe16c..f79fe2cfcb 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -1,7 +1,9 @@ -from typing import Any +from typing import TYPE_CHECKING from pydantic import BaseModel, Field -from k8s_agent_sandbox.sandbox import Sandbox + +if TYPE_CHECKING: + from k8s_agent_sandbox.sandbox import Sandbox from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( K8sAgentSandboxBaseTool, @@ -30,12 +32,12 @@ class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = k8sAgentSandboxExecToolSchema - def _run_with_sandbox(self, sandbox: Sandbox, *args, **kwargs) -> K8sAgentSandboxExecToolOutput: + def _run_with_sandbox(self, sandbox: "Sandbox", *args, **kwargs) -> K8sAgentSandboxExecToolOutput: return self._run_command(sandbox, *args, **kwargs) def _run_command( self, - sandbox: Sandbox, + sandbox: "Sandbox", command: str, timeout: int, ) -> K8sAgentSandboxExecToolOutput: diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 3689231a50..cb635e6850 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -1,7 +1,10 @@ -from typing import Callable, Literal +from typing import ( + Callable, + Literal, + TYPE_CHECKING, +) import base64 import shlex -from typing import Any import posixpath import logging from textwrap import dedent @@ -11,8 +14,10 @@ BaseModel, model_validator, ) -from k8s_agent_sandbox.models import FileEntry -from k8s_agent_sandbox.sandbox import Sandbox + +if TYPE_CHECKING: + from k8s_agent_sandbox.models import FileEntry + from k8s_agent_sandbox.sandbox import Sandbox from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( DEFAULT_TOOL_TIMEOUT_SEC, @@ -120,7 +125,7 @@ class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): def _run_with_sandbox( self, - sandbox: Sandbox, + sandbox: "Sandbox", action: FileAction, path: str, timeout: int, @@ -161,7 +166,7 @@ def _run_with_sandbox( def _read( self, - sandbox: Sandbox, + sandbox: "Sandbox", path: str, *, binary: bool, @@ -187,7 +192,7 @@ def _read( def _write( self, - sandbox: Sandbox, + sandbox: "Sandbox", path: str, content: str, *, @@ -202,7 +207,7 @@ def _write( def _append( self, - sandbox: Sandbox, + sandbox: "Sandbox", path: str, content: str, *, @@ -222,14 +227,14 @@ def _append( total_bytes=len(payload), ) - def _list(self, sandbox: Sandbox, path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: + def _list(self, sandbox: "Sandbox", path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: entries = sandbox.files.list(path, timeout=timeout) return K8sAgentSandboxFileToolOutput( path=path, entries=[self._entry_to_dict(e) for e in entries], ) - def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: + def _delete(self, sandbox: "Sandbox", path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: # TODO: Fall back to deleting with shell command. # Use normal file delete API when it is available in SDK. @@ -256,7 +261,7 @@ def _delete(self, sandbox: Sandbox, path: str, *, timeout: int) -> K8sAgentSandb return K8sAgentSandboxFileToolOutput(status="deleted", path=path) - def _mkdir(self, sandbox: Sandbox, path: str, *, timeout: int): + def _mkdir(self, sandbox: "Sandbox", path: str, *, timeout: int): try: result = sandbox.commands.run( f"mkdir -p {shlex.quote(path)}", @@ -272,7 +277,7 @@ def _mkdir(self, sandbox: Sandbox, path: str, *, timeout: int): f"Cannot create directory {path}. Error: {result.stderr}." ) - def _ensure_parent_dir(self, sandbox: Sandbox, path: str, timeout: int): + def _ensure_parent_dir(self, sandbox: "Sandbox", path: str, timeout: int): parent = posixpath.dirname(path) if not parent or parent in ("/", "."): return @@ -280,7 +285,7 @@ def _ensure_parent_dir(self, sandbox: Sandbox, path: str, timeout: int): return self._mkdir(sandbox, parent, timeout=timeout) @staticmethod - def _entry_to_dict(entry: FileEntry) -> FileEntryModel: + def _entry_to_dict(entry: "FileEntry") -> FileEntryModel: return FileEntryModel( name=getattr(entry, "name", None), type=getattr(entry, "type", None), diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index 8c2e387c09..0b82199859 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -1,9 +1,11 @@ +from typing import TYPE_CHECKING from threading import Lock from abc import ABC, abstractmethod import logging -from k8s_agent_sandbox.exceptions import SandboxNotFoundError -from k8s_agent_sandbox.sandbox import Sandbox +if TYPE_CHECKING: + from k8s_agent_sandbox.exceptions import SandboxNotFoundError + from k8s_agent_sandbox.sandbox import Sandbox from .settings import ( @@ -11,6 +13,7 @@ K8sAgentSandboxToolSandboxSettings, ) +from .utils import lazy_import_k8s_agent_sandbox logger = logging.getLogger(__name__) @@ -33,10 +36,10 @@ def __init__( self._lock = Lock() self._closed = False - self._sandbox: Sandbox | None = None + self._sandbox: 'Sandbox | None' = None self._sandbox_acquired: bool = False - def acquire_sandbox(self) -> Sandbox: + def acquire_sandbox(self) -> "Sandbox": """ Acquires a sandbox based on this implementation and returns it. In order to be acquired again by someone else, it has to be @@ -70,7 +73,7 @@ def close(self) -> None: self._closed = True @abstractmethod - def _acquire_sandbox(self) -> Sandbox: + def _acquire_sandbox(self) -> "Sandbox": pass @abstractmethod @@ -88,7 +91,7 @@ def _terminate_sandbox(self) -> None: self._sandbox.terminate() self._sandbox = None - def _create_sandbox(self) -> Sandbox: + def _create_sandbox(self) -> "Sandbox": return self._client.create_sandbox( warmpool=self._sandbox_settings.warmpool, namespace=self._sandbox_settings.namespace, @@ -102,7 +105,7 @@ class EphemeralModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleManag method and terminates it on `release_sandbox`. """ - def _acquire_sandbox(self) -> Sandbox: + def _acquire_sandbox(self) -> "Sandbox": self._sandbox = self._create_sandbox() return self._sandbox @@ -128,19 +131,21 @@ def __init__( super().__init__(client_settings, sandbox_settings) self._claim_name = claim_name - def _acquire_sandbox(self) -> Sandbox: + def _acquire_sandbox(self) -> "Sandbox": + kas_exceptions_module = lazy_import_k8s_agent_sandbox("exceptions") try: self._sandbox = self._client.get_sandbox( self._claim_name, namespace=self._sandbox_settings.namespace, ) - except SandboxNotFoundError: + + except kas_exceptions_module.SandboxNotFoundError: self._sandbox = None if self._sandbox is not None: return self._sandbox - raise SandboxNotFoundError( + raise kas_exceptions_module.SandboxNotFoundError( f"A sandbox with sandbox claim '{self._claim_name}' " "is expected to exist, but cannot be found." ) @@ -158,7 +163,7 @@ class PersistentModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleMana of the`acquire_sandbox` and `release_sandbox`. """ - def _acquire_sandbox(self) -> Sandbox: + def _acquire_sandbox(self) -> "Sandbox": if self._sandbox is not None: return self._sandbox diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index 04e9fbd9d2..c9778978d6 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -1,9 +1,12 @@ +from typing import TYPE_CHECKING import time from pydantic import ( BaseModel, Field, ) -from k8s_agent_sandbox.sandbox import Sandbox + +if TYPE_CHECKING: + from k8s_agent_sandbox.sandbox import Sandbox from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( K8sAgentSandboxBaseTool, @@ -35,7 +38,7 @@ class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): args_schema: type[BaseModel] = k8sAgentSandboxPythonToolSchema def _run_with_sandbox( - self, sandbox: Sandbox, code: str, timeout: int + self, sandbox: "Sandbox", code: str, timeout: int ) -> K8sAgentSandboxPythonToolOutput: timeout_tracker = create_timeout_tracker(timeout) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py index 69c32d8469..132924477a 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py @@ -1,13 +1,17 @@ +from typing import TYPE_CHECKING from dataclasses import ( dataclass, field, ) -from k8s_agent_sandbox.models import ( - SandboxConnectionConfig, - SandboxTracerConfig, -) -from k8s_agent_sandbox.sandbox_client import SandboxClient +if TYPE_CHECKING: + from k8s_agent_sandbox.models import ( + SandboxConnectionConfig, + SandboxTracerConfig, + ) + from k8s_agent_sandbox.sandbox_client import SandboxClient + +from .utils import lazy_import_k8s_agent_sandbox @dataclass @@ -18,22 +22,26 @@ class K8sAgentSandboxToolClientSettings: class. """ - connection_config: SandboxConnectionConfig | None = None - tracer_config: SandboxTracerConfig | None = None + connection_config: 'SandboxConnectionConfig | None' = None + tracer_config: 'SandboxTracerConfig | None' = None cleanup: bool = False - _client: SandboxClient | None = field(default=None, init=False, repr=False) + _client: 'SandboxClient | None' = field(default=None, init=False, repr=False) @property - def client(self) -> SandboxClient: + def client(self) -> 'SandboxClient': if self._client is not None: return self._client - self._client = SandboxClient( + # from k8s_agent_sandbox.sandbox_client import SandboxClient + kas_client_sandbox_module = lazy_import_k8s_agent_sandbox("sandbox_client") + + client: 'SandboxClient' = kas_client_sandbox_module.SandboxClient( connection_config=self.connection_config, tracer_config=self.tracer_config, cleanup=self.cleanup, ) + self._client = client return self._client diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py new file mode 100644 index 0000000000..027b77faa1 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py @@ -0,0 +1,36 @@ +import importlib +from typing import Any + +# Global dictionary acting as the cache collection +_MODULE_CACHE: dict[str, Any] = {} + +def lazy_import_k8s_agent_sandbox(target: str): + """ + Lazily imports a module or attribute by string name, caches it globally, + and returns it. Subsequent calls return the object directly from the cache. + + :param target: Module name (e.g., 'json') or object path (e.g., 'math.sqrt') + :return: The imported module or attribute object + """ + + full_target = f"k8s_agent_sandbox.{target}" + + module = _MODULE_CACHE.get(full_target) + if module is not None: + return module + + try: + obj = importlib.import_module(full_target) + except ModuleNotFoundError as e: + raise ImportError( + "The 'k8s-agent-sandbox' package is required for K8s Agent Sandbox tools. " + ) from e + + # 3. Store in global cache collection + _MODULE_CACHE[full_target] = obj + + # 4. Inject into the module's global space (using short name) + short_name = full_target.split(".")[-1] + globals()[short_name] = obj + + return obj From b2faeed72b05d0a97725484b7706f3d6e07697b6 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 16:10:19 +0200 Subject: [PATCH 27/45] make apend use tmp file --- .../tools/k8s_agent_sandbox/file_tool.py | 8 +++---- .../tools/k8s_agent_sandbox/test_file_tool.py | 22 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index cb635e6850..1c83e5d9b0 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -3,6 +3,7 @@ Literal, TYPE_CHECKING, ) +import time import base64 import shlex import posixpath @@ -217,14 +218,13 @@ def _append( chunk: bytes = base64.b64decode(content) if binary else content.encode("utf-8") self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) - existing = sandbox.files.read(path, timeout=timeout_tracker()) - payload = existing + chunk - sandbox.files.write(path, payload, timeout=timeout_tracker()) + tmp_file_path = f"/tmp/crewai-file-append{int(time.time())}.py" + sandbox.files.write(tmp_file_path, chunk, timeout=timeout_tracker()) + sandbox.commands.run(f"cat {tmp_file_path} >> path && rm {tmp_file_path}", timeout=timeout_tracker()) return K8sAgentSandboxFileToolOutput( status="appended", path=path, appended_bytes=len(chunk), - total_bytes=len(payload), ) def _list(self, sandbox: "Sandbox", path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py index b15b5af24d..7992eefd43 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py @@ -138,9 +138,7 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): stderr="", ) - mock_sandbox.files.read.return_value = b"Hello" - - content_to_append = " World" + content_to_append = "some content" if binary: content = base64.b64encode(content_to_append.encode("ascii")) @@ -158,21 +156,21 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): assert result == K8sAgentSandboxFileToolOutput( path="parent/file.txt", status="appended", - appended_bytes=6, - total_bytes=11, + appended_bytes=12, ) - mock_sandbox.files.read.assert_called_once() - assert mock_sandbox.files.read.call_args.args == ("parent/file.txt",) - assert mock_sandbox.files.read.call_args.kwargs["timeout"] == 120 + + assert "mkdir" in mock_sandbox.commands.run.call_args_list[0].args[0] mock_sandbox.files.write.assert_called_once() - assert mock_sandbox.files.write.call_args.args == ( - "parent/file.txt", - b"Hello World", - ) + assert mock_sandbox.files.write.call_args.args[1] == b"some content" + tmp_file_path = mock_sandbox.files.write.call_args.args[0] + assert tmp_file_path.startswith("/tmp") assert 0 <= mock_sandbox.files.write.call_args.kwargs["timeout"] <= 120 + assert "cat /tmp" in mock_sandbox.commands.run.call_args_list[1].args[0] + + class TestFileToolListAction: def test_success(self, k8s_file_tool, mock_sandbox): From 6f7f8a7832bd19829c532c154db5f7c1ce9eca7e Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 16:20:25 +0200 Subject: [PATCH 28/45] fix tools readme and descriptions --- .../src/crewai_tools/tools/k8s_agent_sandbox/README.md | 2 +- .../src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py | 2 +- .../src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index 38a42b614d..f9ced58263 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -166,7 +166,7 @@ file_tool = K8sAgentSandboxFileTool(toolset=toolset) ### `K8sAgentSandboxFileTool` - `action: "read" | "write" | "append" | "list" | "delete" | "mkdir" | "info" | "exists"` -- `path: str` — absolute path inside the sandbox. +- `path: str` — relative path inside the sandbox. - `content: str | None` — required for `append`; optional for `write`. - `binary: bool` — if `True`, `content` is base64 on write / returned as base64 on read. diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 1c83e5d9b0..7416be81d3 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -72,7 +72,7 @@ class K8sAgentSandboxFileToolSchema(BaseModel): ), ) - timeout: int | None = Field( + timeout: int = Field( default=DEFAULT_TOOL_TIMEOUT_SEC, description="Maximum seconds to wait for the action to finish.", ) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index c9778978d6..02b75ac4ec 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -16,10 +16,10 @@ class k8sAgentSandboxPythonToolSchema(BaseModel): - code: str = Field(..., description="Shell command to execute in the sandbox.") + code: str = Field(..., description="Python code to execute in the sandbox.") timeout: int = Field( default=DEFAULT_TOOL_TIMEOUT_SEC, - description="Maximum seconds to wait for the command to finish.", + description="Maximum seconds to wait for the code execution to finish.", ) From db14cdca2f2be1f70cc0143ee329ccfc735ea196 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 16:23:58 +0200 Subject: [PATCH 29/45] use uuid instead of timestapm for tmp files --- .../src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py | 4 ++-- .../src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 7416be81d3..0db4f802c8 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -3,12 +3,12 @@ Literal, TYPE_CHECKING, ) -import time import base64 import shlex import posixpath import logging from textwrap import dedent +import uuid from pydantic import ( Field, @@ -218,7 +218,7 @@ def _append( chunk: bytes = base64.b64decode(content) if binary else content.encode("utf-8") self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) - tmp_file_path = f"/tmp/crewai-file-append{int(time.time())}.py" + tmp_file_path = f"/tmp/crewai-file-append{uuid.uuid4()}.py" sandbox.files.write(tmp_file_path, chunk, timeout=timeout_tracker()) sandbox.commands.run(f"cat {tmp_file_path} >> path && rm {tmp_file_path}", timeout=timeout_tracker()) return K8sAgentSandboxFileToolOutput( diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index 02b75ac4ec..39a646b2ad 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -1,5 +1,5 @@ from typing import TYPE_CHECKING -import time +import uuid from pydantic import ( BaseModel, Field, @@ -43,7 +43,7 @@ def _run_with_sandbox( timeout_tracker = create_timeout_tracker(timeout) - tmp_file_path = f"/tmp/crewai-{int(time.time())}.py" + tmp_file_path = f"/tmp/crewai-{uuid.uuid4()}.py" sandbox.files.write(tmp_file_path, code.encode("utf-8"), timeout=timeout_tracker()) From 89fcd5e3c0271ef7ed65f836e2276a20803e8d1c Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 16:27:19 +0200 Subject: [PATCH 30/45] Fix schema class name case --- .../src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index f79fe2cfcb..b7b910500e 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -11,7 +11,7 @@ ) -class k8sAgentSandboxExecToolSchema(BaseModel): +class K8sAgentSandboxExecToolSchema(BaseModel): command: str = Field(..., description="Shell command to execute in the sandbox.") timeout: int = Field( default=DEFAULT_TOOL_TIMEOUT_SEC, @@ -30,7 +30,7 @@ class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): description: str = ( "Executes shell commands inside an isolated Kubernetes pod sandbox." ) - args_schema: type[BaseModel] = k8sAgentSandboxExecToolSchema + args_schema: type[BaseModel] = K8sAgentSandboxExecToolSchema def _run_with_sandbox(self, sandbox: "Sandbox", *args, **kwargs) -> K8sAgentSandboxExecToolOutput: return self._run_command(sandbox, *args, **kwargs) From 858dd1f169e9c9acefaf66ef915b9f28e34402e2 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 16:28:03 +0200 Subject: [PATCH 31/45] Set sadnbox acquired to False on release --- .../crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index 0b82199859..bc4e9accf8 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -62,6 +62,7 @@ def release_sandbox(self) -> None: try: self._release_sandbox() finally: + self._sandbox_acquired = False self._lock.release() def close(self) -> None: From dcd816ed2d45782e6275c2e34c1c2ae503b3d249 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 16:42:27 +0200 Subject: [PATCH 32/45] fix timeout tracker --- .../src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py index 43271940e2..6650d06659 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -51,8 +51,8 @@ def create_timeout_tracker(timeout: int) -> Callable[[], int]: def get_remaining(): remaining_time = timeout - (int(time.time()) - start_time) - if remaining_time < 0: + if remaining_time <= 0: raise TimeoutError("Timeout. Cannot continue the tool action.") - return remaining_time if remaining_time >= 0 else 0 + return remaining_time return get_remaining From 5b747abfd8bb80c94edcdd6127a4dc2e669ec037 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 20 Jul 2026 17:03:04 +0200 Subject: [PATCH 33/45] fix README --- .../src/crewai_tools/tools/k8s_agent_sandbox/README.md | 8 ++++---- .../src/crewai_tools/tools/k8s_agent_sandbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index f9ced58263..72def95a8c 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -6,7 +6,7 @@ Three tools are provided so you can pick what the agent actually needs: - **`K8sAgentSandboxExecTool`** — run a shell command. - **`K8sAgentSandboxFileTool`** — read / write / list / delete files. -- **`K8sAgentSandboxPythonTool`** — run a Python code. +- **`K8sAgentSandboxPythonTool`** — run Python code. ## Installation @@ -20,7 +20,7 @@ pip install "crewai-tools[k8s_agent_sandbox]" Instead of configuring sandbox-related settings for each of the tools separately, the `K8sAgentSandboxToolset` class must be used. -All tools that are meant to share the same sandbox settings and sandbox lifecycle has to be added to the same instance of the +All tools that are meant to share the same sandbox settings and sandbox lifecycle have to be added to the same instance of the `K8sAgentSandboxToolset` class. ### Toolset example @@ -61,9 +61,9 @@ with the `K8sAgentSandboxToolset.create` factory method: Ephemeral mode is the safe default: nothing leaks if the agent forgets to clean up, but since it has to create a new sandbox on each new run, the increased execution time is expected. Use persistent mode when you want filesystem state or installed packages to carry across steps. -K8s agent sandboxes also auto-expire after an idle timeout. Tune it via `sandbox_timeout` (seconds, default `300`). +K8s agent sandboxes also auto-expire via an absolute TTL from creation. Tune it via `sandbox_timeout` (seconds, default `300`). -All tool operations to a sandbox are syncronized with a lock to prevent races between different tools. +All tool operations to a sandbox are synchronized with a lock to prevent races between different tools. ### Sandbox client settings diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py index 132924477a..219bd0baba 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py @@ -53,7 +53,7 @@ class K8sAgentSandboxToolSandboxSettings: Args: warmpool: Name of the warm pool where a sandbox should be created. namespace: Name of the namespace where a sandbox should be created. - sandbox_timeout: The timeout in seconds after which the sandbox will be automatically killed. + sandbox_timeout: The absolute TTL in seconds from creation after which the sandbox will be automatically killed. """ warmpool: str From b47eb97507f3987505ed3c980168cf31409e961b Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Thu, 23 Jul 2026 12:15:36 +0200 Subject: [PATCH 34/45] fix file append action --- .../tools/k8s_agent_sandbox/file_tool.py | 9 ++++++-- .../tools/k8s_agent_sandbox/test_file_tool.py | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 0db4f802c8..83ae148593 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -219,8 +219,13 @@ def _append( self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) tmp_file_path = f"/tmp/crewai-file-append{uuid.uuid4()}.py" - sandbox.files.write(tmp_file_path, chunk, timeout=timeout_tracker()) - sandbox.commands.run(f"cat {tmp_file_path} >> path && rm {tmp_file_path}", timeout=timeout_tracker()) + sandbox.files.write(tmp_file_path, chunk, timeout=timeout_tracker(), allow_unsafe_paths=True) + q = shlex.quote(path) + qt = shlex.quote(tmp_file_path) + result = sandbox.commands.run(f"cat {qt} >> {q}; rc=$?; rm -f {qt}; exit $rc", timeout=timeout_tracker()) + if result.exit_code != 0: + raise RuntimeError(f"Cannot append to {path}. Error: {result.stderr}.") + return K8sAgentSandboxFileToolOutput( status="appended", path=path, diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py index 7992eefd43..aa4a7ee1f1 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py @@ -170,6 +170,28 @@ def test_success(self, k8s_file_tool, mock_sandbox, binary): assert "cat /tmp" in mock_sandbox.commands.run.call_args_list[1].args[0] + @pytest.mark.parametrize("binary", [False, True]) + def test_error(self, k8s_file_tool, mock_sandbox, binary): + mock_sandbox.commands.run.side_effect = [ + ExecutionResult(exit_code=0, stdout="", stderr=""), + ExecutionResult(exit_code=1, stdout="", stderr="append error"), + ] + + content_to_append = "some content" + + if binary: + content = base64.b64encode(content_to_append.encode("ascii")) + else: + content = content_to_append + + with pytest.raises(RuntimeError, match="append error"): + k8s_file_tool.run( + action="append", + path="parent/file.txt", + content=content, + binary=binary, + timeout=120, + ) class TestFileToolListAction: From 3786406096832b8e5ba56e0b7e2cb304fb011b13 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Thu, 23 Jul 2026 15:42:19 +0200 Subject: [PATCH 35/45] add timeout on close --- .../k8s_agent_sandbox/lifecycle_manager.py | 22 ++++- .../tools/k8s_agent_sandbox/toolset.py | 11 +++ .../test_lifecycle_manager.py | 95 +++++++++++++------ 3 files changed, 95 insertions(+), 33 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index bc4e9accf8..14e05a3579 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -27,6 +27,7 @@ def __init__( self, client_settings: K8sAgentSandboxToolClientSettings, sandbox_settings: K8sAgentSandboxToolSandboxSettings, + close_timeout: int = 60, ): self._sandbox_settings = sandbox_settings self._client_settings = client_settings or K8sAgentSandboxToolClientSettings() @@ -39,6 +40,8 @@ def __init__( self._sandbox: 'Sandbox | None' = None self._sandbox_acquired: bool = False + self._close_timeout = close_timeout + def acquire_sandbox(self) -> "Sandbox": """ Acquires a sandbox based on this implementation and returns it. @@ -70,8 +73,16 @@ def close(self) -> None: if self._closed: return - self._close() - self._closed = True + acquired = self._lock.acquire(timeout=self._close_timeout) + if not acquired: + logger.warning("Failed to acquire lock on close before the timeout. Closing anyway.") + + try: + self._close() + self._closed = True + finally: + if acquired: + self._lock.release() @abstractmethod def _acquire_sandbox(self) -> "Sandbox": @@ -128,8 +139,13 @@ def __init__( client_settings: K8sAgentSandboxToolClientSettings, sandbox_settings: K8sAgentSandboxToolSandboxSettings, claim_name: str, + close_timeout: int = 60, ): - super().__init__(client_settings, sandbox_settings) + super().__init__( + client_settings, + sandbox_settings, + close_timeout=close_timeout, + ) self._claim_name = claim_name def _acquire_sandbox(self) -> "Sandbox": diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py index bff02ef59c..38f683d93e 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py @@ -66,6 +66,7 @@ def create( persistent: bool = False, claim_name: str | None = None, cleanup_on_exit: bool = True, + close_timeout: int = 60, ) -> Self: """ Create a toolset by using Agent Sandbox client and sandbox settings. @@ -82,6 +83,8 @@ def create( and this sandbox can be killed by closing the toolset. Mutually exclusive with the `claim_name` argument. cleanup_on_exit: Same as in the constructor. + close_timeout: Time in seconds to wait for all ongoing sandbox operations before closing + the toolset. """ if persistent and claim_name is not None: @@ -89,23 +92,31 @@ def create( client_settings = client_settings or K8sAgentSandboxToolClientSettings() + kwargs = { + "close_timeout": close_timeout + } + if claim_name is not None: lifecycle_manager = AttachModeK8sAgentSandboxLifecycleManager( client_settings, sandbox_settings, claim_name, + **kwargs, + ) elif persistent: lifecycle_manager = PersistentModeK8sAgentSandboxLifecycleManager( client_settings, sandbox_settings, + **kwargs, ) else: lifecycle_manager = EphemeralModeK8sAgentSandboxLifecycleManager( client_settings, sandbox_settings, + **kwargs, ) return cls( diff --git a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py index 8a01800bf1..fd73567e9b 100644 --- a/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py @@ -1,22 +1,61 @@ +from typing import cast +from unittest.mock import MagicMock +from abc import ABC, abstractmethod + import pytest from k8s_agent_sandbox.exceptions import SandboxNotFoundError from crewai_tools.tools.k8s_agent_sandbox.lifecycle_manager import ( + K8sAgentSandboxLifecycleManager, EphemeralModeK8sAgentSandboxLifecycleManager, AttachModeK8sAgentSandboxLifecycleManager, PersistentModeK8sAgentSandboxLifecycleManager, ) -class TestEphemeralModeManager: - @pytest.fixture - def manager(self, mock_client_settings, sample_sandbox_settings): +class LifecycleModeManagetTestBase(ABC): + @abstractmethod + def _create_manager( + self, + mock_client_settings, + sample_sandbox_settings, + **kwargs, + ) -> K8sAgentSandboxLifecycleManager: + pass + + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") + def test_close_acquired(self, mock_client_settings, sample_sandbox_settings, caplog): + manager = self._create_manager( + mock_client_settings, + sample_sandbox_settings, + # This has to make the manager close without releaseing a sandbox, otherwise the test case will hang + close_timeout=0, + ) + + manager.acquire_sandbox() + manager.close() + + assert "Closing anyway" in caplog.text + + +class TestEphemeralModeManager(LifecycleModeManagetTestBase): + def _create_manager( + self, + mock_client_settings, + sample_sandbox_settings, + **kwargs, + ): return EphemeralModeK8sAgentSandboxLifecycleManager( mock_client_settings, sample_sandbox_settings, + **kwargs, ) + @pytest.fixture + def manager(self, mock_client_settings, sample_sandbox_settings): + return self._create_manager(mock_client_settings, sample_sandbox_settings) + def test_main(self, manager, mock_sandbox_client): sandbox = manager.acquire_sandbox() @@ -45,23 +84,25 @@ def test_close_non_acquired(self, manager, mock_sandbox): manager.close() assert mock_sandbox.terminate.call_count == 0 - @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") - def test_close_acquired(self, manager): - sandbox = manager.acquire_sandbox() - assert sandbox.terminate.call_count == 0 - manager.close() - assert sandbox.terminate.call_count == 1 - -class TestAttachModeManager: - @pytest.fixture - def manager(self, mock_client_settings, sample_sandbox_settings): +class TestAttachModeManager(LifecycleModeManagetTestBase): + def _create_manager( + self, + mock_client_settings, + sample_sandbox_settings, + **kwargs, + ): return AttachModeK8sAgentSandboxLifecycleManager( mock_client_settings, sample_sandbox_settings, "my-claim", + **kwargs, ) + @pytest.fixture + def manager(self, mock_client_settings, sample_sandbox_settings): + return self._create_manager(mock_client_settings, sample_sandbox_settings) + @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") def test_acquire_and_release( self, manager, mock_sandbox_client, sample_sandbox_settings @@ -100,21 +141,22 @@ def test_close_non_acquired(self, manager, mock_sandbox): manager.close() assert mock_sandbox.terminate.call_count == 0 - @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") - def test_close_acquired(self, manager): - sandbox = manager.acquire_sandbox() - assert sandbox.terminate.call_count == 0 - manager.close() - assert sandbox.terminate.call_count == 0 - -class TestPersistentModeManager: - @pytest.fixture - def manager(self, mock_client_settings, sample_sandbox_settings): +class TestPersistentModeManager(LifecycleModeManagetTestBase): + def _create_manager( + self, + mock_client_settings, + sample_sandbox_settings, + **kwargs, + ): return PersistentModeK8sAgentSandboxLifecycleManager( mock_client_settings, sample_sandbox_settings, + **kwargs, ) + @pytest.fixture + def manager(self, mock_client_settings, sample_sandbox_settings): + return self._create_manager(mock_client_settings, sample_sandbox_settings) def test_acquire_and_release(self, manager, mock_sandbox_client): sandbox = manager.acquire_sandbox() @@ -141,10 +183,3 @@ def test_close_non_acquired(self, manager, mock_sandbox): assert mock_sandbox.terminate.call_count == 0 manager.close() assert mock_sandbox.terminate.call_count == 0 - - @pytest.mark.usefixtures("mock_client_returns_mock_sandbox_in_create_sandbox") - def test_close_acquired(self, manager): - sandbox = manager.acquire_sandbox() - assert sandbox.terminate.call_count == 0 - manager.close() - assert sandbox.terminate.call_count == 1 From 546587b597d7c1fb8efc929f996fb07cff54ff50 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Thu, 23 Jul 2026 15:42:52 +0200 Subject: [PATCH 36/45] remove commented code from README --- .../src/crewai_tools/tools/k8s_agent_sandbox/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md index 72def95a8c..204034502e 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -236,7 +236,6 @@ inspect_task = Task( "and summarize the results." ), expected_output="A brief summary of the sandbox pod's operating system based on the command output.", - # expected_output="Only name of the Operating System.", agent=devops_agent, ) From 55610394e19d2a51e859982ac1d891e3fd590db6 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Thu, 23 Jul 2026 15:46:28 +0200 Subject: [PATCH 37/45] remove tmp file for python tool --- .../tools/k8s_agent_sandbox/python_tool.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index 39a646b2ad..01352fdaba 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -45,9 +45,17 @@ def _run_with_sandbox( tmp_file_path = f"/tmp/crewai-{uuid.uuid4()}.py" - sandbox.files.write(tmp_file_path, code.encode("utf-8"), timeout=timeout_tracker()) + sandbox.files.write( + tmp_file_path, + code.encode("utf-8"), + allow_unsafe_paths=True, + timeout=timeout_tracker(), + ) - result = sandbox.commands.run(f"python3 {tmp_file_path}", timeout=timeout_tracker()) + result = sandbox.commands.run( + f"python3 {tmp_file_path}; rc=$?; rm -f {tmp_file_path}; exit $rc", + timeout=timeout_tracker(), + ) return K8sAgentSandboxPythonToolOutput( exit_code=result.exit_code, From 41ecd4d21fbc4601d8c83fb6aab2f2f243f1b28e Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Thu, 23 Jul 2026 15:48:38 +0200 Subject: [PATCH 38/45] delete metadata info action from description --- .../src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 83ae148593..cf570d4bd9 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -120,7 +120,7 @@ class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): description: str = ( "Perform filesystem operations inside a K8s agent sandbox: read a file, " "write content to a path, append content to an existing file, list a " - "directory, delete a path, make a directory, fetch file metadata, or " + "directory, delete a path, make a directory or " "check whether a path exists.") args_schema: type[BaseModel] = K8sAgentSandboxFileToolSchema From 972b757bc1d49a982ed2f8eacc98d492a00bcaa6 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Thu, 23 Jul 2026 15:50:20 +0200 Subject: [PATCH 39/45] fix tmp file extension --- .../src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index cf570d4bd9..d3c92eeb12 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -218,7 +218,7 @@ def _append( chunk: bytes = base64.b64decode(content) if binary else content.encode("utf-8") self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) - tmp_file_path = f"/tmp/crewai-file-append{uuid.uuid4()}.py" + tmp_file_path = f"/tmp/crewai-file-append{uuid.uuid4()}.tmp" sandbox.files.write(tmp_file_path, chunk, timeout=timeout_tracker(), allow_unsafe_paths=True) q = shlex.quote(path) qt = shlex.quote(tmp_file_path) From d407003437244750ab9b4dfe50aa3a42ba3cfbb1 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 27 Jul 2026 18:51:20 +0200 Subject: [PATCH 40/45] fix release sandbox --- .../tools/k8s_agent_sandbox/lifecycle_manager.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index 14e05a3579..f53526f86e 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -58,12 +58,11 @@ def release_sandbox(self) -> None: """ Releases a sandbox that is previously acquired. """ - if self._closed: - return if not self._sandbox_acquired: return try: - self._release_sandbox() + if not self._closed: + self._release_sandbox() finally: self._sandbox_acquired = False self._lock.release() From da3c91d122c63d40160ddbdcf9acca4b837b62cc Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Mon, 27 Jul 2026 18:54:19 +0200 Subject: [PATCH 41/45] fix acquire sandbox --- .../crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index f53526f86e..2c5ba790f8 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -48,9 +48,10 @@ def acquire_sandbox(self) -> "Sandbox": In order to be acquired again by someone else, it has to be released first by the :meth:`release_sandbox` method. """ + self._lock.acquire() if self._closed: + self._lock.release() raise RuntimeError("Attempt to acquire a sandbox from a closed helper.") - self._lock.acquire() self._sandbox_acquired = True return self._acquire_sandbox() From 406c9f45f1c75b8325a373bf1341e1d2529233c9 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Tue, 28 Jul 2026 22:43:55 +0200 Subject: [PATCH 42/45] update uv.lock --- uv.lock | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 491e5e797f..1c6c43e014 100644 --- a/uv.lock +++ b/uv.lock @@ -1664,6 +1664,9 @@ github = [ hyperbrowser = [ { name = "hyperbrowser" }, ] +k8s-agent-sandbox = [ + { name = "k8s-agent-sandbox" }, +] linkup-sdk = [ { name = "linkup-sdk" }, ] @@ -1758,6 +1761,7 @@ requires-dist = [ { name = "firecrawl-py", marker = "extra == 'firecrawl-py'", specifier = ">=1.8.0" }, { name = "gitpython", marker = "extra == 'github'", specifier = ">=3.1.55,<4" }, { name = "hyperbrowser", marker = "extra == 'hyperbrowser'", specifier = ">=0.18.0" }, + { name = "k8s-agent-sandbox", marker = "extra == 'k8s-agent-sandbox'", specifier = "~=0.5.0" }, { name = "langchain-apify", marker = "extra == 'apify'", specifier = ">=0.1.2,<1.0.0" }, { name = "linkup-sdk", marker = "extra == 'linkup-sdk'", specifier = ">=0.2.2" }, { name = "lxml", marker = "extra == 'rag'", specifier = ">=6.1.0,<7" }, @@ -1796,7 +1800,7 @@ requires-dist = [ { name = "weaviate-client", marker = "extra == 'weaviate-client'", specifier = ">=4.10.2" }, { name = "youtube-transcript-api", specifier = "~=1.2.2" }, ] -provides-extras = ["apify", "beautifulsoup4", "bedrock", "browserbase", "composio-core", "contextual", "couchbase", "databricks-sdk", "daytona", "e2b", "exa-py", "firecrawl-py", "github", "hyperbrowser", "linkup-sdk", "mcp", "mongodb", "multion", "mysql", "oxylabs", "patronus", "postgresql", "qdrant-client", "rag", "scrapegraph-py", "scrapfly-sdk", "selenium", "serpapi", "singlestore", "snowflake", "spider-client", "sqlalchemy", "stagehand", "tavily-python", "weaviate-client", "xml"] +provides-extras = ["apify", "beautifulsoup4", "bedrock", "browserbase", "composio-core", "contextual", "couchbase", "databricks-sdk", "daytona", "e2b", "exa-py", "firecrawl-py", "github", "hyperbrowser", "k8s-agent-sandbox", "linkup-sdk", "mcp", "mongodb", "multion", "mysql", "oxylabs", "patronus", "postgresql", "qdrant-client", "rag", "scrapegraph-py", "scrapfly-sdk", "selenium", "serpapi", "singlestore", "snowflake", "spider-client", "sqlalchemy", "stagehand", "tavily-python", "weaviate-client", "xml"] [[package]] name = "cryptography" @@ -3720,6 +3724,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "k8s-agent-sandbox" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "kubernetes" }, + { name = "prometheus-client" }, + { name = "pydantic" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/d9/b70dd59e06ddc32883bcc0dbab27d4a7ff269ec8d53f215e54a7b71c18ea/k8s_agent_sandbox-0.5.3.tar.gz", hash = "sha256:f653b6ac2907d34412ede4c888d802e1fb6d8fac74522a7c02d13143d3387c15", size = 136166, upload-time = "2026-07-23T19:14:27.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/d1/6abe6f223acec4167569f2e1418977ce0c57479cd3d7d5fc87997f728f68/k8s_agent_sandbox-0.5.3-py3-none-any.whl", hash = "sha256:c449b634e615778eb022c27ad95777697992f20b759f252a39e5af165699b6ad", size = 75920, upload-time = "2026-07-23T19:14:25.872Z" }, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -6327,6 +6346,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.51" From d787fd1ddd9f811a9fbd7637a56a482e84c355b3 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Tue, 28 Jul 2026 23:14:39 +0200 Subject: [PATCH 43/45] reformat --- .../tools/k8s_agent_sandbox/exec_tool.py | 16 +++- .../tools/k8s_agent_sandbox/file_tool.py | 94 ++++++++++++++----- .../k8s_agent_sandbox/lifecycle_manager.py | 6 +- .../tools/k8s_agent_sandbox/python_tool.py | 13 ++- .../tools/k8s_agent_sandbox/settings.py | 10 +- .../tools/k8s_agent_sandbox/toolset.py | 5 +- .../tools/k8s_agent_sandbox/utils.py | 1 + 7 files changed, 104 insertions(+), 41 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index b7b910500e..d12861709c 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -20,9 +20,15 @@ class K8sAgentSandboxExecToolSchema(BaseModel): class K8sAgentSandboxExecToolOutput(BaseModel): - exit_code: int | None = Field(default=None, description="The exit code of the executed command.") - stdout: str | None = Field(default=None, description="The standard output produced by the command.") - stderr: str | None = Field(default=None, description="The standard error output produced by the command.") + exit_code: int | None = Field( + default=None, description="The exit code of the executed command." + ) + stdout: str | None = Field( + default=None, description="The standard output produced by the command." + ) + stderr: str | None = Field( + default=None, description="The standard error output produced by the command." + ) class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): @@ -32,7 +38,9 @@ class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = K8sAgentSandboxExecToolSchema - def _run_with_sandbox(self, sandbox: "Sandbox", *args, **kwargs) -> K8sAgentSandboxExecToolOutput: + def _run_with_sandbox( + self, sandbox: "Sandbox", *args, **kwargs + ) -> K8sAgentSandboxExecToolOutput: return self._run_command(sandbox, *args, **kwargs) def _run_command( diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index d3c92eeb12..19c4d6a8dc 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -29,9 +29,7 @@ logger = logging.getLogger(__name__) -FileAction = Literal[ - "read", "write", "append", "list", "delete", "mkdir", "exists" -] +FileAction = Literal["read", "write", "append", "list", "delete", "mkdir", "exists"] class K8sAgentSandboxFileToolSchema(BaseModel): @@ -88,23 +86,60 @@ def _validate_action_args(self) -> "K8sAgentSandboxFileToolSchema": class FileEntryModel(BaseModel): - name: str | None = Field(default=None, description="The name of the file or directory.") - type: str | None = Field(default=None, description="The type of the entry, typically 'file' or 'directory'.") + name: str | None = Field( + default=None, description="The name of the file or directory." + ) + type: str | None = Field( + default=None, + description="The type of the entry, typically 'file' or 'directory'.", + ) size: int | None = Field(default=None, description="The size of the file in bytes.") - mod_time: float | None = Field(default=None, description="The last modification time of the entry as a Unix timestamp.") + mod_time: float | None = Field( + default=None, + description="The last modification time of the entry as a Unix timestamp.", + ) class K8sAgentSandboxFileToolOutput(BaseModel): - path: str | None = Field(default=None, description="The target path of the file or directory.") - status: str | None = Field(default=None, description="The status of the file operation, e.g., 'written', 'appended', 'created', 'deleted'.") - exists: bool | None = Field(default=None, description="Indicates whether the file or directory exists. Only returned for the 'exists' action.") - content: str | None = Field(default=None, description="The content of the file. Returned for the 'read' action.") - encoding: str | None = Field(default=None, description="The encoding of the content, e.g., 'utf-8' or 'base64'.") - note: str | None = Field(default=None, description="An optional note providing additional context about the operation (e.g., regarding non-utf8 files).") - bytes: int | None = Field(default=None, description="The number of bytes written. Returned for the 'write' action.") - appended_bytes: int | None = Field(default=None, description="The number of bytes appended to the file. Returned for the 'append' action.") - total_bytes: int | None = Field(default=None, description="The total size of the file in bytes after appending. Returned for the 'append' action.") - entries: list[FileEntryModel] | None = Field(default=None, description="A list of file and directory entries. Returned for the 'list' action.") + path: str | None = Field( + default=None, description="The target path of the file or directory." + ) + status: str | None = Field( + default=None, + description="The status of the file operation, e.g., 'written', 'appended', 'created', 'deleted'.", + ) + exists: bool | None = Field( + default=None, + description="Indicates whether the file or directory exists. Only returned for the 'exists' action.", + ) + content: str | None = Field( + default=None, + description="The content of the file. Returned for the 'read' action.", + ) + encoding: str | None = Field( + default=None, + description="The encoding of the content, e.g., 'utf-8' or 'base64'.", + ) + note: str | None = Field( + default=None, + description="An optional note providing additional context about the operation (e.g., regarding non-utf8 files).", + ) + bytes: int | None = Field( + default=None, + description="The number of bytes written. Returned for the 'write' action.", + ) + appended_bytes: int | None = Field( + default=None, + description="The number of bytes appended to the file. Returned for the 'append' action.", + ) + total_bytes: int | None = Field( + default=None, + description="The total size of the file in bytes after appending. Returned for the 'append' action.", + ) + entries: list[FileEntryModel] | None = Field( + default=None, + description="A list of file and directory entries. Returned for the 'list' action.", + ) class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): @@ -121,7 +156,8 @@ class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): "Perform filesystem operations inside a K8s agent sandbox: read a file, " "write content to a path, append content to an existing file, list a " "directory, delete a path, make a directory or " - "check whether a path exists.") + "check whether a path exists." + ) args_schema: type[BaseModel] = K8sAgentSandboxFileToolSchema def _run_with_sandbox( @@ -182,7 +218,9 @@ def _read( content=base64.b64encode(data).decode("ascii"), ) try: - return K8sAgentSandboxFileToolOutput(path=path, encoding="utf-8", content=data.decode("utf-8")) + return K8sAgentSandboxFileToolOutput( + path=path, encoding="utf-8", content=data.decode("utf-8") + ) except UnicodeDecodeError: return K8sAgentSandboxFileToolOutput( path=path, @@ -204,7 +242,9 @@ def _write( payload = base64.b64decode(content) if binary else content.encode("utf-8") self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) sandbox.files.write(path, payload, timeout=timeout_tracker()) - return K8sAgentSandboxFileToolOutput(status="written", path=path, bytes=len(payload)) + return K8sAgentSandboxFileToolOutput( + status="written", path=path, bytes=len(payload) + ) def _append( self, @@ -219,10 +259,14 @@ def _append( self._ensure_parent_dir(sandbox, path, timeout=timeout_tracker()) tmp_file_path = f"/tmp/crewai-file-append{uuid.uuid4()}.tmp" - sandbox.files.write(tmp_file_path, chunk, timeout=timeout_tracker(), allow_unsafe_paths=True) + sandbox.files.write( + tmp_file_path, chunk, timeout=timeout_tracker(), allow_unsafe_paths=True + ) q = shlex.quote(path) qt = shlex.quote(tmp_file_path) - result = sandbox.commands.run(f"cat {qt} >> {q}; rc=$?; rm -f {qt}; exit $rc", timeout=timeout_tracker()) + result = sandbox.commands.run( + f"cat {qt} >> {q}; rc=$?; rm -f {qt}; exit $rc", timeout=timeout_tracker() + ) if result.exit_code != 0: raise RuntimeError(f"Cannot append to {path}. Error: {result.stderr}.") @@ -232,14 +276,18 @@ def _append( appended_bytes=len(chunk), ) - def _list(self, sandbox: "Sandbox", path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: + def _list( + self, sandbox: "Sandbox", path: str, *, timeout: int + ) -> K8sAgentSandboxFileToolOutput: entries = sandbox.files.list(path, timeout=timeout) return K8sAgentSandboxFileToolOutput( path=path, entries=[self._entry_to_dict(e) for e in entries], ) - def _delete(self, sandbox: "Sandbox", path: str, *, timeout: int) -> K8sAgentSandboxFileToolOutput: + def _delete( + self, sandbox: "Sandbox", path: str, *, timeout: int + ) -> K8sAgentSandboxFileToolOutput: # TODO: Fall back to deleting with shell command. # Use normal file delete API when it is available in SDK. diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index 2c5ba790f8..fdb4f44466 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -37,7 +37,7 @@ def __init__( self._lock = Lock() self._closed = False - self._sandbox: 'Sandbox | None' = None + self._sandbox: "Sandbox | None" = None self._sandbox_acquired: bool = False self._close_timeout = close_timeout @@ -75,7 +75,9 @@ def close(self) -> None: acquired = self._lock.acquire(timeout=self._close_timeout) if not acquired: - logger.warning("Failed to acquire lock on close before the timeout. Closing anyway.") + logger.warning( + "Failed to acquire lock on close before the timeout. Closing anyway." + ) try: self._close() diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index 01352fdaba..afb9c374f7 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -24,9 +24,16 @@ class k8sAgentSandboxPythonToolSchema(BaseModel): class K8sAgentSandboxPythonToolOutput(BaseModel): - exit_code: int | None = Field(default=None, description="The exit code of the Python script execution.") - stdout: str | None = Field(default=None, description="The standard output produced by the Python script.") - stderr: str | None = Field(default=None, description="The standard error output produced by the Python script.") + exit_code: int | None = Field( + default=None, description="The exit code of the Python script execution." + ) + stdout: str | None = Field( + default=None, description="The standard output produced by the Python script." + ) + stderr: str | None = Field( + default=None, + description="The standard error output produced by the Python script.", + ) class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py index 219bd0baba..40b0d3848e 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py @@ -22,21 +22,21 @@ class K8sAgentSandboxToolClientSettings: class. """ - connection_config: 'SandboxConnectionConfig | None' = None - tracer_config: 'SandboxTracerConfig | None' = None + connection_config: "SandboxConnectionConfig | None" = None + tracer_config: "SandboxTracerConfig | None" = None cleanup: bool = False - _client: 'SandboxClient | None' = field(default=None, init=False, repr=False) + _client: "SandboxClient | None" = field(default=None, init=False, repr=False) @property - def client(self) -> 'SandboxClient': + def client(self) -> "SandboxClient": if self._client is not None: return self._client # from k8s_agent_sandbox.sandbox_client import SandboxClient kas_client_sandbox_module = lazy_import_k8s_agent_sandbox("sandbox_client") - client: 'SandboxClient' = kas_client_sandbox_module.SandboxClient( + client: "SandboxClient" = kas_client_sandbox_module.SandboxClient( connection_config=self.connection_config, tracer_config=self.tracer_config, cleanup=self.cleanup, diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py index 38f683d93e..791188a73b 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py @@ -92,9 +92,7 @@ def create( client_settings = client_settings or K8sAgentSandboxToolClientSettings() - kwargs = { - "close_timeout": close_timeout - } + kwargs = {"close_timeout": close_timeout} if claim_name is not None: lifecycle_manager = AttachModeK8sAgentSandboxLifecycleManager( @@ -102,7 +100,6 @@ def create( sandbox_settings, claim_name, **kwargs, - ) elif persistent: diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py index 027b77faa1..6fc0824d8d 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py @@ -4,6 +4,7 @@ # Global dictionary acting as the cache collection _MODULE_CACHE: dict[str, Any] = {} + def lazy_import_k8s_agent_sandbox(target: str): """ Lazily imports a module or attribute by string name, caches it globally, From a1e741f0a400574d560c38dc6eb5682dd2175678 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Wed, 29 Jul 2026 00:47:57 +0200 Subject: [PATCH 44/45] fix lint errors, return back py.typed --- lib/crewai-tools/src/crewai_tools/py.typed | 0 .../tools/k8s_agent_sandbox/base_tool.py | 17 ++++++++++---- .../tools/k8s_agent_sandbox/exec_tool.py | 6 ++--- .../tools/k8s_agent_sandbox/file_tool.py | 22 +++++++++---------- .../k8s_agent_sandbox/lifecycle_manager.py | 17 +++++++------- .../tools/k8s_agent_sandbox/python_tool.py | 4 ++-- .../tools/k8s_agent_sandbox/settings.py | 16 +++++++------- .../tools/k8s_agent_sandbox/toolset.py | 6 +++-- .../tools/k8s_agent_sandbox/utils.py | 6 ++--- 9 files changed, 52 insertions(+), 42 deletions(-) create mode 100644 lib/crewai-tools/src/crewai_tools/py.typed diff --git a/lib/crewai-tools/src/crewai_tools/py.typed b/lib/crewai-tools/src/crewai_tools/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py index 6650d06659..9e3cc1e327 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -1,4 +1,8 @@ -from typing import Any, Callable +from typing import ( + Any, + Callable, + TYPE_CHECKING +) import time import logging from pydantic import ( @@ -8,11 +12,16 @@ ) from abc import abstractmethod +if TYPE_CHECKING: + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + from crewai.tools import BaseTool from .toolset import K8sAgentSandboxToolset + + logger = logging.getLogger(__name__) DEFAULT_TOOL_TIMEOUT_SEC = 60 @@ -29,7 +38,7 @@ def model_post_init(self, __context: Any) -> None: self.toolset.add_tool(self) super().model_post_init(__context) - def _run(self, *args, **kwargs: Any) -> BaseModel: + def _run(self, *args: Any, **kwargs: Any) -> BaseModel: try: sandbox = self.toolset.lifecycle_manager.acquire_sandbox() return self._run_with_sandbox(sandbox, *args, **kwargs) @@ -37,7 +46,7 @@ def _run(self, *args, **kwargs: Any) -> BaseModel: self.toolset.lifecycle_manager.release_sandbox() @abstractmethod - def _run_with_sandbox(self, sandbox, *args, **kwargs) -> BaseModel: + def _run_with_sandbox(self, sandbox: "Sandbox", *args: Any, **kwargs: Any) -> BaseModel: # type: ignore[no-any-unimported] pass @@ -49,7 +58,7 @@ def create_timeout_tracker(timeout: int) -> Callable[[], int]: start_time = int(time.time()) - def get_remaining(): + def get_remaining() -> int: remaining_time = timeout - (int(time.time()) - start_time) if remaining_time <= 0: raise TimeoutError("Timeout. Cannot continue the tool action.") diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index d12861709c..0bc55f4d04 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field if TYPE_CHECKING: - from k8s_agent_sandbox.sandbox import Sandbox + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( K8sAgentSandboxBaseTool, @@ -38,12 +38,12 @@ class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = K8sAgentSandboxExecToolSchema - def _run_with_sandbox( + def _run_with_sandbox( # type: ignore[no-untyped-def,no-any-unimported] self, sandbox: "Sandbox", *args, **kwargs ) -> K8sAgentSandboxExecToolOutput: return self._run_command(sandbox, *args, **kwargs) - def _run_command( + def _run_command( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", command: str, diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 19c4d6a8dc..4befe590e8 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -17,8 +17,8 @@ ) if TYPE_CHECKING: - from k8s_agent_sandbox.models import FileEntry - from k8s_agent_sandbox.sandbox import Sandbox + from k8s_agent_sandbox.models import FileEntry # type: ignore[import-untyped] + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( DEFAULT_TOOL_TIMEOUT_SEC, @@ -160,7 +160,7 @@ class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = K8sAgentSandboxFileToolSchema - def _run_with_sandbox( + def _run_with_sandbox( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", action: FileAction, @@ -201,7 +201,7 @@ def _run_with_sandbox( raise ValueError(f"Unknown action: {action}") - def _read( + def _read( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, @@ -229,7 +229,7 @@ def _read( note="File was not valid utf-8; returned as base64.", ) - def _write( + def _write( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, @@ -246,7 +246,7 @@ def _write( status="written", path=path, bytes=len(payload) ) - def _append( + def _append( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, @@ -276,7 +276,7 @@ def _append( appended_bytes=len(chunk), ) - def _list( + def _list( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, *, timeout: int ) -> K8sAgentSandboxFileToolOutput: entries = sandbox.files.list(path, timeout=timeout) @@ -285,7 +285,7 @@ def _list( entries=[self._entry_to_dict(e) for e in entries], ) - def _delete( + def _delete( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, *, timeout: int ) -> K8sAgentSandboxFileToolOutput: # TODO: Fall back to deleting with shell command. @@ -314,7 +314,7 @@ def _delete( return K8sAgentSandboxFileToolOutput(status="deleted", path=path) - def _mkdir(self, sandbox: "Sandbox", path: str, *, timeout: int): + def _mkdir(self, sandbox: "Sandbox", path: str, *, timeout: int): # type: ignore[no-any-unimported,no-untyped-def] try: result = sandbox.commands.run( f"mkdir -p {shlex.quote(path)}", @@ -330,7 +330,7 @@ def _mkdir(self, sandbox: "Sandbox", path: str, *, timeout: int): f"Cannot create directory {path}. Error: {result.stderr}." ) - def _ensure_parent_dir(self, sandbox: "Sandbox", path: str, timeout: int): + def _ensure_parent_dir(self, sandbox: "Sandbox", path: str, timeout: int): # type: ignore[no-any-unimported,no-untyped-def] parent = posixpath.dirname(path) if not parent or parent in ("/", "."): return @@ -338,7 +338,7 @@ def _ensure_parent_dir(self, sandbox: "Sandbox", path: str, timeout: int): return self._mkdir(sandbox, parent, timeout=timeout) @staticmethod - def _entry_to_dict(entry: "FileEntry") -> FileEntryModel: + def _entry_to_dict(entry: "FileEntry") -> FileEntryModel: # type: ignore[no-any-unimported] return FileEntryModel( name=getattr(entry, "name", None), type=getattr(entry, "type", None), diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index fdb4f44466..215219818e 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -4,8 +4,7 @@ import logging if TYPE_CHECKING: - from k8s_agent_sandbox.exceptions import SandboxNotFoundError - from k8s_agent_sandbox.sandbox import Sandbox + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from .settings import ( @@ -37,12 +36,12 @@ def __init__( self._lock = Lock() self._closed = False - self._sandbox: "Sandbox | None" = None + self._sandbox: "Sandbox | None" = None # type: ignore[no-any-unimported] self._sandbox_acquired: bool = False self._close_timeout = close_timeout - def acquire_sandbox(self) -> "Sandbox": + def acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] """ Acquires a sandbox based on this implementation and returns it. In order to be acquired again by someone else, it has to be @@ -87,7 +86,7 @@ def close(self) -> None: self._lock.release() @abstractmethod - def _acquire_sandbox(self) -> "Sandbox": + def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] pass @abstractmethod @@ -105,7 +104,7 @@ def _terminate_sandbox(self) -> None: self._sandbox.terminate() self._sandbox = None - def _create_sandbox(self) -> "Sandbox": + def _create_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] return self._client.create_sandbox( warmpool=self._sandbox_settings.warmpool, namespace=self._sandbox_settings.namespace, @@ -119,7 +118,7 @@ class EphemeralModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleManag method and terminates it on `release_sandbox`. """ - def _acquire_sandbox(self) -> "Sandbox": + def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] self._sandbox = self._create_sandbox() return self._sandbox @@ -150,7 +149,7 @@ def __init__( ) self._claim_name = claim_name - def _acquire_sandbox(self) -> "Sandbox": + def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] kas_exceptions_module = lazy_import_k8s_agent_sandbox("exceptions") try: self._sandbox = self._client.get_sandbox( @@ -182,7 +181,7 @@ class PersistentModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleMana of the`acquire_sandbox` and `release_sandbox`. """ - def _acquire_sandbox(self) -> "Sandbox": + def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] if self._sandbox is not None: return self._sandbox diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index afb9c374f7..488313cc16 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -6,7 +6,7 @@ ) if TYPE_CHECKING: - from k8s_agent_sandbox.sandbox import Sandbox + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( K8sAgentSandboxBaseTool, @@ -44,7 +44,7 @@ class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = k8sAgentSandboxPythonToolSchema - def _run_with_sandbox( + def _run_with_sandbox( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", code: str, timeout: int ) -> K8sAgentSandboxPythonToolOutput: diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py index 40b0d3848e..0bb48a5dd5 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py @@ -5,38 +5,38 @@ ) if TYPE_CHECKING: - from k8s_agent_sandbox.models import ( + from k8s_agent_sandbox.models import ( # type: ignore[import-untyped] SandboxConnectionConfig, SandboxTracerConfig, ) - from k8s_agent_sandbox.sandbox_client import SandboxClient + from k8s_agent_sandbox.sandbox_client import SandboxClient # type: ignore[import-untyped] from .utils import lazy_import_k8s_agent_sandbox @dataclass -class K8sAgentSandboxToolClientSettings: +class K8sAgentSandboxToolClientSettings: # type: ignore[no-any-unimported] """ The dataclass that stores data required to created an Agent Sandbox Client. It basically has the same arguments as the `k8s_agent_sandbox.SandboxClient` class. """ - connection_config: "SandboxConnectionConfig | None" = None - tracer_config: "SandboxTracerConfig | None" = None + connection_config: "SandboxConnectionConfig | None" = None # type: ignore[no-any-unimported] + tracer_config: "SandboxTracerConfig | None" = None # type: ignore[no-any-unimported] cleanup: bool = False - _client: "SandboxClient | None" = field(default=None, init=False, repr=False) + _client: "SandboxClient | None" = field(default=None, init=False, repr=False) # type: ignore[no-any-unimported] @property - def client(self) -> "SandboxClient": + def client(self) -> "SandboxClient": # type: ignore[no-any-unimported] if self._client is not None: return self._client # from k8s_agent_sandbox.sandbox_client import SandboxClient kas_client_sandbox_module = lazy_import_k8s_agent_sandbox("sandbox_client") - client: "SandboxClient" = kas_client_sandbox_module.SandboxClient( + client: "SandboxClient" = kas_client_sandbox_module.SandboxClient( # type: ignore[no-any-unimported] connection_config=self.connection_config, tracer_config=self.tracer_config, cleanup=self.cleanup, diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py index 791188a73b..c76fd8de6a 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py @@ -44,7 +44,7 @@ def __init__( self._all_tools: dict[str, "K8sAgentSandboxBaseTool"] = {} - def add_tool(self, tool: "K8sAgentSandboxBaseTool"): + def add_tool(self, tool: "K8sAgentSandboxBaseTool") -> None: name = tool.name if name in self._all_tools: raise ValueError(f"The tool '{name}' is already in the toolset.") @@ -55,7 +55,7 @@ def add_tool(self, tool: "K8sAgentSandboxBaseTool"): def tools(self) -> list["K8sAgentSandboxBaseTool"]: return list(self._all_tools.values()) - def close(self): + def close(self) -> None: self.lifecycle_manager.close() @classmethod @@ -94,6 +94,8 @@ def create( kwargs = {"close_timeout": close_timeout} + lifecycle_manager: K8sAgentSandboxLifecycleManager + if claim_name is not None: lifecycle_manager = AttachModeK8sAgentSandboxLifecycleManager( client_settings, diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py index 6fc0824d8d..c4dcaba9eb 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py @@ -1,11 +1,11 @@ import importlib -from typing import Any +import types # Global dictionary acting as the cache collection -_MODULE_CACHE: dict[str, Any] = {} +_MODULE_CACHE: dict[str, types.ModuleType] = {} -def lazy_import_k8s_agent_sandbox(target: str): +def lazy_import_k8s_agent_sandbox(target: str) -> types.ModuleType: """ Lazily imports a module or attribute by string name, caches it globally, and returns it. Subsequent calls return the object directly from the cache. From 90a23582ed4d3354122db73acb42b26a5f3c8e98 Mon Sep 17 00:00:00 2001 From: Artur Kamalov Date: Wed, 29 Jul 2026 00:50:04 +0200 Subject: [PATCH 45/45] reformat --- .../tools/k8s_agent_sandbox/base_tool.py | 14 +++++------- .../tools/k8s_agent_sandbox/exec_tool.py | 6 ++--- .../tools/k8s_agent_sandbox/file_tool.py | 22 +++++++++---------- .../k8s_agent_sandbox/lifecycle_manager.py | 16 +++++++------- .../tools/k8s_agent_sandbox/python_tool.py | 4 ++-- .../tools/k8s_agent_sandbox/settings.py | 14 ++++++------ 6 files changed, 36 insertions(+), 40 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py index 9e3cc1e327..b047a01eb4 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -1,8 +1,4 @@ -from typing import ( - Any, - Callable, - TYPE_CHECKING -) +from typing import Any, Callable, TYPE_CHECKING import time import logging from pydantic import ( @@ -13,15 +9,13 @@ from abc import abstractmethod if TYPE_CHECKING: - from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from crewai.tools import BaseTool from .toolset import K8sAgentSandboxToolset - - logger = logging.getLogger(__name__) DEFAULT_TOOL_TIMEOUT_SEC = 60 @@ -46,7 +40,9 @@ def _run(self, *args: Any, **kwargs: Any) -> BaseModel: self.toolset.lifecycle_manager.release_sandbox() @abstractmethod - def _run_with_sandbox(self, sandbox: "Sandbox", *args: Any, **kwargs: Any) -> BaseModel: # type: ignore[no-any-unimported] + def _run_with_sandbox( # type: ignore[no-any-unimported] + self, sandbox: "Sandbox", *args: Any, **kwargs: Any + ) -> BaseModel: pass diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py index 0bc55f4d04..ffa38c7473 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field if TYPE_CHECKING: - from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( K8sAgentSandboxBaseTool, @@ -38,12 +38,12 @@ class K8sAgentSandboxExecTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = K8sAgentSandboxExecToolSchema - def _run_with_sandbox( # type: ignore[no-untyped-def,no-any-unimported] + def _run_with_sandbox( # type: ignore[no-untyped-def,no-any-unimported] self, sandbox: "Sandbox", *args, **kwargs ) -> K8sAgentSandboxExecToolOutput: return self._run_command(sandbox, *args, **kwargs) - def _run_command( # type: ignore[no-any-unimported] + def _run_command( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", command: str, diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py index 4befe590e8..1d0f6534a3 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -17,8 +17,8 @@ ) if TYPE_CHECKING: - from k8s_agent_sandbox.models import FileEntry # type: ignore[import-untyped] - from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + from k8s_agent_sandbox.models import FileEntry # type: ignore[import-untyped] + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( DEFAULT_TOOL_TIMEOUT_SEC, @@ -160,7 +160,7 @@ class K8sAgentSandboxFileTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = K8sAgentSandboxFileToolSchema - def _run_with_sandbox( # type: ignore[no-any-unimported] + def _run_with_sandbox( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", action: FileAction, @@ -201,7 +201,7 @@ def _run_with_sandbox( # type: ignore[no-any-unimported] raise ValueError(f"Unknown action: {action}") - def _read( # type: ignore[no-any-unimported] + def _read( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, @@ -229,7 +229,7 @@ def _read( # type: ignore[no-any-unimported] note="File was not valid utf-8; returned as base64.", ) - def _write( # type: ignore[no-any-unimported] + def _write( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, @@ -246,7 +246,7 @@ def _write( # type: ignore[no-any-unimported] status="written", path=path, bytes=len(payload) ) - def _append( # type: ignore[no-any-unimported] + def _append( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, @@ -276,7 +276,7 @@ def _append( # type: ignore[no-any-unimported] appended_bytes=len(chunk), ) - def _list( # type: ignore[no-any-unimported] + def _list( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, *, timeout: int ) -> K8sAgentSandboxFileToolOutput: entries = sandbox.files.list(path, timeout=timeout) @@ -285,7 +285,7 @@ def _list( # type: ignore[no-any-unimported] entries=[self._entry_to_dict(e) for e in entries], ) - def _delete( # type: ignore[no-any-unimported] + def _delete( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", path: str, *, timeout: int ) -> K8sAgentSandboxFileToolOutput: # TODO: Fall back to deleting with shell command. @@ -314,7 +314,7 @@ def _delete( # type: ignore[no-any-unimported] return K8sAgentSandboxFileToolOutput(status="deleted", path=path) - def _mkdir(self, sandbox: "Sandbox", path: str, *, timeout: int): # type: ignore[no-any-unimported,no-untyped-def] + def _mkdir(self, sandbox: "Sandbox", path: str, *, timeout: int): # type: ignore[no-any-unimported,no-untyped-def] try: result = sandbox.commands.run( f"mkdir -p {shlex.quote(path)}", @@ -330,7 +330,7 @@ def _mkdir(self, sandbox: "Sandbox", path: str, *, timeout: int): # type: ignore f"Cannot create directory {path}. Error: {result.stderr}." ) - def _ensure_parent_dir(self, sandbox: "Sandbox", path: str, timeout: int): # type: ignore[no-any-unimported,no-untyped-def] + def _ensure_parent_dir(self, sandbox: "Sandbox", path: str, timeout: int): # type: ignore[no-any-unimported,no-untyped-def] parent = posixpath.dirname(path) if not parent or parent in ("/", "."): return @@ -338,7 +338,7 @@ def _ensure_parent_dir(self, sandbox: "Sandbox", path: str, timeout: int): # typ return self._mkdir(sandbox, parent, timeout=timeout) @staticmethod - def _entry_to_dict(entry: "FileEntry") -> FileEntryModel: # type: ignore[no-any-unimported] + def _entry_to_dict(entry: "FileEntry") -> FileEntryModel: # type: ignore[no-any-unimported] return FileEntryModel( name=getattr(entry, "name", None), type=getattr(entry, "type", None), diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py index 215219818e..bcadef9829 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -4,7 +4,7 @@ import logging if TYPE_CHECKING: - from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from .settings import ( @@ -36,12 +36,12 @@ def __init__( self._lock = Lock() self._closed = False - self._sandbox: "Sandbox | None" = None # type: ignore[no-any-unimported] + self._sandbox: "Sandbox | None" = None # type: ignore[no-any-unimported] self._sandbox_acquired: bool = False self._close_timeout = close_timeout - def acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] + def acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] """ Acquires a sandbox based on this implementation and returns it. In order to be acquired again by someone else, it has to be @@ -86,7 +86,7 @@ def close(self) -> None: self._lock.release() @abstractmethod - def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] + def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] pass @abstractmethod @@ -104,7 +104,7 @@ def _terminate_sandbox(self) -> None: self._sandbox.terminate() self._sandbox = None - def _create_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] + def _create_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] return self._client.create_sandbox( warmpool=self._sandbox_settings.warmpool, namespace=self._sandbox_settings.namespace, @@ -118,7 +118,7 @@ class EphemeralModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleManag method and terminates it on `release_sandbox`. """ - def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] + def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] self._sandbox = self._create_sandbox() return self._sandbox @@ -149,7 +149,7 @@ def __init__( ) self._claim_name = claim_name - def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] + def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] kas_exceptions_module = lazy_import_k8s_agent_sandbox("exceptions") try: self._sandbox = self._client.get_sandbox( @@ -181,7 +181,7 @@ class PersistentModeK8sAgentSandboxLifecycleManager(K8sAgentSandboxLifecycleMana of the`acquire_sandbox` and `release_sandbox`. """ - def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] + def _acquire_sandbox(self) -> "Sandbox": # type: ignore[no-any-unimported] if self._sandbox is not None: return self._sandbox diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py index 488313cc16..ee0e160345 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -6,7 +6,7 @@ ) if TYPE_CHECKING: - from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( K8sAgentSandboxBaseTool, @@ -44,7 +44,7 @@ class K8sAgentSandboxPythonTool(K8sAgentSandboxBaseTool): ) args_schema: type[BaseModel] = k8sAgentSandboxPythonToolSchema - def _run_with_sandbox( # type: ignore[no-any-unimported] + def _run_with_sandbox( # type: ignore[no-any-unimported] self, sandbox: "Sandbox", code: str, timeout: int ) -> K8sAgentSandboxPythonToolOutput: diff --git a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py index 0bb48a5dd5..627c1fd53e 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py @@ -5,17 +5,17 @@ ) if TYPE_CHECKING: - from k8s_agent_sandbox.models import ( # type: ignore[import-untyped] + from k8s_agent_sandbox.models import ( # type: ignore[import-untyped] SandboxConnectionConfig, SandboxTracerConfig, ) - from k8s_agent_sandbox.sandbox_client import SandboxClient # type: ignore[import-untyped] + from k8s_agent_sandbox.sandbox_client import SandboxClient # type: ignore[import-untyped] from .utils import lazy_import_k8s_agent_sandbox @dataclass -class K8sAgentSandboxToolClientSettings: # type: ignore[no-any-unimported] +class K8sAgentSandboxToolClientSettings: # type: ignore[no-any-unimported] """ The dataclass that stores data required to created an Agent Sandbox Client. It basically has the same arguments as the `k8s_agent_sandbox.SandboxClient` @@ -23,20 +23,20 @@ class K8sAgentSandboxToolClientSettings: # type: ignore[no-any-unimported] """ connection_config: "SandboxConnectionConfig | None" = None # type: ignore[no-any-unimported] - tracer_config: "SandboxTracerConfig | None" = None # type: ignore[no-any-unimported] + tracer_config: "SandboxTracerConfig | None" = None # type: ignore[no-any-unimported] cleanup: bool = False - _client: "SandboxClient | None" = field(default=None, init=False, repr=False) # type: ignore[no-any-unimported] + _client: "SandboxClient | None" = field(default=None, init=False, repr=False) # type: ignore[no-any-unimported] @property - def client(self) -> "SandboxClient": # type: ignore[no-any-unimported] + def client(self) -> "SandboxClient": # type: ignore[no-any-unimported] if self._client is not None: return self._client # from k8s_agent_sandbox.sandbox_client import SandboxClient kas_client_sandbox_module = lazy_import_k8s_agent_sandbox("sandbox_client") - client: "SandboxClient" = kas_client_sandbox_module.SandboxClient( # type: ignore[no-any-unimported] + client: "SandboxClient" = kas_client_sandbox_module.SandboxClient( # type: ignore[no-any-unimported] connection_config=self.connection_config, tracer_config=self.tracer_config, cleanup=self.cleanup,