diff --git a/src/praisonai-agents/tests/managed/test_cloud_compute.py b/src/praisonai-agents/tests/managed/test_cloud_compute.py index 97775fd18..20d65ca3f 100644 --- a/src/praisonai-agents/tests/managed/test_cloud_compute.py +++ b/src/praisonai-agents/tests/managed/test_cloud_compute.py @@ -121,6 +121,45 @@ def test_execute_nonexistent_instance(self): assert result["exit_code"] == -1 +class TestTenkiComputeUnit: + def test_importable(self): + from praisonai.integrations.compute.tenki import TenkiCompute + assert TenkiCompute is not None + + def test_provider_name(self): + from praisonai.integrations.compute.tenki import TenkiCompute + compute = TenkiCompute(api_key="test") + assert compute.provider_name == "tenki" + + def test_is_available_with_key(self): + from praisonai.integrations.compute.tenki import TenkiCompute + compute = TenkiCompute(api_key="test-key") + assert compute.is_available is True + + def test_is_available_without_key(self): + from praisonai.integrations.compute.tenki import TenkiCompute + old = os.environ.pop("TENKI_API_KEY", None) + try: + compute = TenkiCompute(api_key="") + assert compute.is_available is False + finally: + if old: + os.environ["TENKI_API_KEY"] = old + + def test_protocol_methods_exist(self): + from praisonai.integrations.compute.tenki import TenkiCompute + compute = TenkiCompute(api_key="test") + for method in ["provision", "shutdown", "get_status", "execute", + "upload_file", "download_file", "list_instances"]: + assert hasattr(compute, method), f"Missing method: {method}" + + def test_execute_nonexistent_instance(self): + from praisonai.integrations.compute.tenki import TenkiCompute + compute = TenkiCompute(api_key="test") + result = asyncio.run(compute.execute("nonexistent", "echo hello")) + assert result["exit_code"] == -1 + + class TestComputeExports: def test_all_adapters_from_init(self): from praisonai.integrations.compute import ( @@ -130,6 +169,7 @@ def test_all_adapters_from_init(self): E2BCompute, ModalCompute, FlyioCompute, + TenkiCompute, ) assert DockerCompute is not None assert LocalCompute is not None @@ -137,6 +177,7 @@ def test_all_adapters_from_init(self): assert E2BCompute is not None assert ModalCompute is not None assert FlyioCompute is not None + assert TenkiCompute is not None # --------------------------------------------------------------------------- # @@ -260,6 +301,90 @@ def test_pip_install_via_image(self): asyncio.run(compute.shutdown(info.instance_id)) +class TestTenkiComputeIntegration: + @pytest.fixture(autouse=True) + def skip_without_key(self): + if not os.environ.get("TENKI_API_KEY"): + pytest.skip("TENKI_API_KEY not set") + + def test_provision_execute_shutdown(self): + from praisonai.integrations.compute.tenki import TenkiCompute + from praisonaiagents.managed.protocols import ComputeConfig, InstanceStatus + + compute = TenkiCompute() + config = ComputeConfig( + idle_timeout_s=120, + env={"TEST_VAR": "hello_tenki"}, + ) + + info = asyncio.run(compute.provision(config)) + assert info.status == InstanceStatus.RUNNING + assert info.provider == "tenki" + assert info.instance_id.startswith("tenki_") + + result = asyncio.run(compute.execute(info.instance_id, "echo $TEST_VAR")) + assert result["exit_code"] == 0 + assert "hello_tenki" in result["stdout"] + + result2 = asyncio.run(compute.execute(info.instance_id, "python3 -c 'print(2+2)'")) + assert result2["exit_code"] == 0 + assert "4" in result2["stdout"] + + status = asyncio.run(compute.get_status(info.instance_id)) + assert status.status == InstanceStatus.RUNNING + + instances = asyncio.run(compute.list_instances()) + assert len(instances) >= 1 + + asyncio.run(compute.shutdown(info.instance_id)) + status2 = asyncio.run(compute.get_status(info.instance_id)) + assert status2.status == InstanceStatus.STOPPED + + def test_file_upload_download(self): + import tempfile + from praisonai.integrations.compute.tenki import TenkiCompute + from praisonaiagents.managed.protocols import ComputeConfig + + compute = TenkiCompute() + info = asyncio.run(compute.provision(ComputeConfig(idle_timeout_s=120))) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("test content from host") + local_path = f.name + + ok = asyncio.run(compute.upload_file(info.instance_id, local_path, "/tmp/test.txt")) + assert ok is True + + dl_path = local_path + ".download" + ok2 = asyncio.run(compute.download_file(info.instance_id, "/tmp/test.txt", dl_path)) + assert ok2 is True + with open(dl_path) as f: + assert f.read() == "test content from host" + + os.unlink(local_path) + os.unlink(dl_path) + asyncio.run(compute.shutdown(info.instance_id)) + + def test_pip_install(self): + from praisonai.integrations.compute.tenki import TenkiCompute + from praisonaiagents.managed.protocols import ComputeConfig + + compute = TenkiCompute() + config = ComputeConfig( + packages={"pip": ["requests"]}, + idle_timeout_s=120, + ) + info = asyncio.run(compute.provision(config)) + result = asyncio.run(compute.execute( + info.instance_id, + "python3 -c 'import requests; print(requests.__version__)'", + )) + assert result["exit_code"] == 0 + assert result["stdout"].strip() + + asyncio.run(compute.shutdown(info.instance_id)) + + class TestDockerComputeIntegration: @pytest.fixture(autouse=True) def skip_without_docker(self): diff --git a/src/praisonai/praisonai/integrations/compute/__init__.py b/src/praisonai/praisonai/integrations/compute/__init__.py index aba23db51..fd44a0bb6 100644 --- a/src/praisonai/praisonai/integrations/compute/__init__.py +++ b/src/praisonai/praisonai/integrations/compute/__init__.py @@ -12,6 +12,7 @@ "E2BCompute", "ModalCompute", "FlyioCompute", + "TenkiCompute", ] @@ -34,4 +35,7 @@ def __getattr__(name): if name == "FlyioCompute": from .flyio import FlyioCompute return FlyioCompute + if name == "TenkiCompute": + from .tenki import TenkiCompute + return TenkiCompute raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/praisonai/praisonai/integrations/compute/tenki.py b/src/praisonai/praisonai/integrations/compute/tenki.py new file mode 100644 index 000000000..a2e698f10 --- /dev/null +++ b/src/praisonai/praisonai/integrations/compute/tenki.py @@ -0,0 +1,384 @@ +""" +Tenki Compute Provider — cloud sandbox-based compute for managed agents. + +Uses the Tenki Cloud SDK to run tools in disposable Linux microVMs. + +Requires: ``pip install tenki-sandbox`` +Environment: ``TENKI_API_KEY`` (optionally ``TENKI_WORKSPACE_ID``, ``TENKI_PROJECT_ID``) +""" + +import base64 +import dataclasses +import logging +import os +import shlex +import time +import uuid +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + + +class TenkiCompute: + """Tenki Cloud microVM compute provider. + + Satisfies ``ComputeProviderProtocol`` (Core SDK). Uses only stable Tenki + features (ephemeral exec + file I/O over base64) so it works on the default + image without volumes, snapshots or templates. + + Example:: + + from praisonaiagents.managed import ComputeConfig + from praisonai.integrations.compute.tenki import TenkiCompute + + compute = TenkiCompute() + config = ComputeConfig( + packages={"pip": ["pandas"]}, # installed on Tenki's stock image + ) + info = await compute.provision(config) + result = await compute.execute(info.instance_id, "python -c 'print(1+1)'") + await compute.shutdown(info.instance_id) + """ + + def __init__( + self, + api_key: str = "", + workspace_id: str = "", + project_id: str = "", + ) -> None: + self._api_key = api_key or os.environ.get("TENKI_API_KEY", "") + self._workspace_id = workspace_id or os.environ.get("TENKI_WORKSPACE_ID", "") + self._project_id = project_id or os.environ.get("TENKI_PROJECT_ID", "") + self._client = None + self._sandboxes: Dict[str, Dict[str, Any]] = {} + + def _get_client(self): + if self._client is None: + try: + from tenki_sandbox import Client + except ImportError as e: + raise ImportError( + "Tenki SDK required. Install with: pip install tenki-sandbox" + ) from e + # Falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN in the environment. + self._client = Client(auth_token=self._api_key) if self._api_key else Client() + return self._client + + def _resolve_ids(self, client) -> tuple: + """Resolve the workspace/project to create sandboxes under. + + The SDK has no "current project" default like the CLI, so fall back to + the first workspace/project on the account. + """ + if self._workspace_id and self._project_id: + return self._workspace_id, self._project_id + identity = client.who_am_i() + workspaces = identity.workspaces + if not workspaces: + raise RuntimeError("No Tenki workspaces available for this API key") + if self._workspace_id: + ws = next((w for w in workspaces if w.id == self._workspace_id), None) + if ws is None: + raise RuntimeError( + f"Configured TENKI_WORKSPACE_ID '{self._workspace_id}' not found for this API key" + ) + else: + ws = workspaces[0] + if not ws.projects: + raise RuntimeError(f"No Tenki projects available in workspace {ws.id}") + if self._project_id: + proj = next((p for p in ws.projects if p.id == self._project_id), None) + if proj is None: + raise RuntimeError( + f"Configured TENKI_PROJECT_ID '{self._project_id}' not found in workspace {ws.id}" + ) + else: + proj = ws.projects[0] + self._workspace_id, self._project_id = ws.id, proj.id + return ws.id, proj.id + + @staticmethod + def _sandbox_id(sandbox) -> str: + value = getattr(sandbox, "id", None) + return value() if callable(value) else (value or "") + + @property + def provider_name(self) -> str: + return "tenki" + + @property + def is_available(self) -> bool: + return bool(self._api_key) + + async def provision(self, config) -> Any: + import asyncio + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._provision_sync, config) + + def _provision_sync(self, config) -> Any: + from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus + + client = self._get_client() + workspace_id, project_id = self._resolve_ids(client) + instance_id = f"tenki_{uuid.uuid4().hex[:12]}" + + create_kwargs: Dict[str, Any] = { + "name": instance_id, + "workspace_id": workspace_id, + "project_id": project_id, + "cpu_cores": config.cpu, + "memory_mb": config.memory_mb, + "env": config.env or None, + # Outbound is on unless the caller restricts networking. + "allow_outbound": (config.networking or {}).get("type") != "restricted", + } + # metadata["tenki_image"] wins; otherwise honour an explicit + # ComputeConfig.image. Its default is a Docker-style ref, but Tenki's + # registry is a snapshot store (no Docker pull-through), so an unchanged + # default means "use Tenki's stock image." Read that default off the + # dataclass field rather than hardcoding it, so this can't silently + # drift if ComputeConfig's default ever changes. + image = (config.metadata or {}).get("tenki_image") + if not image: + configured = getattr(config, "image", None) + default_image = next( + (f.default for f in dataclasses.fields(config) if f.name == "image"), + None, + ) if dataclasses.is_dataclass(config) else None + if configured and configured != default_image: + image = configured + if image: + create_kwargs["image"] = image + if config.auto_shutdown: + create_kwargs["idle_timeout_minutes"] = max(config.idle_timeout_s // 60, 1) + create_kwargs = {k: v for k, v in create_kwargs.items() if v is not None} + + sandbox = client.create(**create_kwargs) + sandbox_id = self._sandbox_id(sandbox) + + self._sandboxes[instance_id] = { + "sandbox": sandbox, + "sandbox_id": sandbox_id, + "config": config, + "created_at": time.time(), + } + + if config.packages: + try: + self._install_packages_sync(sandbox, config.packages) + except Exception: + # A half-provisioned sandbox is useless and still bills — tear it + # down and surface the failure instead of returning RUNNING. + self._sandboxes.pop(instance_id, None) + try: + sandbox.terminate() + except Exception as cleanup_err: + logger.warning("[tenki_compute] cleanup after failed install: %s", cleanup_err) + raise + + logger.info("[tenki_compute] provisioned: %s sandbox=%s", instance_id, sandbox_id) + + return InstanceInfo( + instance_id=instance_id, + status=InstanceStatus.RUNNING, + endpoint=f"tenki://{sandbox_id}", + provider="tenki", + created_at=time.time(), + ) + + async def shutdown(self, instance_id: str) -> None: + import asyncio + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._shutdown_sync, instance_id) + + def _shutdown_sync(self, instance_id: str) -> None: + info = self._sandboxes.get(instance_id) + if not info: + return + # Terminate BEFORE dropping the handle: if this raises (transient network / + # service error), the sandbox stays tracked so get_status still reports it + # and a later shutdown can retry, rather than silently leaking the microVM. + info["sandbox"].terminate() + self._sandboxes.pop(instance_id, None) + logger.info("[tenki_compute] shutdown: %s", instance_id) + + @staticmethod + def _is_running(info: Dict[str, Any]) -> bool: + """Reconcile against live Tenki state instead of trusting the local map. + + Tenki can terminate a sandbox server-side (e.g. the configured idle + timeout); without a refresh, get_status/list_instances would keep + reporting RUNNING for a dead sandbox while execute() hits it and fails. + Mirrors the E2B provider's is_running() check. On a transient fetch + failure, report not-running rather than a false RUNNING. + """ + try: + info["sandbox"].refresh() + return info["sandbox"].state == "RUNNING" + except Exception: + return False + + async def get_status(self, instance_id: str) -> Any: + import asyncio + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._get_status_sync, instance_id) + + def _get_status_sync(self, instance_id: str) -> Any: + from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus + + info = self._sandboxes.get(instance_id) + if not info: + return InstanceInfo( + instance_id=instance_id, + status=InstanceStatus.STOPPED, + provider="tenki", + ) + running = self._is_running(info) + return InstanceInfo( + instance_id=instance_id, + status=InstanceStatus.RUNNING if running else InstanceStatus.STOPPED, + endpoint=f"tenki://{info['sandbox_id']}", + provider="tenki", + created_at=info.get("created_at", 0), + ) + + async def execute( + self, + instance_id: str, + command: str, + timeout: int = 300, + ) -> Dict[str, Any]: + import asyncio + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, self._execute_sync, instance_id, command, timeout, + ) + + def _execute_sync( + self, instance_id: str, command: str, timeout: int, + ) -> Dict[str, Any]: + info = self._sandboxes.get(instance_id) + if not info: + return {"stdout": "", "stderr": "Instance not found", "exit_code": -1} + + sandbox = info["sandbox"] + try: + result = sandbox.exec("bash", "-lc", command, timeout=timeout) + return { + "stdout": (result.stdout or b"").decode(errors="replace"), + "stderr": (result.stderr or b"").decode(errors="replace"), + "exit_code": result.exit_code, + } + except Exception as e: + return {"stdout": "", "stderr": str(e), "exit_code": -1} + + async def upload_file( + self, instance_id: str, local_path: str, remote_path: str, + ) -> bool: + import asyncio + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, self._upload_sync, instance_id, local_path, remote_path, + ) + + def _upload_sync(self, instance_id: str, local_path: str, remote_path: str) -> bool: + info = self._sandboxes.get(instance_id) + if not info: + return False + try: + with open(local_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + path = shlex.quote(remote_path) + result = info["sandbox"].exec( + "bash", "-lc", f"mkdir -p \"$(dirname {path})\" && base64 -d > {path}", input=b64, timeout=120, + ) + return result.exit_code == 0 + except Exception as e: + logger.error("[tenki_compute] upload failed: %s", e) + return False + + async def download_file( + self, instance_id: str, remote_path: str, local_path: str, + ) -> bool: + import asyncio + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, self._download_sync, instance_id, remote_path, local_path, + ) + + def _download_sync(self, instance_id: str, remote_path: str, local_path: str) -> bool: + info = self._sandboxes.get(instance_id) + if not info: + return False + try: + path = shlex.quote(remote_path) + result = info["sandbox"].exec("bash", "-lc", f"base64 -w0 {path}", timeout=120) + if result.exit_code != 0: + logger.error("[tenki_compute] download failed: %s", (result.stderr or b"").decode(errors="replace")) + return False + data = base64.b64decode((result.stdout or b"").strip()) + with open(local_path, "wb") as f: + f.write(data) + return True + except Exception as e: + logger.error("[tenki_compute] download failed: %s", e) + return False + + async def list_instances(self) -> List[Any]: + import asyncio + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._list_instances_sync) + + def _list_instances_sync(self) -> List[Any]: + from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus + + result = [] + for iid, info in self._sandboxes.items(): + # Only surface sandboxes that are actually alive remotely, matching + # the E2B provider (a server-side idle timeout can kill one without + # our local map knowing). + if not self._is_running(info): + continue + result.append(InstanceInfo( + instance_id=iid, + status=InstanceStatus.RUNNING, + endpoint=f"tenki://{info['sandbox_id']}", + provider="tenki", + created_at=info.get("created_at", 0), + )) + return result + + def _install_packages_sync(self, sandbox, packages: Dict[str, list]) -> None: + # Package specs are shlex-quoted before hitting `bash -lc` — a spec must + # never be able to inject shell commands into the sandbox. Failures raise + # so provision() can tear the sandbox down rather than report it ready. + pip_pkgs = packages.get("pip", []) + if pip_pkgs: + specs = " ".join(shlex.quote(p) for p in pip_pkgs) + # The default Tenki image ships python3 but not pip; bootstrap it. + cmd = ( + "if ! command -v pip3 >/dev/null 2>&1; then " + "sudo apt-get update -y && sudo apt-get install -y python3-pip; fi && " + f"pip3 install -q --break-system-packages {specs}" + ) + logger.info("[tenki_compute] installing pip: %s", pip_pkgs) + result = sandbox.exec("bash", "-lc", cmd, timeout=300) + if result.exit_code != 0: + raise RuntimeError( + f"pip install failed: {(result.stderr or b'').decode(errors='replace')}" + ) + + npm_pkgs = packages.get("npm", []) + if npm_pkgs: + specs = " ".join(shlex.quote(p) for p in npm_pkgs) + cmd = ( + "if ! command -v npm >/dev/null 2>&1; then " + "sudo apt-get update -y && sudo apt-get install -y npm; fi && " + f"npm install -g {specs}" + ) + logger.info("[tenki_compute] installing npm: %s", npm_pkgs) + result = sandbox.exec("bash", "-lc", cmd, timeout=300) + if result.exit_code != 0: + raise RuntimeError( + f"npm install failed: {(result.stderr or b'').decode(errors='replace')}" + ) diff --git a/src/praisonai/praisonai/integrations/hosted_agent.py b/src/praisonai/praisonai/integrations/hosted_agent.py index 4ec0e9e70..a67073bfb 100644 --- a/src/praisonai/praisonai/integrations/hosted_agent.py +++ b/src/praisonai/praisonai/integrations/hosted_agent.py @@ -117,7 +117,7 @@ def _unavailable_provider_message(provider: str) -> str: from .backend_registry import get_backend_registry _llm_hints = {"openai", "gemini", "ollama", "local"} - _compute_hints = {"e2b", "modal", "flyio", "daytona", "docker"} + _compute_hints = {"e2b", "modal", "flyio", "daytona", "docker", "tenki"} if provider in _llm_hints: hint = ( diff --git a/src/praisonai/praisonai/integrations/managed_agents.py b/src/praisonai/praisonai/integrations/managed_agents.py index 594da57df..b0c1ba8fe 100644 --- a/src/praisonai/praisonai/integrations/managed_agents.py +++ b/src/praisonai/praisonai/integrations/managed_agents.py @@ -1111,7 +1111,7 @@ def ManagedAgent( return AnthropicManagedAgent(provider=provider, **kwargs) # Compute provider names - maintain backward compatibility by passing to LocalManagedAgent - elif provider in {"e2b", "modal", "flyio", "daytona", "docker"}: + elif provider in {"e2b", "modal", "flyio", "daytona", "docker", "tenki"}: warnings.warn( f"ManagedAgent(provider='{provider}') for compute providers is deprecated. " f"Use LocalAgent(compute='{provider}', config=LocalAgentConfig(...)) instead.", diff --git a/src/praisonai/praisonai/integrations/managed_local.py b/src/praisonai/praisonai/integrations/managed_local.py index d7ba03b48..cdf1d336c 100644 --- a/src/praisonai/praisonai/integrations/managed_local.py +++ b/src/praisonai/praisonai/integrations/managed_local.py @@ -1032,7 +1032,7 @@ def _resolve_compute(compute: Optional[Any]) -> Optional[Any]: Accepts: - None → no remote compute - A string: ``"local"``, ``"docker"``, ``"e2b"``, ``"modal"``, - ``"daytona"``, ``"flyio"`` + ``"daytona"``, ``"flyio"``, ``"tenki"`` - An already-instantiated compute provider object """ if compute is None: @@ -1057,6 +1057,9 @@ def _resolve_compute(compute: Optional[Any]) -> Optional[Any]: elif name == "flyio": from praisonai.integrations.compute.flyio import FlyioCompute return FlyioCompute() + elif name == "tenki": + from praisonai.integrations.compute.tenki import TenkiCompute + return TenkiCompute() else: raise ValueError(f"Unknown compute provider: {name}") return compute # Already an instance diff --git a/src/praisonai/pyproject.toml b/src/praisonai/pyproject.toml index 2990d13c3..f8b8486a3 100644 --- a/src/praisonai/pyproject.toml +++ b/src/praisonai/pyproject.toml @@ -90,6 +90,9 @@ daytona = [ # Note: daytona package not yet available on PyPI # Install via: pip install git+https://github.com/daytonaio/daytona-python ] +tenki = [ + "tenki-sandbox>=0.4.0", +] realtime = [ "aiui>=0.3.121,<0.4", "tavily-python==0.5.0",