diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 8cdbdcd743..e9b1a77cd3 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -148,6 +148,9 @@ e2b = [ "e2b-code-interpreter~=2.6.0", ] +k8s_agent_sandbox = [ + "k8s-agent-sandbox~=0.5.0", +] [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..7dce37b3fb 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -217,6 +217,15 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools +from crewai_tools.tools.k8s_agent_sandbox import ( + K8sAgentSandboxToolClientSettings, + K8sAgentSandboxToolSandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxBaseTool, + K8sAgentSandboxExecTool, + K8sAgentSandboxPythonTool, + K8sAgentSandboxFileTool, +) __all__ = [ @@ -272,6 +281,13 @@ "InvokeCrewAIAutomationTool", "JSONSearchTool", "JinaScrapeWebsiteTool", + "K8sAgentSandboxToolClientSettings", + "K8sAgentSandboxToolSandboxSettings", + "K8sAgentSandboxToolset", + "K8sAgentSandboxBaseTool", + "K8sAgentSandboxExecTool", + "K8sAgentSandboxFileTool", + "K8sAgentSandboxPythonTool", "LinkupSearchTool", "LlamaIndexTool", "MCPServerAdapter", diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index dfab952b9f..34fc0add3b 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -204,6 +204,15 @@ YoutubeVideoSearchTool, ) from crewai_tools.tools.zapier_action_tool.zapier_action_tool import ZapierActionTools +from crewai_tools.tools.k8s_agent_sandbox import ( + K8sAgentSandboxToolClientSettings, + K8sAgentSandboxToolSandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxBaseTool, + K8sAgentSandboxExecTool, + K8sAgentSandboxPythonTool, + K8sAgentSandboxFileTool, +) __all__ = [ @@ -256,6 +265,13 @@ "InvokeCrewAIAutomationTool", "JSONSearchTool", "JinaScrapeWebsiteTool", + "K8sAgentSandboxToolClientSettings", + "K8sAgentSandboxToolSandboxSettings", + "K8sAgentSandboxToolset", + "K8sAgentSandboxBaseTool", + "K8sAgentSandboxExecTool", + "K8sAgentSandboxFileTool", + "K8sAgentSandboxPythonTool", "LinkupSearchTool", "LlamaIndexTool", "MDXSearchTool", 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..204034502e --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/README.md @@ -0,0 +1,262 @@ +# K8s Agent Sandbox Tools + +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: + +- **`K8sAgentSandboxExecTool`** — run a shell command. +- **`K8sAgentSandboxFileTool`** — read / write / list / delete files. +- **`K8sAgentSandboxPythonTool`** — run Python code. + +## Installation + +```shell +uv add "crewai-tools[k8s_agent_sandbox]" +# or +pip install "crewai-tools[k8s_agent_sandbox]" +``` + +## Sandbox toolset + +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 have to be added to the same instance of the +`K8sAgentSandboxToolset` class. + +### 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, + K8sAgentSandboxToolset, + K8sAgentSandboxExecTool, + K8sAgentSandboxFileTool, +) + +toolset = K8sAgentSandboxToolset.create( + # Sandbox settings for all tools in this toolset. + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", + ), +) + +# Adding the tools to the toolset. +exec_tool = K8sAgentSandboxExecTool(toolset=toolset) +file_tool = K8sAgentSandboxFileTool(toolset=toolset) +``` + +### Lifecycle modes + +The `K8sAgentSandboxToolset` supports three sandbox lifecycle modes and they can be set on creation of the toolset instance +with the `K8sAgentSandboxToolset.create` factory method: + +| 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 | + +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 via an absolute TTL from creation. Tune it via `sandbox_timeout` (seconds, default `300`). + +All tool operations to a sandbox are synchronized 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, +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 +from crewai_tools import ( + K8sAgentSandboxToolClientSettings as ClientSettings, # <- import this. + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxExecTool, + K8sAgentSandboxFileTool, +) +from k8s_agent_sandbox.models import SandboxGatewayConnectionConfig + + +toolset = K8sAgentSandboxToolset.create( + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", + ), + client_settings=ClientSettings( + connection_config=SandboxGatewayConnectionConfig( + gateway_name="external-http-gateway", # Name of the Gateway resource + ) + ) +) + +``` + +## Examples + +### Ephemeral sandboxes + +```python +from crewai_tools import ( + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, +) + +toolset = K8sAgentSandboxToolset.create( + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", + ), +) + +exec_tool = K8sAgentSandboxExecTool(toolset=toolset) +file_tool = K8sAgentSandboxFileTool(toolset=toolset) +``` + +### Persistent sandboxes + +```python +from crewai_tools import ( + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, +) + +toolset = K8sAgentSandboxToolset.create( + sandbox_settings=SandboxSettings( + warmpool="my-warmpool", + namespace="my-namespace", + ), + persistent=True # <-- Add this. +) + +exec_tool = K8sAgentSandboxExecTool(toolset=toolset) +file_tool = K8sAgentSandboxFileTool(toolset=toolset) +``` + +### 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) +``` + +## 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` — 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. + +### `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 ( + K8sAgentSandboxToolSandboxSettings as SandboxSettings, + K8sAgentSandboxToolset, + K8sAgentSandboxPythonTool, + K8sAgentSandboxExecTool, + K8sAgentSandboxFileTool, +) + +# 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 +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=[exec_tool, file_tool, python_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.", + 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) +``` + 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..9ea6d3098d --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/__init__.py @@ -0,0 +1,20 @@ +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__ = [ + "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 new file mode 100644 index 0000000000..b047a01eb4 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/base_tool.py @@ -0,0 +1,63 @@ +from typing import Any, Callable, TYPE_CHECKING +import time +import logging +from pydantic import ( + BaseModel, + Field, + SkipValidation, +) +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 + + +class K8sAgentSandboxBaseTool(BaseTool, arbitrary_types_allowed=True): + name: str + description: str + toolset: SkipValidation[K8sAgentSandboxToolset] = Field( + 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: Any, **kwargs: Any) -> BaseModel: + try: + 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( # type: ignore[no-any-unimported] + self, sandbox: "Sandbox", *args: Any, **kwargs: Any + ) -> 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 multiple sandbox commands in one tool action. + """ + + start_time = int(time.time()) + + def get_remaining() -> int: + remaining_time = timeout - (int(time.time()) - start_time) + if remaining_time <= 0: + raise TimeoutError("Timeout. Cannot continue the tool action.") + return remaining_time + + 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 new file mode 100644 index 0000000000..ffa38c7473 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/exec_tool.py @@ -0,0 +1,57 @@ +from typing import TYPE_CHECKING + +from pydantic import BaseModel, Field + +if TYPE_CHECKING: + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + +from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( + K8sAgentSandboxBaseTool, + DEFAULT_TOOL_TIMEOUT_SEC, +) + + +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 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 = ( + "Executes shell commands inside an isolated Kubernetes pod sandbox." + ) + args_schema: type[BaseModel] = K8sAgentSandboxExecToolSchema + + 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] + self, + sandbox: "Sandbox", + command: str, + timeout: int, + ) -> K8sAgentSandboxExecToolOutput: + result = sandbox.commands.run(command, timeout=timeout) + 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 new file mode 100644 index 0000000000..1d0f6534a3 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/file_tool.py @@ -0,0 +1,347 @@ +from typing import ( + Callable, + Literal, + TYPE_CHECKING, +) +import base64 +import shlex +import posixpath +import logging +from textwrap import dedent +import uuid + +from pydantic import ( + Field, + BaseModel, + model_validator, +) + +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 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", "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. + - 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.", + ) + content: str | None = Field( + default=None, + description=( + "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 = 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 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 a 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 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." + ) + args_schema: type[BaseModel] = K8sAgentSandboxFileToolSchema + + def _run_with_sandbox( # type: ignore[no-any-unimported] + self, + sandbox: "Sandbox", + action: FileAction, + path: str, + timeout: int, + binary: bool, + content: str | None = None, + ) -> K8sAgentSandboxFileToolOutput: + + 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 K8sAgentSandboxFileToolOutput(status="created", path=path) + if action == "exists": + result = sandbox.files.exists(path, timeout=timeout) + return K8sAgentSandboxFileToolOutput(path=path, exists=result) + + raise ValueError(f"Unknown action: {action}") + + def _read( # type: ignore[no-any-unimported] + self, + sandbox: "Sandbox", + path: str, + *, + binary: bool, + timeout: int, + ) -> K8sAgentSandboxFileToolOutput: + + data: bytes = sandbox.files.read(path, timeout=timeout) + if binary: + return K8sAgentSandboxFileToolOutput( + path=path, + encoding="base64", + content=base64.b64encode(data).decode("ascii"), + ) + try: + return K8sAgentSandboxFileToolOutput( + path=path, encoding="utf-8", content=data.decode("utf-8") + ) + except UnicodeDecodeError: + return K8sAgentSandboxFileToolOutput( + path=path, + encoding="base64", + content=base64.b64encode(data).decode("ascii"), + note="File was not valid utf-8; returned as base64.", + ) + + def _write( # type: ignore[no-any-unimported] + self, + sandbox: "Sandbox", + path: str, + content: str, + *, + binary: bool, + timeout_tracker: Callable[[], int], + ) -> 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 K8sAgentSandboxFileToolOutput( + status="written", path=path, bytes=len(payload) + ) + + def _append( # type: ignore[no-any-unimported] + self, + sandbox: "Sandbox", + path: str, + content: str, + *, + binary: bool, + timeout_tracker: Callable[[], int], + ) -> K8sAgentSandboxFileToolOutput: + 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()}.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) + 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, + appended_bytes=len(chunk), + ) + + def _list( # type: ignore[no-any-unimported] + 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( # type: ignore[no-any-unimported] + 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. + + 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.") + + 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 K8sAgentSandboxFileToolOutput(status="deleted", path=path) + + 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)}", + 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): # type: ignore[no-any-unimported,no-untyped-def] + 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") -> FileEntryModel: # type: ignore[no-any-unimported] + return FileEntryModel( + name=getattr(entry, "name", None), + type=getattr(entry, "type", None), + size=getattr(entry, "size", None), + mod_time=getattr(entry, "mod_time", 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 new file mode 100644 index 0000000000..bcadef9829 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/lifecycle_manager.py @@ -0,0 +1,195 @@ +from typing import TYPE_CHECKING +from threading import Lock +from abc import ABC, abstractmethod +import logging + +if TYPE_CHECKING: + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + + +from .settings import ( + K8sAgentSandboxToolClientSettings, + K8sAgentSandboxToolSandboxSettings, +) + +from .utils import lazy_import_k8s_agent_sandbox + +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, + close_timeout: int = 60, + ): + 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 # 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] + """ + Acquires a sandbox based on this implementation and returns it. + 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._sandbox_acquired = True + return self._acquire_sandbox() + + def release_sandbox(self) -> None: + """ + Releases a sandbox that is previously acquired. + """ + if not self._sandbox_acquired: + return + try: + if not self._closed: + self._release_sandbox() + finally: + self._sandbox_acquired = False + self._lock.release() + + def close(self) -> None: + """Closes the lifecycle manager.""" + if self._closed: + return + + 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": # type: ignore[no-any-unimported] + pass + + @abstractmethod + def _release_sandbox(self) -> None: + pass + + @abstractmethod + def _close(self) -> None: + pass + + def _terminate_sandbox(self) -> None: + if self._sandbox is None: + return + + self._sandbox.terminate() + self._sandbox = None + + 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, + 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": # type: ignore[no-any-unimported] + self._sandbox = self._create_sandbox() + return self._sandbox + + def _release_sandbox(self) -> None: + self._terminate_sandbox() + + def _close(self) -> None: + 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, + close_timeout: int = 60, + ): + super().__init__( + client_settings, + sandbox_settings, + close_timeout=close_timeout, + ) + self._claim_name = claim_name + + 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( + self._claim_name, + namespace=self._sandbox_settings.namespace, + ) + + except kas_exceptions_module.SandboxNotFoundError: + self._sandbox = None + + if self._sandbox is not None: + return self._sandbox + + raise kas_exceptions_module.SandboxNotFoundError( + f"A sandbox with sandbox claim '{self._claim_name}' " + "is expected to exist, but cannot be found." + ) + + def _release_sandbox(self) -> None: + pass + + def _close(self) -> None: + 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) -> "Sandbox": # type: ignore[no-any-unimported] + if self._sandbox is not None: + return self._sandbox + + self._sandbox = self._create_sandbox() + return self._sandbox + + def _release_sandbox(self) -> None: + pass + + 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 new file mode 100644 index 0000000000..ee0e160345 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/python_tool.py @@ -0,0 +1,71 @@ +from typing import TYPE_CHECKING +import uuid +from pydantic import ( + BaseModel, + Field, +) + +if TYPE_CHECKING: + from k8s_agent_sandbox.sandbox import Sandbox # type: ignore[import-untyped] + +from crewai_tools.tools.k8s_agent_sandbox.base_tool import ( + K8sAgentSandboxBaseTool, + DEFAULT_TOOL_TIMEOUT_SEC, + create_timeout_tracker, +) + + +class k8sAgentSandboxPythonToolSchema(BaseModel): + 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 code execution to finish.", + ) + + +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 = ( + "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( # type: ignore[no-any-unimported] + self, sandbox: "Sandbox", code: str, timeout: int + ) -> K8sAgentSandboxPythonToolOutput: + + timeout_tracker = create_timeout_tracker(timeout) + + tmp_file_path = f"/tmp/crewai-{uuid.uuid4()}.py" + + 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}; rc=$?; rm -f {tmp_file_path}; exit $rc", + timeout=timeout_tracker(), + ) + + return K8sAgentSandboxPythonToolOutput( + 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..627c1fd53e --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/settings.py @@ -0,0 +1,61 @@ +from typing import TYPE_CHECKING +from dataclasses import ( + dataclass, + field, +) + +if TYPE_CHECKING: + 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 .utils import lazy_import_k8s_agent_sandbox + + +@dataclass +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 # 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] + + @property + 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] + connection_config=self.connection_config, + tracer_config=self.tracer_config, + cleanup=self.cleanup, + ) + self._client = client + 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 absolute TTL in seconds from creation 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..c76fd8de6a --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/toolset.py @@ -0,0 +1,124 @@ +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` instead of the constructor. + + Args: + 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 program 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") -> None: + 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) -> None: + 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, + close_timeout: int = 60, + ) -> Self: + """ + Create a toolset by using Agent Sandbox client and sandbox settings. + + Args: + 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. + 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. + close_timeout: Time in seconds to wait for all ongoing sandbox operations before closing + the toolset. + + """ + if persistent and claim_name is not None: + raise ValueError("The persistent and attach modes are mutually exclusive.") + + client_settings = client_settings or K8sAgentSandboxToolClientSettings() + + kwargs = {"close_timeout": close_timeout} + + lifecycle_manager: K8sAgentSandboxLifecycleManager + + 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( + lifecycle_manager, + cleanup_on_exit=cleanup_on_exit, + ) 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..c4dcaba9eb --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/k8s_agent_sandbox/utils.py @@ -0,0 +1,37 @@ +import importlib +import types + +# Global dictionary acting as the cache collection +_MODULE_CACHE: dict[str, types.ModuleType] = {} + + +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. + + :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 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..1000315d32 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/conftest.py @@ -0,0 +1,118 @@ +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..aeb93580d5 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_base_tool.py @@ -0,0 +1,107 @@ +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 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) -> DummyK8sToolOutput: + return self._dummy_work(sandbox, *args, **kwargs) + + def _dummy_work( + self, sandbox, test_arg: str, test_kwarg: str | None = None + ) -> DummyK8sToolOutput: + return DummyK8sToolOutput( + 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 == DummyK8sToolOutput( + 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) -> DummyK8sToolOutput: + 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..bfd045140a --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_exec_tool.py @@ -0,0 +1,34 @@ +import pytest +from k8s_agent_sandbox.models import ExecutionResult + +from crewai_tools.tools.k8s_agent_sandbox.exec_tool import ( + K8sAgentSandboxExecTool, + K8sAgentSandboxExecToolOutput, +) +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 == 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 new file mode 100644 index 0000000000..aa4a7ee1f1 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_file_tool.py @@ -0,0 +1,306 @@ +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, + K8sAgentSandboxFileToolOutput, + FileEntryModel, +) + + +@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 == K8sAgentSandboxFileToolOutput( + content=base64.b64encode(content).decode("ascii"), + path="some/path", + encoding="base64", + ) + else: + assert result == K8sAgentSandboxFileToolOutput( + 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 == K8sAgentSandboxFileToolOutput( + 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 == K8sAgentSandboxFileToolOutput( + 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="", + ) + + content_to_append = "some content" + + 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 == K8sAgentSandboxFileToolOutput( + path="parent/file.txt", + status="appended", + appended_bytes=12, + ) + + + 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[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] + + @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: + 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 == 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", + ) + + +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 == K8sAgentSandboxFileToolOutput( + 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)}" + ) + + @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): + 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 == K8sAgentSandboxFileToolOutput( + path="parent/subfolder", + status="created", + ) + + mock_sandbox.commands.run.assert_called_once_with( + f"mkdir -p {shlex.quote('parent/subfolder')}", + timeout=120, + ) + + +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 == K8sAgentSandboxFileToolOutput(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..fd73567e9b --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_lifecycle_manager.py @@ -0,0 +1,185 @@ +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 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() + 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 + + +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 + ): + + 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 + + +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() + + 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 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..df3246a590 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_python_tool.py @@ -0,0 +1,36 @@ +import base64 +import pytest + +from k8s_agent_sandbox.models import ExecutionResult + +from crewai_tools.tools.k8s_agent_sandbox.python_tool import ( + K8sAgentSandboxPythonTool, + K8sAgentSandboxPythonToolOutput, +) + + +@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 == K8sAgentSandboxPythonToolOutput( + exit_code=exit_code, + stdout="some-output", + stderr="some-logs", + ) + + 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 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..8767a6ccf5 --- /dev/null +++ b/lib/crewai-tools/tests/tools/k8s_agent_sandbox/test_toolset.py @@ -0,0 +1,74 @@ +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", + ) 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"