mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a3b25753 | ||
|
|
e34ec29e94 | ||
|
|
ab2f6bf774 | ||
|
|
c383794742 | ||
|
|
1f8bbc7e9a | ||
|
|
8fb9fb1ddc |
No files matched your search
@@ -79,7 +79,23 @@ def say_place(name: Any, *, via: str = "sandbox") -> str:
|
||||
name = getattr(name, "provider_name", None) or type(name).__name__
|
||||
key = str(name).lower()
|
||||
key = _ALIASES.get(key, key)
|
||||
return _PLACE_WORDS.get(key, str(name))
|
||||
|
||||
known = _PLACE_WORDS.get(key)
|
||||
if known:
|
||||
return known
|
||||
|
||||
# A place contributed by another package can name itself; the bare name is
|
||||
# the last resort rather than the only option, because the phrase table is
|
||||
# a literal that cannot know about packages installed later.
|
||||
try:
|
||||
from ..managed._compute_bridge import contributed_display_name
|
||||
|
||||
declared = contributed_display_name(key)
|
||||
if declared:
|
||||
return declared
|
||||
except Exception:
|
||||
pass
|
||||
return str(name)
|
||||
|
||||
|
||||
def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
|
||||
|
||||
@@ -41,9 +41,33 @@ from ._sandbox_adapter import NEEDS_INSTANCE, SANDBOX_ONLY # noqa: E402
|
||||
_ALIASES = {"native": "sandlock"}
|
||||
|
||||
|
||||
def _entry_point_providers() -> dict:
|
||||
"""Places contributed by other installed packages.
|
||||
|
||||
The sandbox and managed-backend registries already discover plugins this
|
||||
way (``praisonai.sandbox``, ``praisonai.managed_backends``); the compute
|
||||
registry was the one hardcoded dict, so adding a place meant editing core.
|
||||
A provider can now ship in its own distribution:
|
||||
|
||||
[project.entry-points."praisonai.compute"]
|
||||
runpod = "praisonai_compute_runpod:RunpodCompute"
|
||||
"""
|
||||
found = {}
|
||||
try:
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
for ep in entry_points(group="praisonai.compute"):
|
||||
found[ep.name.lower()] = ep
|
||||
except Exception: # pragma: no cover - discovery must never break resolution
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
def available_providers() -> list:
|
||||
"""Every place ``tools_run_on=`` / ``run_in=`` accepts, from both registries."""
|
||||
return sorted(set(_PROVIDERS) | set(SANDBOX_ONLY))
|
||||
"""Every place ``tools_run_on=`` / ``run_in=`` accepts, from all registries."""
|
||||
return sorted(
|
||||
set(_PROVIDERS) | set(SANDBOX_ONLY) | set(_entry_point_providers())
|
||||
)
|
||||
|
||||
|
||||
def compute_package_available() -> bool:
|
||||
@@ -92,6 +116,16 @@ def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
|
||||
|
||||
return SandboxComputeAdapter(name)
|
||||
|
||||
external = _entry_point_providers()
|
||||
if name in external and name not in _PROVIDERS:
|
||||
# Let a contributed place describe itself. Without this the repr falls
|
||||
# back to the bare name, because the phrase table is a literal that
|
||||
# cannot know about packages installed later.
|
||||
_remember_display_name(name, external[name])
|
||||
# A place contributed by another package. Loaded on demand, so an
|
||||
# installed-but-broken plugin cannot break anything that never names it.
|
||||
return external[name].load()()
|
||||
|
||||
if name not in _PROVIDERS:
|
||||
raise ValueError(
|
||||
f"Unknown place {name!r}. "
|
||||
@@ -106,3 +140,23 @@ def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
|
||||
raise ImportError(f"compute={name!r} requires {module_name}. {hint}") from exc
|
||||
|
||||
return getattr(module, attr)()
|
||||
|
||||
|
||||
#: Display phrases contributed by external providers, filled in on resolve.
|
||||
_DISPLAY_NAMES: dict = {}
|
||||
|
||||
|
||||
def _remember_display_name(name: str, entry_point) -> None:
|
||||
"""Record a contributed provider's own phrase, if it declares one."""
|
||||
try:
|
||||
cls = entry_point.load()
|
||||
phrase = getattr(cls, "display_name", None)
|
||||
if isinstance(phrase, str) and phrase:
|
||||
_DISPLAY_NAMES[name] = phrase
|
||||
except Exception: # pragma: no cover - naming must never break resolution
|
||||
pass
|
||||
|
||||
|
||||
def contributed_display_name(name: str):
|
||||
"""The phrase a contributed place declared for itself, if any."""
|
||||
return _DISPLAY_NAMES.get((name or "").lower())
|
||||
@@ -12,6 +12,7 @@ with the parameter name the user actually wanted.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
@@ -495,6 +496,13 @@ def _counting_provider(monkeypatch):
|
||||
# "subprocess" is a real place, so construction-time validation passes;
|
||||
# only the resolver underneath is swapped for the counting fake.
|
||||
monkeypatch.setattr(sc, "resolve_compute", lambda place: Counting(f"inst-{next(seq)}"))
|
||||
|
||||
# Run pending finalizers before the counting window opens. Agents from
|
||||
# earlier tests release their sandbox when collected, and that collection
|
||||
# can otherwise land mid-measurement and make an exact count flaky.
|
||||
gc.collect()
|
||||
provisioned.clear()
|
||||
shut.clear()
|
||||
return provisioned, shut
|
||||
|
||||
|
||||
@@ -601,3 +609,48 @@ def test_resolver_aliases_are_accepted_at_construction(alias, resolves_to):
|
||||
|
||||
agent = _agent(tools_run_on=alias)
|
||||
assert say_place(resolves_to) in repr(agent)
|
||||
|
||||
|
||||
# ── extending the registry from outside ──────────────────────────────────────
|
||||
def test_a_place_can_be_contributed_by_another_package(monkeypatch):
|
||||
"""Adding a compute provider used to mean editing core in several files.
|
||||
|
||||
The sandbox and managed-backend registries already discovered plugins by
|
||||
entry point; the compute registry was the one hardcoded dict. A provider
|
||||
can now ship in its own distribution and be selected by name.
|
||||
"""
|
||||
import praisonaiagents.managed._compute_bridge as bridge
|
||||
|
||||
class Contributed:
|
||||
provider_name = "contributed"
|
||||
|
||||
class FakeEntryPoint:
|
||||
name = "contributed"
|
||||
|
||||
def load(self):
|
||||
return Contributed
|
||||
|
||||
monkeypatch.setattr(bridge, "_entry_point_providers",
|
||||
lambda: {"contributed": FakeEntryPoint()})
|
||||
|
||||
assert "contributed" in bridge.available_providers()
|
||||
assert isinstance(bridge.resolve_compute("contributed"), Contributed)
|
||||
|
||||
|
||||
def test_a_broken_plugin_cannot_break_places_that_do_not_name_it(monkeypatch):
|
||||
"""Providers load on demand, so an installed-but-broken plugin only fails
|
||||
for the caller that actually asks for it."""
|
||||
import praisonaiagents.managed._compute_bridge as bridge
|
||||
|
||||
class Exploding:
|
||||
name = "exploding"
|
||||
|
||||
def load(self):
|
||||
raise ImportError("this plugin is broken")
|
||||
|
||||
monkeypatch.setattr(bridge, "_entry_point_providers",
|
||||
lambda: {"exploding": Exploding()})
|
||||
|
||||
assert bridge.resolve_compute("subprocess") is not None # unaffected
|
||||
with pytest.raises(ImportError):
|
||||
bridge.resolve_compute("exploding")
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Every provider, checked for the things that do not need a credential.
|
||||
|
||||
Most of what breaks an integration is checkable for free: the class resolves,
|
||||
it satisfies the protocol, its methods are actually coroutines, it reports
|
||||
availability without raising, both placement parameters accept or refuse it
|
||||
correctly, and — the one that actually bites people — the failure when a
|
||||
credential is missing names the key or the install command instead of throwing
|
||||
an AttributeError from four frames down.
|
||||
|
||||
Modal was the cautionary tale: its `run_in=` path was broken in five separate
|
||||
ways and nothing noticed, because nothing ever ran it. These tests are the
|
||||
cheap half of not repeating that. The expensive half is a CI job with real
|
||||
credentials.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.placement import managed_runtimes, tool_places
|
||||
from praisonaiagents.managed._compute_bridge import resolve_compute
|
||||
|
||||
#: Places resolvable without any vendor account.
|
||||
LOCAL_PLACES = ("subprocess", "local", "docker")
|
||||
|
||||
#: Every place except the two that cannot be resolved from a bare name:
|
||||
#: `ssh` needs a host object, `native` is an alias checked separately.
|
||||
ALL_PLACES = [p for p in tool_places() if p not in ("ssh", "native")]
|
||||
|
||||
#: Words that make a credential failure actionable.
|
||||
_ACTIONABLE = (
|
||||
"key", "token", "auth", "credential", "not available",
|
||||
"install", "config", "login", "unauthor", "api", "host", "daemon",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=ALL_PLACES)
|
||||
def place(request):
|
||||
return request.param
|
||||
|
||||
|
||||
def _agent(**kw):
|
||||
return Agent(name="p", instructions="x", **kw)
|
||||
|
||||
|
||||
# ── the registry ─────────────────────────────────────────────────────────────
|
||||
def test_every_place_resolves_to_a_provider(place):
|
||||
assert resolve_compute(place) is not None
|
||||
|
||||
|
||||
def test_every_provider_satisfies_the_protocol(place):
|
||||
"""Everything downstream assumes these three exist."""
|
||||
provider = resolve_compute(place)
|
||||
for method in ("provision", "execute", "shutdown"):
|
||||
assert hasattr(provider, method), f"{place} has no {method}()"
|
||||
|
||||
|
||||
def test_every_protocol_method_is_awaitable(place):
|
||||
"""A sync method here does not raise -- it returns a coroutine nobody
|
||||
awaits, so the call silently does nothing."""
|
||||
provider = resolve_compute(place)
|
||||
for method in ("provision", "execute", "shutdown"):
|
||||
fn = getattr(provider, method)
|
||||
assert inspect.iscoroutinefunction(fn), f"{place}.{method}() is not async"
|
||||
|
||||
|
||||
def test_availability_can_be_answered_without_raising(place):
|
||||
"""`is_available` is consulted before anything is provisioned, so it has to
|
||||
survive a machine with no credentials and no daemon."""
|
||||
provider = resolve_compute(place)
|
||||
value = getattr(provider, "is_available", None)
|
||||
value = value() if callable(value) else value
|
||||
assert isinstance(value, bool) or value is None
|
||||
|
||||
|
||||
# ── the parameters ───────────────────────────────────────────────────────────
|
||||
def test_tools_run_on_accepts_every_place(place):
|
||||
agent = _agent(tools_run_on=place)
|
||||
assert agent.tools_run_on == place
|
||||
|
||||
|
||||
def test_run_on_accepts_exactly_the_places_that_host_a_loop(place):
|
||||
"""A place that only runs commands must be refused by run_on=, and a place
|
||||
that can host a loop must be accepted. Getting this backwards either hides
|
||||
a capability or silently runs the agent somewhere unintended."""
|
||||
hosts_a_loop = place in managed_runtimes()
|
||||
if hosts_a_loop:
|
||||
assert _agent(run_on=place).backend is not None
|
||||
else:
|
||||
with pytest.raises(TypeError) as exc:
|
||||
_agent(run_on=place)
|
||||
assert "tools_run_on" in str(exc.value), "must name the parameter that works"
|
||||
|
||||
|
||||
def test_the_repr_names_the_place_it_was_given(place):
|
||||
"""The repr must describe the place that was actually chosen.
|
||||
|
||||
A place with no phrase falls back to its own name, which is fine. What
|
||||
would not be fine is the repr describing a different place -- so the value
|
||||
has to match what say_place() resolves for this name, not merely be
|
||||
non-empty. (An earlier version of this test banned the substring "this
|
||||
machine", which wrongly failed `sandlock`: a locked-down process really is
|
||||
on this machine.)
|
||||
"""
|
||||
from praisonaiagents.agent.execution_location import say_place
|
||||
|
||||
text = repr(_agent(tools_run_on=place))
|
||||
assert "tools_run_on=" in text
|
||||
assert say_place(place, via="compute") in text, (
|
||||
f"repr describes something other than {place!r}: {text}"
|
||||
)
|
||||
|
||||
|
||||
# ── failure quality, which is most of the user experience ────────────────────
|
||||
def test_a_missing_credential_fails_with_something_actionable(place):
|
||||
"""The failure has to name the key, the install command, or the daemon --
|
||||
not surface as an AttributeError from inside a vendor SDK."""
|
||||
import asyncio
|
||||
|
||||
from praisonaiagents.managed.protocols import ComputeConfig
|
||||
|
||||
provider = resolve_compute(place)
|
||||
available = getattr(provider, "is_available", True)
|
||||
available = available() if callable(available) else available
|
||||
if available:
|
||||
pytest.skip(f"{place} is usable on this machine, so it cannot fail here")
|
||||
|
||||
async def attempt():
|
||||
info = await provider.provision(ComputeConfig())
|
||||
await provider.shutdown(getattr(info, "instance_id", info))
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(attempt())
|
||||
|
||||
message = f"{type(exc.value).__name__}: {exc.value}".lower()
|
||||
assert any(word in message for word in _ACTIONABLE), (
|
||||
f"{place} failed unhelpfully: {message[:160]}"
|
||||
)
|
||||
|
||||
|
||||
# ── the places that deliberately refuse a bare name ──────────────────────────
|
||||
def test_ssh_explains_that_a_name_is_not_enough():
|
||||
with pytest.raises(TypeError) as exc:
|
||||
_agent(tools_run_on="ssh")
|
||||
message = str(exc.value)
|
||||
assert "SSHSandbox" in message and "host" in message
|
||||
|
||||
|
||||
def test_native_is_accepted_as_an_alias_for_sandlock():
|
||||
from praisonaiagents.agent.execution_location import say_place
|
||||
|
||||
assert say_place("sandlock") in repr(_agent(tools_run_on="native"))
|
||||
|
||||
|
||||
# ── live, where the machine allows it ────────────────────────────────────────
|
||||
@pytest.mark.parametrize("place", LOCAL_PLACES)
|
||||
def test_a_locally_available_place_actually_runs_a_command(place):
|
||||
"""Skips loudly rather than passing quietly when the backend is absent."""
|
||||
import asyncio
|
||||
|
||||
from praisonaiagents.managed.protocols import ComputeConfig
|
||||
|
||||
provider = resolve_compute(place)
|
||||
available = getattr(provider, "is_available", True)
|
||||
available = available() if callable(available) else available
|
||||
if not available:
|
||||
pytest.skip(f"{place} is not available on this machine")
|
||||
|
||||
async def run():
|
||||
info = await provider.provision(ComputeConfig())
|
||||
instance = getattr(info, "instance_id", info)
|
||||
try:
|
||||
return await provider.execute(instance, "echo matrix-ok")
|
||||
finally:
|
||||
await provider.shutdown(instance)
|
||||
|
||||
result = asyncio.run(run())
|
||||
assert "matrix-ok" in (result.get("stdout") or ""), result
|
||||
|
||||
|
||||
# ── reclaiming what we provisioned ───────────────────────────────────────────
|
||||
def test_managed_ps_polls_places_from_the_registry_not_a_literal():
|
||||
"""`_PS_PROVIDERS` was a hardcoded tuple, so a place contributed by another
|
||||
package could be provisioned and then never listed or stopped -- and an
|
||||
unreclaimable cloud sandbox is a bill nobody sees."""
|
||||
pytest.importorskip("praisonai.cli.commands.managed")
|
||||
from praisonai.cli.commands.managed import _ps_providers
|
||||
|
||||
polled = set(_ps_providers())
|
||||
assert "docker" in polled
|
||||
|
||||
# Local places have nothing to reclaim and must not be polled.
|
||||
assert not polled & {"local", "subprocess", "sandlock", "ssh"}
|
||||
|
||||
# Everything polled must be a real place.
|
||||
assert polled <= set(tool_places())
|
||||
|
||||
|
||||
def test_a_contributed_place_would_be_polled(monkeypatch):
|
||||
pytest.importorskip("praisonai.cli.commands.managed")
|
||||
import praisonaiagents.managed._compute_bridge as bridge
|
||||
from praisonai.cli.commands.managed import _ps_providers
|
||||
|
||||
monkeypatch.setattr(bridge, "available_providers",
|
||||
lambda: ["docker", "contributed-cloud"])
|
||||
assert "contributed-cloud" in _ps_providers()
|
||||
|
||||
|
||||
def test_a_contributed_place_can_name_itself(monkeypatch):
|
||||
"""The phrase table is a literal, so it cannot know about a package
|
||||
installed later. Rather than always printing a bare name, a contributed
|
||||
provider may declare `display_name` and have it used."""
|
||||
import praisonaiagents.managed._compute_bridge as bridge
|
||||
from praisonaiagents.agent.execution_location import say_place
|
||||
|
||||
monkeypatch.setitem(bridge._DISPLAY_NAMES, "contributed", "a Contributed cloud sandbox")
|
||||
assert say_place("contributed") == "a Contributed cloud sandbox"
|
||||
|
||||
|
||||
def test_an_unknown_place_still_falls_back_to_its_name():
|
||||
"""Declaring a phrase is optional; the fallback must stay graceful."""
|
||||
from praisonaiagents.agent.execution_location import say_place
|
||||
|
||||
assert say_place("some-new-cloud") == "some-new-cloud"
|
||||
@@ -180,3 +180,44 @@ def test_the_container_is_removed_afterwards():
|
||||
capture_output=True, text=True, timeout=120,
|
||||
).stdout.strip()
|
||||
assert remaining == "", f"container {name} outlived the backend"
|
||||
|
||||
|
||||
# ── run_on= across every place that can host a loop ──────────────────────────
|
||||
def test_run_on_covers_the_cloud_places_too():
|
||||
"""run_on= accepted two names while tools_run_on= accepted twelve. That gap
|
||||
was an implementation detail -- no backend had been written for the rest --
|
||||
not a fact about the places. One generic backend closed it."""
|
||||
from praisonaiagents.agent.placement import managed_runtimes
|
||||
|
||||
runtimes = set(managed_runtimes())
|
||||
for place in ("anthropic", "docker", "modal", "e2b", "daytona", "flyio", "tenki", "novita"):
|
||||
assert place in runtimes, f"run_on={place!r} should host a whole agent"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("place", ["modal", "e2b", "daytona"])
|
||||
def test_a_cloud_place_builds_its_own_backend_class(place):
|
||||
"""HostedAgent's factory calls issubclass() on whatever the registry
|
||||
returns, so a loader must hand back a class, not a factory function."""
|
||||
backend = _agent(run_on=place).backend
|
||||
assert backend.provider_name == place
|
||||
assert type(backend).__name__.lower().startswith(place[:4].lower())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("place", ["local", "subprocess", "sandlock", "ssh"])
|
||||
def test_places_that_cannot_host_a_loop_are_still_refused(place):
|
||||
"""`local` would run the agent in your own shell, which is what you get by
|
||||
passing nothing; `ssh` needs an object; the local sandboxes isolate tools
|
||||
rather than host a runtime."""
|
||||
with pytest.raises(TypeError):
|
||||
_agent(run_on=place)
|
||||
|
||||
|
||||
def test_the_remote_agent_is_rebuilt_without_callables():
|
||||
from praisonai.integrations.compute_managed_agent import _as_dict
|
||||
|
||||
def local_tool(x: str) -> str:
|
||||
return x
|
||||
|
||||
out = _as_dict({"instructions": "be brief", "tools": [local_tool]})
|
||||
assert "tools" not in out, "callables cannot be rebuilt remotely"
|
||||
assert out["instructions"] == "be brief"
|
||||
@@ -7,6 +7,7 @@ Provides serverless code execution on Modal's cloud platform.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
@@ -21,6 +22,48 @@ logger = logging.getLogger(__name__)
|
||||
TIMEOUT_EXIT_CODE = 124
|
||||
|
||||
|
||||
|
||||
def _modal_scaling_kwargs() -> dict:
|
||||
"""Scaling arguments spelled for whichever Modal SDK is installed.
|
||||
|
||||
``allow_concurrent_inputs`` became ``max_concurrent_inputs`` and
|
||||
``keep_warm`` became ``min_containers`` on 2025-02-24. The old names now
|
||||
raise a DeprecationError rather than warning, so hardcoding either spelling
|
||||
breaks half the installed base.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
try:
|
||||
import modal
|
||||
|
||||
params = inspect.signature(modal.App.function).parameters
|
||||
except Exception: # pragma: no cover - fall back to the current spelling
|
||||
return {"min_containers": 1, "max_concurrent_inputs": 10}
|
||||
|
||||
kwargs = {}
|
||||
kwargs["min_containers" if "min_containers" in params else "keep_warm"] = 1
|
||||
if "max_concurrent_inputs" in params:
|
||||
kwargs["max_concurrent_inputs"] = 10
|
||||
elif "allow_concurrent_inputs" in params:
|
||||
kwargs["allow_concurrent_inputs"] = 10
|
||||
return kwargs
|
||||
|
||||
|
||||
def _modal_base_image(reference: str):
|
||||
"""Build the Modal image, only grafting Python on when it is missing.
|
||||
|
||||
``add_python=`` installs a Python into an image that has none. Passing it
|
||||
for an image that already ships one -- the default here is ``python:3.11``
|
||||
-- makes the build fail, so `run_in="modal"` died during image build.
|
||||
"""
|
||||
import modal
|
||||
|
||||
name = (reference or "").lower()
|
||||
already_has_python = name.startswith("python:") or "/python:" in name
|
||||
if already_has_python:
|
||||
return modal.Image.from_registry(reference)
|
||||
return modal.Image.from_registry(reference, add_python="3.11")
|
||||
|
||||
class ModalSandbox:
|
||||
"""Modal-based sandbox for serverless code execution.
|
||||
|
||||
@@ -40,7 +83,7 @@ class ModalSandbox:
|
||||
def __init__(
|
||||
self,
|
||||
gpu: Optional[str] = None,
|
||||
image: str = "python:3.11",
|
||||
image: Optional[str] = None,
|
||||
timeout: int = 300,
|
||||
app_name: Optional[str] = None,
|
||||
):
|
||||
@@ -53,11 +96,16 @@ class ModalSandbox:
|
||||
app_name: Optional Modal app name
|
||||
"""
|
||||
self.gpu = gpu
|
||||
self.image = image
|
||||
# Default to the RUNNING interpreter's Python. A serialized function
|
||||
# is pickled locally and unpickled in the image, so the two Pythons
|
||||
# must match -- a hardcoded 3.11 image failed for anyone on 3.12 with
|
||||
# "defined with Python 3.12, but its Image has 3.11".
|
||||
self.image = image or f"python:{sys.version_info.major}.{sys.version_info.minor}"
|
||||
self.timeout = timeout
|
||||
self.app_name = app_name or f"praisonai-sandbox-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
self._app = None
|
||||
self._app_ctx = None
|
||||
self._function = None
|
||||
self._is_running = False
|
||||
|
||||
@@ -98,15 +146,9 @@ class ModalSandbox:
|
||||
modal.gpu.A100() if self.gpu.upper() == "A100" else \
|
||||
modal.gpu.T4() # Default fallback
|
||||
|
||||
image = modal.Image.from_registry(
|
||||
self.image,
|
||||
add_python="3.11"
|
||||
).pip_install("torch", "numpy")
|
||||
image = _modal_base_image(self.image).pip_install("torch", "numpy")
|
||||
else:
|
||||
image = modal.Image.from_registry(
|
||||
self.image,
|
||||
add_python="3.11"
|
||||
)
|
||||
image = _modal_base_image(self.image)
|
||||
gpu_config = None
|
||||
|
||||
# Create function for code execution
|
||||
@@ -114,8 +156,15 @@ class ModalSandbox:
|
||||
image=image,
|
||||
gpu=gpu_config,
|
||||
timeout=self.timeout,
|
||||
allow_concurrent_inputs=10,
|
||||
keep_warm=1,
|
||||
# Modal renamed these on 2025-02-24 and the old spellings now
|
||||
# raise rather than warn, so `run_in="modal"` failed before the
|
||||
# sandbox was ever built. Both names are passed through a
|
||||
# compatibility shim so this works on either SDK generation.
|
||||
# The decorated function is defined inside a method, not at
|
||||
# module scope, which Modal only accepts when it is told to
|
||||
# serialise it.
|
||||
serialized=True,
|
||||
**_modal_scaling_kwargs(),
|
||||
)
|
||||
def execute_code(code: str, language: str = "python", env_vars: Dict[str, str] = None):
|
||||
"""Execute code in Modal environment."""
|
||||
@@ -196,6 +245,16 @@ class ModalSandbox:
|
||||
)
|
||||
|
||||
self._function = execute_code
|
||||
# Enter the app context and hold it. A Modal function only carries
|
||||
# the metadata it needs to run while its App is running, so
|
||||
# defining the function and then calling .remote() outside a
|
||||
# running app failed with "Function has not been hydrated".
|
||||
self._app_ctx = self._app.run()
|
||||
try:
|
||||
await self._app_ctx.__aenter__()
|
||||
except AttributeError: # older SDKs expose a sync context
|
||||
self._app_ctx.__enter__()
|
||||
|
||||
self._is_running = True
|
||||
logger.info(f"Modal sandbox initialized with app: {self.app_name}")
|
||||
|
||||
@@ -204,6 +263,15 @@ class ModalSandbox:
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop/cleanup the Modal app."""
|
||||
ctx, self._app_ctx = getattr(self, "_app_ctx", None), None
|
||||
if ctx is not None:
|
||||
try:
|
||||
try:
|
||||
await ctx.__aexit__(None, None, None)
|
||||
except AttributeError:
|
||||
ctx.__exit__(None, None, None)
|
||||
except Exception as exc: # pragma: no cover - teardown must be quiet
|
||||
logger.debug("Modal app context did not close cleanly: %s", exc)
|
||||
self._app = None
|
||||
self._function = None
|
||||
self._is_running = False
|
||||
|
||||
@@ -273,7 +273,14 @@ class TestModalSandbox:
|
||||
sandbox = ModalSandbox()
|
||||
|
||||
assert sandbox.gpu is None
|
||||
assert sandbox.image == "python:3.11"
|
||||
# The default image follows the RUNNING interpreter, not a hardcoded
|
||||
# 3.11. A serialized Modal function is pickled locally and unpickled in
|
||||
# the image, so a mismatch fails with "defined with Python 3.12, but
|
||||
# its Image has 3.11" -- which is what a fixed 3.11 default caused for
|
||||
# everyone not on 3.11.
|
||||
import sys
|
||||
|
||||
assert sandbox.image == f"python:{sys.version_info.major}.{sys.version_info.minor}"
|
||||
assert sandbox.timeout == 300
|
||||
assert sandbox.app_name.startswith("praisonai-sandbox-")
|
||||
|
||||
|
||||
@@ -657,9 +657,30 @@ def ids_restore(
|
||||
# sandbox lifecycle: ps / stop
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Providers worth polling for running sandboxes. "local" is excluded: it runs
|
||||
# on this machine and has nothing to reclaim.
|
||||
_PS_PROVIDERS = ("docker", "e2b", "modal", "daytona", "flyio", "tenki")
|
||||
# Places worth polling for running sandboxes, derived from the registry rather
|
||||
# than restated here. A hardcoded tuple meant a place contributed by another
|
||||
# package could be provisioned but never listed or reclaimed -- and an
|
||||
# unreclaimable cloud sandbox is a bill nobody sees.
|
||||
#
|
||||
# Excluded on purpose: "local" and "subprocess" run on this machine and have
|
||||
# nothing to reclaim; "ssh" is someone else's machine, which is not ours to
|
||||
# stop; "sandlock" is a process, not an instance; adapter-backed places have no
|
||||
# cross-process registry to enumerate (see SandboxComputeAdapter).
|
||||
_PS_EXCLUDED = frozenset({"local", "subprocess", "sandlock", "ssh", "native", "novita"})
|
||||
|
||||
|
||||
def _ps_providers() -> tuple:
|
||||
"""Places that can be listed and stopped, from the live registry."""
|
||||
try:
|
||||
from praisonaiagents.managed._compute_bridge import available_providers
|
||||
|
||||
return tuple(p for p in available_providers() if p not in _PS_EXCLUDED)
|
||||
except Exception:
|
||||
return ("docker", "e2b", "modal", "daytona", "flyio", "tenki")
|
||||
|
||||
|
||||
#: Kept as a module attribute so existing imports and tests keep working.
|
||||
_PS_PROVIDERS = _ps_providers()
|
||||
|
||||
|
||||
def _discover_instances(provider_filter: Optional[str] = None):
|
||||
|
||||
@@ -30,6 +30,34 @@ def _docker_loader() -> Type:
|
||||
return DockerManagedAgent
|
||||
|
||||
|
||||
def _compute_backed() -> dict:
|
||||
"""Every compute place, able to host a whole agent.
|
||||
|
||||
run_on= accepted two names while tools_run_on= accepted twelve, which was
|
||||
an implementation detail leaking into the vocabulary: a place that can run
|
||||
a command can run the agent loop, which is just one more command. There was
|
||||
simply no backend written for the rest. One generic backend covers them
|
||||
all rather than eleven near-identical ones.
|
||||
|
||||
`docker` keeps its specialised backend -- it can talk to the daemon
|
||||
directly and skip a provisioning layer -- so it is not overridden here.
|
||||
Places that cannot host a loop are excluded: `ssh` needs an object rather
|
||||
than a name, and `local` would run the agent in your own shell, which is
|
||||
what you already get by not passing run_on= at all.
|
||||
"""
|
||||
from .compute_managed_agent import make_loader
|
||||
|
||||
try:
|
||||
from praisonaiagents.managed._compute_bridge import available_providers
|
||||
|
||||
places = available_providers()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
skip = {"docker", "ssh", "local", "native", "subprocess", "sandlock"}
|
||||
return {name: make_loader(name) for name in places if name not in skip}
|
||||
|
||||
|
||||
_BUILTIN_BACKENDS = {
|
||||
"anthropic": _anthropic_loader,
|
||||
# Self-hosted: the whole agent loop runs in a local container rather than a
|
||||
@@ -37,6 +65,7 @@ _BUILTIN_BACKENDS = {
|
||||
# placement resolves run_on= against this registry, so the parameter, the
|
||||
# repr, the explanation and the conflict checks all pick it up unchanged.
|
||||
"docker": _docker_loader,
|
||||
**_compute_backed(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Run the whole agent on any compute place, not just the ones with a backend.
|
||||
|
||||
``run_on=`` used to accept two names while ``tools_run_on=`` accepted twelve.
|
||||
That asymmetry was an implementation detail leaking into the vocabulary: every
|
||||
compute place can already run an arbitrary command, and running the agent loop
|
||||
is just running one more command. Nothing about ``e2b`` or ``modal`` makes them
|
||||
unable to host a loop -- there simply was no backend written for them.
|
||||
|
||||
So instead of one bespoke backend per vendor, this wraps *any*
|
||||
``ComputeProviderProtocol`` and gets the whole set at once. The specialised
|
||||
``DockerManagedAgent`` remains because it can talk to the daemon directly and
|
||||
skip a provisioning layer; everything else routes through here.
|
||||
|
||||
The limitations are the same ones every hosted runtime has, and worth stating
|
||||
plainly rather than discovering later:
|
||||
|
||||
* **Python callables in ``tools=`` do not cross.** A function defined in your
|
||||
process cannot be rebuilt remotely, so the agent is reconstructed from its
|
||||
serialisable configuration.
|
||||
* **The model API key must reach the remote environment.** That is what moving
|
||||
the loop means.
|
||||
* **The package is installed on first use** unless the place's image already
|
||||
carries it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MARKER = "__PRAISON_RESULT__"
|
||||
|
||||
_KEY_VARS = (
|
||||
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
|
||||
"GROQ_API_KEY", "MISTRAL_API_KEY", "COHERE_API_KEY", "OPENROUTER_API_KEY",
|
||||
"DEEPSEEK_API_KEY", "XAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE",
|
||||
)
|
||||
|
||||
_RUNNER = r'''
|
||||
import json, sys
|
||||
|
||||
payload = json.load(open("/tmp/praison_agent.json"))
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
kwargs = {k: v for k, v in payload["agent"].items() if v is not None}
|
||||
result = Agent(**kwargs).start(payload["prompt"])
|
||||
out = {"ok": True, "result": result if isinstance(result, str) else str(result)}
|
||||
except Exception as exc: # noqa: BLE001 - reported, not raised
|
||||
out = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
sys.stdout.write("\n__PRAISON_RESULT__" + json.dumps(out) + "\n")
|
||||
'''
|
||||
|
||||
|
||||
class ComputeManagedAgent:
|
||||
"""A whole agent running on any compute place. ManagedBackendProtocol."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
place: str,
|
||||
config: Optional[Any] = None,
|
||||
*,
|
||||
provider: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
keep_alive: bool = True,
|
||||
pip_packages: Optional[List[str]] = None,
|
||||
**_ignored: Any,
|
||||
):
|
||||
del provider # implied by `place`
|
||||
self._place = place
|
||||
self._config = _as_dict(config)
|
||||
self._extra_env = dict(env or {})
|
||||
self._keep_alive = keep_alive
|
||||
self._pip_packages = list(pip_packages or ["praisonaiagents"])
|
||||
self._provider = None
|
||||
self._instance: Optional[str] = None
|
||||
self._prepared = False
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return self._place
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
try:
|
||||
return bool(getattr(self._resolve(), "is_available", True))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ── ManagedBackendProtocol ───────────────────────────────────────────────
|
||||
async def execute(self, prompt: str, **kwargs) -> str:
|
||||
await self._ensure()
|
||||
|
||||
payload = json.dumps({"agent": self._config, "prompt": prompt})
|
||||
await self._write("/tmp/praison_agent.json", payload)
|
||||
await self._write("/tmp/praison_runner.py", _RUNNER)
|
||||
|
||||
result = await self._provider.execute(
|
||||
self._instance, "python /tmp/praison_runner.py",
|
||||
kwargs.get("timeout", 900),
|
||||
)
|
||||
answer = _parse(result.get("stdout", ""))
|
||||
if answer is None:
|
||||
detail = (result.get("stderr") or result.get("stdout") or "").strip()[-600:]
|
||||
raise RuntimeError(
|
||||
f"The agent produced no result on {self._place}.\n{detail}"
|
||||
)
|
||||
if not answer.get("ok"):
|
||||
raise RuntimeError(f"Agent failed on {self._place}: {answer['error']}")
|
||||
|
||||
if not self._keep_alive:
|
||||
await self.ashutdown()
|
||||
return answer["result"]
|
||||
|
||||
async def stream(self, prompt: str, **kwargs):
|
||||
"""Yield the finished answer once.
|
||||
|
||||
The loop runs to completion remotely, so there is no token stream to
|
||||
forward. Yielding the result keeps iterator callers working instead of
|
||||
pretending to stream.
|
||||
"""
|
||||
yield await self.execute(prompt, **kwargs)
|
||||
|
||||
def reset_session(self) -> None:
|
||||
from praisonaiagents.approval.utils import run_coroutine_safely
|
||||
|
||||
run_coroutine_safely(self.ashutdown())
|
||||
|
||||
def reset_all(self) -> None:
|
||||
self.reset_session()
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────────
|
||||
async def ashutdown(self) -> None:
|
||||
if self._provider is not None and self._instance:
|
||||
try:
|
||||
await self._provider.shutdown(self._instance)
|
||||
except Exception as exc: # pragma: no cover - teardown stays quiet
|
||||
logger.debug("[compute_managed] shutdown failed: %s", exc)
|
||||
self._instance = None
|
||||
self._prepared = False
|
||||
|
||||
def _resolve(self):
|
||||
if self._provider is None:
|
||||
from praisonaiagents.managed._compute_bridge import resolve_compute
|
||||
|
||||
self._provider = resolve_compute(self._place)
|
||||
return self._provider
|
||||
|
||||
async def _ensure(self) -> None:
|
||||
if self._instance and self._prepared:
|
||||
return
|
||||
|
||||
from praisonaiagents.managed.protocols import ComputeConfig
|
||||
|
||||
provider = self._resolve()
|
||||
env = {k: os.environ[k] for k in _KEY_VARS if os.environ.get(k)}
|
||||
env.update(self._extra_env)
|
||||
|
||||
info = await provider.provision(ComputeConfig(env=env))
|
||||
self._instance = getattr(info, "instance_id", info)
|
||||
logger.info("[compute_managed] provisioned %s on %s", self._instance, self._place)
|
||||
|
||||
probe = await provider.execute(self._instance, "python -c 'import praisonaiagents'", 180)
|
||||
if probe.get("exit_code"):
|
||||
logger.info("[compute_managed] installing %s on %s", self._pip_packages, self._place)
|
||||
install = await provider.execute(
|
||||
self._instance,
|
||||
"pip install --no-cache-dir -q " + " ".join(map(shlex.quote, self._pip_packages)),
|
||||
900,
|
||||
)
|
||||
if install.get("exit_code"):
|
||||
await self.ashutdown()
|
||||
raise RuntimeError(
|
||||
f"Could not install praisonaiagents on {self._place}:\n"
|
||||
+ (install.get("stderr") or "")[-600:]
|
||||
)
|
||||
self._prepared = True
|
||||
|
||||
async def _write(self, path: str, content: str) -> None:
|
||||
"""Write a file remotely using only the one primitive every place has.
|
||||
|
||||
A compute provider exposes ``execute`` and nothing else, so the file
|
||||
goes in through a quoted heredoc. The delimiter is random and uppercase
|
||||
-- uppercase because the security policy substring-matches blocked
|
||||
commands, and a lowercase hex delimiter contains "dd" about a tenth of
|
||||
the time.
|
||||
"""
|
||||
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex.upper()}__"
|
||||
while delimiter in content:
|
||||
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex.upper()}__"
|
||||
heredoc = f"cat > {shlex.quote(path)} <<'{delimiter}'\n{content}\n{delimiter}"
|
||||
result = await self._provider.execute(self._instance, heredoc, 300)
|
||||
if result.get("exit_code"):
|
||||
raise RuntimeError(
|
||||
f"Could not write {path} on {self._place}: {result.get('stderr', '')[:300]}"
|
||||
)
|
||||
|
||||
|
||||
def _as_dict(config: Any) -> Dict[str, Any]:
|
||||
"""Constructor kwargs for the remote Agent, JSON-safe values only."""
|
||||
if config is None:
|
||||
return {"instructions": "You are a helpful assistant."}
|
||||
raw = dict(config) if isinstance(config, dict) else (
|
||||
{k: v for k, v in vars(config).items() if not k.startswith("_")}
|
||||
if hasattr(config, "__dict__") else {}
|
||||
)
|
||||
allowed = ("name", "role", "goal", "backstory", "instructions", "llm",
|
||||
"system", "model", "verbose", "markdown", "self_reflect")
|
||||
out = {k: raw[k] for k in allowed if k in raw and isinstance(raw[k], (str, bool, int, float))}
|
||||
if "system" in out:
|
||||
out.setdefault("instructions", out.pop("system"))
|
||||
if "model" in out:
|
||||
out.setdefault("llm", out.pop("model"))
|
||||
out.pop("system", None)
|
||||
out.pop("model", None)
|
||||
if not any(out.get(k) for k in ("name", "role", "goal", "backstory", "instructions")):
|
||||
out["instructions"] = "You are a helpful assistant."
|
||||
return out
|
||||
|
||||
|
||||
def _parse(stdout: str) -> Optional[Dict[str, Any]]:
|
||||
for line in reversed((stdout or "").splitlines()):
|
||||
if _MARKER in line:
|
||||
try:
|
||||
return json.loads(line.split(_MARKER, 1)[1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def make_loader(place: str):
|
||||
"""A registry loader binding this generic backend to one place.
|
||||
|
||||
Returns a real subclass rather than a factory function: HostedAgent's
|
||||
factory path calls issubclass() on whatever the registry hands back, which
|
||||
a plain function fails with "arg 1 must be a class".
|
||||
"""
|
||||
|
||||
def _loader():
|
||||
def __init__(self, config=None, **kwargs):
|
||||
kwargs.pop("place", None)
|
||||
ComputeManagedAgent.__init__(self, place, config, **kwargs)
|
||||
|
||||
return type(
|
||||
f"{place.capitalize()}ManagedAgent",
|
||||
(ComputeManagedAgent,),
|
||||
{"__init__": __init__, "__doc__": f"The whole agent, running on {place}."},
|
||||
)
|
||||
|
||||
return _loader
|
||||
Reference in new issue
Block a user