From 24221e5f91aac6d4e3fcfefb208cffb12f7fecea Mon Sep 17 00:00:00 2001 From: Rishi Joshi Date: Thu, 16 Jul 2026 15:34:46 +0530 Subject: [PATCH 1/6] feat: add Tenki Cloud compute provider Add Tenki Cloud (https://tenki.cloud) as a compute provider for managed agents, alongside E2B, Daytona, Modal, Fly.io, Docker and local. - TenkiCompute implements ComputeProviderProtocol (provision / execute / shutdown / get_status / upload_file / download_file / list_instances), running tools in disposable Tenki microVMs. Sync SDK wrapped via run_in_executor, matching the existing providers. - Registered as "tenki" in the compute barrel, the _resolve_compute factory, and the compute-provider hint sets. - Uses only stable Tenki features (exec + file I/O). Default stock image installs pip packages on demand; set metadata["tenki_image"] for a custom image. Auto-resolves workspace/project from the API key. - Enabled via TENKI_API_KEY; optional `tenki` extra (tenki-sandbox). - Unit + live (skipped-by-default) tests mirroring the E2B/Daytona suites. --- .../tests/managed/test_cloud_compute.py | 125 +++++++ .../integrations/compute/__init__.py | 4 + .../praisonai/integrations/compute/tenki.py | 312 ++++++++++++++++++ .../praisonai/integrations/hosted_agent.py | 2 +- .../praisonai/integrations/managed_agents.py | 2 +- .../praisonai/integrations/managed_local.py | 5 +- src/praisonai/pyproject.toml | 3 + 7 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 src/praisonai/praisonai/integrations/compute/tenki.py 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..6780580e6 --- /dev/null +++ b/src/praisonai/praisonai/integrations/compute/tenki.py @@ -0,0 +1,312 @@ +""" +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 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( + image="python:3.12-slim", + packages={"pip": ["pandas"]}, + ) + 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: + raise ImportError( + "Tenki SDK required. Install with: pip install tenki-sandbox" + ) + # 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") + ws = next((w for w in workspaces if w.id == self._workspace_id), workspaces[0]) if self._workspace_id else workspaces[0] + if not ws.projects: + raise RuntimeError(f"No Tenki projects available in workspace {ws.id}") + proj = next((p for p in ws.projects if p.id == self._project_id), ws.projects[0]) if self._project_id else 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, + # Tools generally need outbound network for package installs / APIs. + "allow_outbound": True, + } + # Custom images map to a Tenki registry image / template; default stock + # image otherwise (keeps the provider on stable features). + image = (config.metadata or {}).get("tenki_image") + 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: + self._install_packages_sync(sandbox, config.packages) + + 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.pop(instance_id, None) + if info: + try: + info["sandbox"].terminate() + except Exception as e: + logger.warning("[tenki_compute] shutdown error: %s", e) + logger.info("[tenki_compute] shutdown: %s", instance_id) + + async def get_status(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", + ) + return InstanceInfo( + instance_id=instance_id, + status=InstanceStatus.RUNNING, + 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]: + from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus + + result = [] + for iid, info in self._sandboxes.items(): + 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: + pip_pkgs = packages.get("pip", []) + if 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 {' '.join(pip_pkgs)}" + ) + logger.info("[tenki_compute] installing pip: %s", pip_pkgs) + try: + result = sandbox.exec("bash", "-lc", cmd, timeout=300) + if result.exit_code != 0: + logger.warning( + "[tenki_compute] pip install failed: %s", + (result.stderr or b"").decode(errors="replace"), + ) + except Exception as e: + logger.warning("[tenki_compute] pip install error: %s", e) + + npm_pkgs = packages.get("npm", []) + if 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 {' '.join(npm_pkgs)}" + ) + logger.info("[tenki_compute] installing npm: %s", npm_pkgs) + try: + sandbox.exec("bash", "-lc", cmd, timeout=300) + except Exception as e: + logger.warning("[tenki_compute] npm install error: %s", e) 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..cbf0cccd1 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.3", +] realtime = [ "aiui>=0.3.121,<0.4", "tavily-python==0.5.0", From 93546029ebd1855a6695d22858a02de03e518288 Mon Sep 17 00:00:00 2001 From: Rishi Joshi Date: Thu, 16 Jul 2026 19:29:51 +0530 Subject: [PATCH 2/6] chore(tenki): require tenki-sandbox>=0.3.6 per review --- src/praisonai/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/praisonai/pyproject.toml b/src/praisonai/pyproject.toml index cbf0cccd1..0d1cf19bb 100644 --- a/src/praisonai/pyproject.toml +++ b/src/praisonai/pyproject.toml @@ -91,7 +91,7 @@ daytona = [ # Install via: pip install git+https://github.com/daytonaio/daytona-python ] tenki = [ - "tenki-sandbox>=0.3", + "tenki-sandbox>=0.3.6", ] realtime = [ "aiui>=0.3.121,<0.4", From 68c8ebf95c5300c910e858f7afea7766b8253788 Mon Sep 17 00:00:00 2001 From: Rishi Joshi Date: Mon, 20 Jul 2026 13:00:10 -0700 Subject: [PATCH 3/6] chore(tenki): bump tenki-sandbox to >=0.4.0 (latest SDK release) --- src/praisonai/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/praisonai/pyproject.toml b/src/praisonai/pyproject.toml index 0d1cf19bb..f8b8486a3 100644 --- a/src/praisonai/pyproject.toml +++ b/src/praisonai/pyproject.toml @@ -91,7 +91,7 @@ daytona = [ # Install via: pip install git+https://github.com/daytonaio/daytona-python ] tenki = [ - "tenki-sandbox>=0.3.6", + "tenki-sandbox>=0.4.0", ] realtime = [ "aiui>=0.3.121,<0.4", From 39c314efdaaea0e04d5258ce74590ce272801658 Mon Sep 17 00:00:00 2001 From: Rishi Joshi Date: Mon, 20 Jul 2026 13:21:27 -0700 Subject: [PATCH 4/6] fix(tenki): address automated review findings (validation, safety, cleanup) The maintainer's review bot flagged these but couldn't push to a fork, so applying directly: - Raise on a configured-but-unknown TENKI_WORKSPACE_ID/PROJECT_ID instead of silently using the first workspace/project (wrong-workspace billing). - shlex.quote every pip/npm spec (command-injection hardening). - Honour a non-default ComputeConfig.image, not just metadata["tenki_image"]. - Respect ComputeConfig.networking (restricted -> allow_outbound=False). - Fail provisioning and tear down the sandbox when package install fails (no false RUNNING / leaked sandbox). - Terminate before dropping the handle in shutdown, so a failed terminate keeps the sandbox tracked for retry instead of silently leaking it. --- .../praisonai/integrations/compute/tenki.py | 88 +++++++++++++------ 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/src/praisonai/praisonai/integrations/compute/tenki.py b/src/praisonai/praisonai/integrations/compute/tenki.py index 6780580e6..8aa347347 100644 --- a/src/praisonai/praisonai/integrations/compute/tenki.py +++ b/src/praisonai/praisonai/integrations/compute/tenki.py @@ -76,10 +76,24 @@ def _resolve_ids(self, client) -> tuple: workspaces = identity.workspaces if not workspaces: raise RuntimeError("No Tenki workspaces available for this API key") - ws = next((w for w in workspaces if w.id == self._workspace_id), workspaces[0]) if self._workspace_id else workspaces[0] + 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}") - proj = next((p for p in ws.projects if p.id == self._project_id), ws.projects[0]) if self._project_id else ws.projects[0] + 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 @@ -115,12 +129,14 @@ def _provision_sync(self, config) -> Any: "cpu_cores": config.cpu, "memory_mb": config.memory_mb, "env": config.env or None, - # Tools generally need outbound network for package installs / APIs. - "allow_outbound": True, + # Outbound is on unless the caller restricts networking. + "allow_outbound": (config.networking or {}).get("type") != "restricted", } - # Custom images map to a Tenki registry image / template; default stock - # image otherwise (keeps the provider on stable features). + # Prefer an explicit Tenki registry image via metadata; otherwise honour a + # non-default ComputeConfig.image; else fall back to Tenki's stock image. image = (config.metadata or {}).get("tenki_image") + if not image and getattr(config, "image", None) and config.image != "python:3.12-slim": + image = config.image if image: create_kwargs["image"] = image if config.auto_shutdown: @@ -138,7 +154,17 @@ def _provision_sync(self, config) -> Any: } if config.packages: - self._install_packages_sync(sandbox, 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) @@ -156,13 +182,15 @@ async def shutdown(self, instance_id: str) -> None: await loop.run_in_executor(None, self._shutdown_sync, instance_id) def _shutdown_sync(self, instance_id: str) -> None: - info = self._sandboxes.pop(instance_id, None) - if info: - try: - info["sandbox"].terminate() - except Exception as e: - logger.warning("[tenki_compute] shutdown error: %s", e) - logger.info("[tenki_compute] shutdown: %s", instance_id) + 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) async def get_status(self, instance_id: str) -> Any: from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus @@ -279,34 +307,36 @@ async def list_instances(self) -> List[Any]: 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 {' '.join(pip_pkgs)}" + f"pip3 install -q --break-system-packages {specs}" ) logger.info("[tenki_compute] installing pip: %s", pip_pkgs) - try: - result = sandbox.exec("bash", "-lc", cmd, timeout=300) - if result.exit_code != 0: - logger.warning( - "[tenki_compute] pip install failed: %s", - (result.stderr or b"").decode(errors="replace"), - ) - except Exception as e: - logger.warning("[tenki_compute] pip install error: %s", e) + 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 {' '.join(npm_pkgs)}" + f"npm install -g {specs}" ) logger.info("[tenki_compute] installing npm: %s", npm_pkgs) - try: - sandbox.exec("bash", "-lc", cmd, timeout=300) - except Exception as e: - logger.warning("[tenki_compute] npm install error: %s", e) + 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')}" + ) From bfa0a32f0b8c8b5c34d95d3d7f9d7d0bf75e8b61 Mon Sep 17 00:00:00 2001 From: Rishi Joshi Date: Wed, 29 Jul 2026 08:42:51 -0700 Subject: [PATCH 5/6] fix(tenki): reconcile live sandbox state + address review nits - get_status/list_instances now refresh remote Tenki state (sandbox.refresh + .state) instead of trusting the local map, so a server-side idle timeout no longer surfaces as RUNNING while execute() hits a dead sandbox. Mirrors the E2B provider's is_running() reconciliation. - add exception chaining on the SDK ImportError (raise ... from e) - drop the misleading docstring image example (it passed the default sentinel that is intentionally treated as the stock image) --- .../praisonai/integrations/compute/tenki.py | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/praisonai/praisonai/integrations/compute/tenki.py b/src/praisonai/praisonai/integrations/compute/tenki.py index 8aa347347..f09ce0c13 100644 --- a/src/praisonai/praisonai/integrations/compute/tenki.py +++ b/src/praisonai/praisonai/integrations/compute/tenki.py @@ -32,8 +32,7 @@ class TenkiCompute: compute = TenkiCompute() config = ComputeConfig( - image="python:3.12-slim", - packages={"pip": ["pandas"]}, + 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)'") @@ -56,10 +55,10 @@ def _get_client(self): if self._client is None: try: from tenki_sandbox import Client - except ImportError: + 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 @@ -192,7 +191,28 @@ def _shutdown_sync(self, instance_id: str) -> None: 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) @@ -202,9 +222,10 @@ async def get_status(self, instance_id: str) -> Any: status=InstanceStatus.STOPPED, provider="tenki", ) + running = self._is_running(info) return InstanceInfo( instance_id=instance_id, - status=InstanceStatus.RUNNING, + status=InstanceStatus.RUNNING if running else InstanceStatus.STOPPED, endpoint=f"tenki://{info['sandbox_id']}", provider="tenki", created_at=info.get("created_at", 0), @@ -293,10 +314,20 @@ def _download_sync(self, instance_id: str, remote_path: str, local_path: str) -> 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, From 44631dc650a20a00452633055b8e72c2a5a71c24 Mon Sep 17 00:00:00 2001 From: Rishi Joshi Date: Wed, 29 Jul 2026 09:40:13 -0700 Subject: [PATCH 6/6] fix(tenki): derive image default from ComputeConfig field, not a literal Reads the 'unchanged default = use stock image' sentinel off ComputeConfig's dataclass field default instead of hardcoding "python:3.12-slim", so the image-selection logic can't silently drift if that default changes (CodeRabbit out-of-diff nit). --- .../praisonai/integrations/compute/tenki.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/praisonai/praisonai/integrations/compute/tenki.py b/src/praisonai/praisonai/integrations/compute/tenki.py index f09ce0c13..a2e698f10 100644 --- a/src/praisonai/praisonai/integrations/compute/tenki.py +++ b/src/praisonai/praisonai/integrations/compute/tenki.py @@ -8,6 +8,7 @@ """ import base64 +import dataclasses import logging import os import shlex @@ -131,11 +132,21 @@ def _provision_sync(self, config) -> Any: # Outbound is on unless the caller restricts networking. "allow_outbound": (config.networking or {}).get("type") != "restricted", } - # Prefer an explicit Tenki registry image via metadata; otherwise honour a - # non-default ComputeConfig.image; else fall back to Tenki's stock image. + # 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 and getattr(config, "image", None) and config.image != "python:3.12-slim": - image = config.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: