Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5 b1a3b25753 feat(placement): a contributed place can name itself
The phrase table is a literal, so it cannot know about a package
installed later: an external place printed as a bare name
(tools_run_on='demo') while every built-in got a readable phrase. A
provider may now declare `display_name` and have it used in the repr and
in where_does_it_run().

Verified with a plugin installed from outside the repo: it reports
"a Demo cloud sandbox" in both. Declaring it stays optional and the
fallback to the bare name is unchanged, so nothing existing shifts.

That leaves one derived surface still hand-maintained -- the ToolPlace
Literal -- which is inherent to static typing rather than fixable: a type
checker cannot see a runtime plugin. Its union already stays open, so an
unknown name type-checks and only loses autocomplete.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 23:19:53 +01:00
MervinPraisonandClaude Opus 5 e34ec29e94 fix(cli): managed ps polls 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. An
unreclaimable cloud sandbox is a bill nobody sees, which makes this the
one remaining hand-maintained list with a cost attached.

Exclusions are now stated with reasons rather than by omission: local and
subprocess run here and have nothing to reclaim, ssh is someone else's
machine and not ours to stop, sandlock is a process rather than an
instance, and adapter-backed places have no cross-process registry to
enumerate.

_PS_PROVIDERS stays as a module attribute so existing imports keep
working.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 23:19:53 +01:00
MervinPraisonandClaude Opus 5 ab2f6bf774 test(providers): check every provider and SDK without needing a credential
Modal's `run_in=` path was broken in five separate ways and nothing
noticed, because nothing had ever run it. Most of what breaks an
integration is checkable for free, so this checks it: for all ten places
the class resolves, satisfies the protocol, exposes coroutines rather
than sync methods (a sync one here does not raise -- it returns a
coroutine nobody awaits, so the call silently does nothing), answers
is_available without throwing, is accepted by tools_run_on=, is accepted
or refused by run_on= according to whether it can host a loop, and is
described in the repr as the place it was actually given.

The one that matters most in practice is the last check: a missing
credential has to fail with something actionable -- naming the key, the
install command or the daemon -- rather than an AttributeError from
inside a vendor SDK. All seven credential-less places pass it today.

Live where the machine allows: docker, subprocess, local and modal
provision and execute for real; the rest skip loudly.

Also removes a latent flake. The batch-provisioning test asserts an exact
provision count, and agents from earlier tests release their sandbox when
collected -- which could land inside the measurement window now that
lifetime is tied to a finalizer. The window is opened after a collection
instead of hoping the timing misses.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 23:19:53 +01:00
MervinPraisonandClaude Opus 5 c383794742 feat(placement): run_on= now covers every place that can host a loop
run_on= accepted two names while tools_run_on= accepted twelve. That was
not a fact about the places -- a place that can run a command can run the
agent loop, which is just one more command -- it was an implementation
detail: no backend had been written for the rest.

One generic ComputeManagedAgent wraps any ComputeProviderProtocol rather
than eleven near-identical vendor backends, taking run_on= from 2 places
to 8: anthropic, docker, e2b, modal, daytona, flyio, tenki, novita.

Verified live end to end on Modal -- the whole loop ran there and came
back with the answer, having installed the package remotely first.

Excluded deliberately, with tests asserting they still raise: `local`
would run the agent in your own shell, which is what you get by passing
nothing; `ssh` needs an object, not a name; `subprocess`/`sandlock`
isolate tools rather than host a runtime. `docker` keeps its specialised
backend since it can talk to the daemon directly.

Two details worth keeping:
- the registry loader returns a real subclass, not a factory function,
  because HostedAgent's factory path calls issubclass() on it
- files reach the remote machine through a quoted heredoc, since execute()
  is the only primitive every compute place has, and the delimiter is
  uppercase for the same reason as elsewhere: a lowercase hex delimiter
  contains "dd", a blocked command, about a tenth of the time

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 23:19:53 +01:00
MervinPraisonandClaude Opus 5 1f8bbc7e9a fix(modal): run_in="modal" was dead five times over
Modal is authenticated on this machine, so the path could finally be
exercised. It failed five times in a row, each fault hiding the next:

1. keep_warm / allow_concurrent_inputs were renamed on 2025-02-24 and
   the old spellings now RAISE rather than warn. Both generations are
   handled by inspecting the installed SDK's signature, since hardcoding
   either spelling breaks half the installed base.
2. The decorated function is defined inside a method, which Modal only
   accepts with serialized=True.
3. The App was never started. A Modal function only carries the metadata
   it needs while its App is running, so .remote() failed with "Function
   has not been hydrated". The app.run() context is now held for the
   sandbox's lifetime and closed in stop().
4. add_python="3.11" was passed to an image that already ships Python --
   the default is python:3.11 -- so the image build failed outright.
5. The default image was pinned to 3.11 while a serialized function is
   pickled locally and unpickled inside it, so anyone not on 3.11 got
   "defined with Python 3.12, but its Image has 3.11". The default now
   follows the running interpreter.

Verified end to end: execute_code(run_in="modal") returns 'from modal'
with exit 0 against real Modal infrastructure.

tools_run_on="modal" was already working and is now verified live too --
provision, uname, a file written and read back across calls, shutdown.

The test asserting a hardcoded python:3.11 default was updated rather
than deleted; it now pins the interpreter-matching rule that replaced it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 23:19:53 +01:00
MervinPraisonandClaude Opus 5 8fb9fb1ddc feat(placement): let other packages contribute a place
Two of the three registries already discovered plugins by entry point --
praisonai.sandbox and praisonai.managed_backends. The compute registry,
which is where tools_run_on= places live, was a hardcoded dict, so adding
one meant editing core:

  _compute_bridge._PROVIDERS       the resolver
  compute/__init__.py              the lazy import
  placement.ToolPlace              or it gets no typing
  cli/commands/managed._PS_PROVIDERS  or `managed ps` cannot see it
  execution_location._PLACE_WORDS  or it prints a bare name
  plus the lists in two docstrings

Six edits across two packages, for something that is by nature a plugin.
It now reads praisonai.compute as well, so a provider ships in its own
distribution:

    [project.entry-points."praisonai.compute"]
    runpod = "praisonai_compute_runpod:RunpodCompute"

Verified end to end with a package installed outside this repo: it
appeared in available_providers(), passed tools_run_on= validation,
resolved, executed, and Agent(tools_run_on="demo") built -- with no
change to any file here. The repr falls back to the bare name, which is
the one remaining hand-maintained list and degrades gracefully.

Providers load on demand, so an installed-but-broken plugin fails only
for the caller that names it. There is a test for that.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 23:19:53 +01:00
10 changed files with 790 additions and 19 deletions

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
+8 -1
View File
@@ -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