mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 12:47:55 -10:00
Merge pull request #3242 from rishijoshi/feat/tenki-compute-provider
feat: add Tenki Cloud compute provider
This commit is contained in:
7 files changed
+529
-3
No files matched your search
@@ -121,6 +121,48 @@ class TestDaytonaComputeUnit:
|
||||
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)
|
||||
old_tok = os.environ.pop("TENKI_AUTH_TOKEN", None)
|
||||
try:
|
||||
compute = TenkiCompute(api_key="")
|
||||
assert compute.is_available is False
|
||||
finally:
|
||||
if old:
|
||||
os.environ["TENKI_API_KEY"] = old
|
||||
if old_tok:
|
||||
os.environ["TENKI_AUTH_TOKEN"] = old_tok
|
||||
|
||||
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 +172,7 @@ class TestComputeExports:
|
||||
E2BCompute,
|
||||
ModalCompute,
|
||||
FlyioCompute,
|
||||
TenkiCompute,
|
||||
)
|
||||
assert DockerCompute is not None
|
||||
assert LocalCompute is not None
|
||||
@@ -137,6 +180,7 @@ class TestComputeExports:
|
||||
assert E2BCompute is not None
|
||||
assert ModalCompute is not None
|
||||
assert FlyioCompute is not None
|
||||
assert TenkiCompute is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -260,6 +304,90 @@ class TestModalComputeIntegration:
|
||||
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") or os.environ.get("TENKI_AUTH_TOKEN")):
|
||||
pytest.skip("TENKI_API_KEY or TENKI_AUTH_TOKEN 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):
|
||||
|
||||
@@ -12,6 +12,7 @@ __all__ = [
|
||||
"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}")
|
||||
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
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``
|
||||
Environment: ``TENKI_API_KEY`` or ``TENKI_AUTH_TOKEN`` (optionally ``TENKI_WORKSPACE_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 = "",
|
||||
) -> None:
|
||||
# Match the SDK's own precedence: explicit key, then TENKI_AUTH_TOKEN,
|
||||
# then TENKI_API_KEY — so is_available agrees with what Client() resolves.
|
||||
self._api_key = (
|
||||
api_key
|
||||
or os.environ.get("TENKI_AUTH_TOKEN", "")
|
||||
or os.environ.get("TENKI_API_KEY", "")
|
||||
)
|
||||
self._workspace_id = workspace_id or os.environ.get("TENKI_WORKSPACE_ID", "")
|
||||
self._client = None
|
||||
self._sandboxes: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
try:
|
||||
from tenki import Client
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Tenki SDK required. Install with: pip install tenki"
|
||||
) 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_workspace(self, client) -> str:
|
||||
"""Resolve the workspace to create sandboxes under.
|
||||
|
||||
The SDK has no "current workspace" default like the CLI. An explicitly
|
||||
configured TENKI_WORKSPACE_ID is trusted (Tenki validates it at
|
||||
create()); otherwise fall back to the first workspace on the account.
|
||||
"""
|
||||
if self._workspace_id:
|
||||
return self._workspace_id
|
||||
identity = client.who_am_i()
|
||||
workspaces = identity.workspaces
|
||||
if not workspaces:
|
||||
raise RuntimeError("No Tenki workspaces available for this API key")
|
||||
self._workspace_id = workspaces[0].id
|
||||
return self._workspace_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 = self._resolve_workspace(client)
|
||||
instance_id = f"tenki_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
create_kwargs: Dict[str, Any] = {
|
||||
"name": instance_id,
|
||||
"workspace_id": workspace_id,
|
||||
"cpu_cores": config.cpu,
|
||||
"memory_mb": config.memory_mb,
|
||||
"env": config.env or None,
|
||||
# Tenki's outbound control is a single boolean and can't express
|
||||
# "limited" per-host allowlists, so only "unrestricted" gets full
|
||||
# outbound; any restrictive policy ("limited", which the managed CLI
|
||||
# also uses for --no-networking) disables it.
|
||||
"allow_outbound": (config.networking or {}).get("type", "unrestricted") == "unrestricted",
|
||||
}
|
||||
# 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.
|
||||
# Terminate BEFORE dropping the handle so a failed terminate keeps
|
||||
# it tracked (for list_instances / retry) rather than leaking it.
|
||||
try:
|
||||
sandbox.terminate()
|
||||
self._sandboxes.pop(instance_id, None)
|
||||
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.
|
||||
|
||||
A *successful* refresh returning a non-RUNNING state means it really is
|
||||
gone. A refresh *exception* (transient outage / rate limit / auth blip)
|
||||
is "unknown", not "terminated" — assume still running so we don't hide a
|
||||
sandbox that may be alive and still billing.
|
||||
"""
|
||||
try:
|
||||
info["sandbox"].refresh()
|
||||
except Exception:
|
||||
return True
|
||||
return info["sandbox"].state == "RUNNING"
|
||||
|
||||
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}"
|
||||
)
|
||||
# Log only the count — pip specs can carry private-index URLs/tokens.
|
||||
logger.info("[tenki_compute] installing %d pip package(s)", len(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 %d npm package(s)", len(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')}"
|
||||
)
|
||||
@@ -117,7 +117,7 @@ class HostedAgent(AnthropicManagedAgent):
|
||||
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 = (
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1042,7 +1042,7 @@ class LocalManagedAgent:
|
||||
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:
|
||||
@@ -1067,6 +1067,9 @@ class LocalManagedAgent:
|
||||
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
|
||||
@@ -1113,6 +1116,15 @@ class LocalManagedAgent:
|
||||
"working_dir",
|
||||
self._cfg.get("working_dir") or _default("working_dir", "/workspace"),
|
||||
),
|
||||
# Forward the networking policy and provider metadata so providers
|
||||
# that honour them (e.g. Tenki's allow_outbound / tenki_image) see
|
||||
# the caller's request instead of always getting the defaults. Copy
|
||||
# metadata — it's mutated below with env_cfg.metadata.
|
||||
networking=kwargs.get(
|
||||
"networking",
|
||||
self._cfg.get("networking") or _default("networking", {"type": "unrestricted"}),
|
||||
),
|
||||
metadata=kwargs.get("metadata", dict(self._cfg.get("metadata") or {})),
|
||||
auto_shutdown=kwargs.get("auto_shutdown", True),
|
||||
idle_timeout_s=kwargs.get("idle_timeout_s", 300),
|
||||
)
|
||||
|
||||
@@ -95,6 +95,9 @@ daytona = [
|
||||
# Note: daytona package not yet available on PyPI
|
||||
# Install via: pip install git+https://github.com/daytonaio/daytona-python
|
||||
]
|
||||
tenki = [
|
||||
"tenki>=0.5.4",
|
||||
]
|
||||
realtime = [
|
||||
"aiui>=0.3.121,<0.4",
|
||||
"tavily-python==0.5.0",
|
||||
|
||||
Reference in new issue
Block a user