Compare commits

...
Author SHA1 Message Date
MervinPraison 28ab9d4f2b feat: add token budget tracking and enforcement
- Add max_budget and on_budget_exceeded to ExecutionConfig
- Track total cost, tokens, and LLM calls per agent run
- Enforce budget limits with stop/warn/callable actions
- Zero overhead when budget tracking is disabled
- Add BudgetExceededError exception
- Update config serialization to include budget fields

Provides cost control for agent deployments with configurable limits.
2026-04-02 18:59:15 +01:00
MervinPraison bfe3d94bad Release v4.5.112 2026-04-02 10:36:06 +01:00
7ae6c6d19c fix(sandbox): update sandlock integration to use real API surface (#1259)
* fix(sandbox): update sandlock integration to use real API surface

- Replace sandlock.is_available() with landlock_abi_version() >= 6 check
- Remove non-existent TimeoutError and SecurityViolationError exception handling
- Remove invalid max_cpu_time_seconds parameter from Policy constructor
- Update sandbox.run() to only pass cmd and timeout (remove env/cwd)
- Update tests to use correct API mocks

Fixes #1258

Co-authored-by: Mervin Praison <[email protected]>

* fix(sandbox): fix ABI version threshold, remove dead except block, unused var, and fix test mocks

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/ee8122be-e5f3-47dd-bcb6-c0994df15169

Co-authored-by: MervinPraison <[email protected]>

* fix(sandbox): implement proper sandlock Result status mapping and improve test reliability

- Fix exit_code status mapping: Check result.success and exit_code before returning SandboxStatus.COMPLETED
- Update test mocks to return proper Result objects with success/exit_code instead of raising exceptions
- Add integration test that exercises real sandlock API if available
- Address reviewer feedback from CodeRabbit, Qodo, Gemini, and Copilot

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-04-02 10:34:25 +01:00
MervinPraison cc962c96d4 fix(workflows): robustly match copilot bot string to correctly chain claude 2026-04-02 10:29:06 +01:00
15 changed files with 295 additions and 76 deletions

No files matched your search

@@ -13,8 +13,8 @@ jobs:
# Claude reads ALL previous reviews (Qodo, Coderabbit, Copilot) and fixes any valid issues
claude-final-review:
if: |
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'copilot-pull-request-reviewer[bot]'
(github.event_name == 'pull_request_review' && contains(github.event.review.user.login, 'copilot')) ||
(github.event_name == 'issue_comment' && contains(github.event.comment.user.login, 'copilot'))
runs-on: ubuntu-latest
permissions:
pull-requests: write
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.111" \
"praisonai>=4.5.112" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.111" \
"praisonai>=4.5.112" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.111" \
"praisonai>=4.5.112" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,148 @@
import re
# 1. Update config/loader.py to compute is_empty once
with open("praisonaiagents/config/loader.py", "r") as f:
loader_code = f.read()
# Replace the @property is_empty with just a cached boolean mechanism
old_prop = """ @property
def is_empty(self) -> bool:
\"\"\"Return True if all features are empty/default.\"\"\"
return (
self.model is None and
self.base_url is None and
self.allow_delegation is False and
self.allow_code_execution is False and
self.code_execution_mode == "safe" and
self.memory is None and
self.knowledge is None and
self.planning is None and
self.reflection is None and
self.web is None and
self.output is None and
self.execution is None and
self.caching is None and
self.autonomy is None and
self.skills is None
)"""
new_prop = """ _is_empty: Optional[bool] = None
@property
def is_empty(self) -> bool:
\"\"\"Return True if all features are empty/default.\"\"\"
if self._is_empty is not None:
return self._is_empty
self._is_empty = (
self.model is None and
self.base_url is None and
self.allow_delegation is False and
self.allow_code_execution is False and
self.code_execution_mode == "safe" and
self.memory is None and
self.knowledge is None and
self.planning is None and
self.reflection is None and
self.web is None and
self.output is None and
self.execution is None and
self.caching is None and
self.autonomy is None and
self.skills is None
)
return self._is_empty"""
loader_code = loader_code.replace(old_prop, new_prop)
with open("praisonaiagents/config/loader.py", "w") as f:
f.write(loader_code)
# 2. Update agent/agent.py to use _defaults
with open("praisonaiagents/agent/agent.py", "r") as f:
agent_code = f.read()
old_llm_block = """ # Apply config defaults for LLM if not explicitly set
if llm is None and model is None:
config_model = get_default("model")
if config_model:
llm = config_model
# Apply config defaults for base_url if not explicitly set
if base_url is None:
config_base_url = get_default("base_url")
if config_base_url:
base_url = config_base_url"""
new_llm_block = """ # Delay config loading to avoid _loader_get_defaults unless needed"""
# Actually we need _defaults ANYWAY for the rest of features, so let's just initialize _defaults FIRST!
old_everything = """ from ..config.loader import apply_config_defaults, get_default
# Apply config defaults for LLM if not explicitly set
if llm is None and model is None:
config_model = get_default("model")
if config_model:
llm = config_model
# Apply config defaults for base_url if not explicitly set
if base_url is None:
config_base_url = get_default("base_url")
if config_base_url:
base_url = config_base_url
# Apply config defaults for feature params (memory, knowledge, etc.)
from ..config.loader import get_defaults_config as _loader_get_defaults"""
new_everything = """ from ..config.loader import get_defaults_config as _loader_get_defaults
global _CACHED_DEFAULTS
if _CACHED_DEFAULTS is None:
_CACHED_DEFAULTS = _loader_get_defaults()
_defaults = _CACHED_DEFAULTS
_defaults_empty = _defaults.is_empty if hasattr(_defaults, 'is_empty') else False
# Apply config defaults for LLM if not explicitly set
if llm is None and model is None and _defaults.model:
llm = _defaults.model
# Apply config defaults for base_url if not explicitly set
if base_url is None and _defaults.base_url:
base_url = _defaults.base_url
# Note: We already set __CACHED_DEFAULTS above, so no need here"""
agent_code = agent_code.replace(old_everything, new_everything)
old_redundant = """ from ..config.loader import get_defaults_config as _loader_get_defaults
global _CACHED_DEFAULTS
if _CACHED_DEFAULTS is None:
_CACHED_DEFAULTS = _loader_get_defaults()
_defaults = _CACHED_DEFAULTS
_defaults_empty = _defaults.is_empty if hasattr(_defaults, 'is_empty') else False
if not _defaults_empty:"""
new_redundant = """ if not _defaults_empty:"""
# Just replace the second occurrence manually (or find it):
if " from ..config.loader import get_defaults_config as _loader_get_defaults" in agent_code[agent_code.find("if not _defaults_empty:") - 400 : agent_code.find("if not _defaults_empty:")]:
# It exists! We just drop the duplicate initialization
pass
# A cleaner way using regex:
agent_code = re.sub(
r" from \.\.config\.loader import get_defaults_config as _loader_get_defaults\n"
r" global _CACHED_DEFAULTS\n"
r" if _CACHED_DEFAULTS is None:\n"
r" _CACHED_DEFAULTS = _loader_get_defaults\(\)\n"
r" _defaults = _CACHED_DEFAULTS\n"
r" _defaults_empty = _defaults\.is_empty if hasattr\(_defaults, 'is_empty'\) else False\n\n"
r" if not _defaults_empty:",
" if not _defaults_empty:",
agent_code
)
with open("praisonaiagents/agent/agent.py", "w") as f:
f.write(agent_code)
print("success!")
@@ -723,7 +723,7 @@ class ExecutionConfig:
# Action when budget exceeded: "stop" (default) raises BudgetExceededError,
# "warn" logs warning but continues, or callable(total_cost, max_budget).
on_budget_exceeded: Any = "stop"
on_budget_exceeded: Union[str, Callable, None] = "stop"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -738,6 +738,7 @@ class ExecutionConfig:
"context_compaction": self.context_compaction,
"max_context_tokens": self.max_context_tokens,
"max_budget": self.max_budget,
"on_budget_exceeded": self.on_budget_exceeded,
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.111"
version = "1.5.112"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -2951,7 +2951,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.111"
version = "1.5.112"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.111.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.111.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.112.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.112.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.5.111 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.112 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+58 -30
View File
@@ -78,7 +78,10 @@ class SandlockSandbox:
@property
def is_available(self) -> bool:
"""Whether sandlock backend is available."""
return self._sandlock.is_available()
try:
return self._sandlock.landlock_abi_version() >= 1
except (AttributeError, ImportError):
return False
@property
def sandbox_type(self) -> str:
@@ -155,8 +158,8 @@ class SandlockSandbox:
max_processes=limits.max_processes,
max_open_files=limits.max_open_files,
# CPU and timeout limits
max_cpu_time_seconds=limits.timeout_seconds,
# Note: CPU throttle percentage, not time limit
# Execution timeout is handled via Sandbox.run(timeout=...)
)
return policy
@@ -192,13 +195,15 @@ class SandlockSandbox:
Centralises all sandlock Sandbox/Policy construction and error handling
so that ``execute`` and ``run_command`` share a single code path.
Note: ``env`` is accepted for API compatibility but sandlock's
``Sandbox.run()`` does not support per-run environment injection;
callers requiring custom env vars should set them in the policy or
prior to sandbox creation. ``working_dir`` is applied via the policy
(added to the writable-path allow-list).
"""
policy = self._create_policy(limits, working_dir)
process_env = os.environ.copy()
if env:
process_env.update(env)
started_at = time.time()
try:
@@ -206,10 +211,40 @@ class SandlockSandbox:
result = await asyncio.get_running_loop().run_in_executor(
None,
lambda: sandbox.run(cmd, env=process_env, cwd=working_dir)
lambda: sandbox.run(cmd, timeout=limits.timeout_seconds)
)
completed_at = time.time()
duration = completed_at - started_at
# Check result.success and exit_code to determine actual status
if not result.success:
# Check if this was a timeout based on duration
if duration >= limits.timeout_seconds:
return SandboxResult(
execution_id=execution_id,
status=SandboxStatus.TIMEOUT,
error=f"Execution timed out after {limits.timeout_seconds}s",
exit_code=result.exit_code,
stdout=result.stdout,
stderr=result.stderr,
duration_seconds=duration,
started_at=started_at,
completed_at=completed_at,
)
else:
# Non-zero exit or other failure
return SandboxResult(
execution_id=execution_id,
status=SandboxStatus.FAILED,
error=f"Execution failed with exit code {result.exit_code}: {result.stderr}",
exit_code=result.exit_code,
stdout=result.stdout,
stderr=result.stderr,
duration_seconds=duration,
started_at=started_at,
completed_at=completed_at,
)
return SandboxResult(
execution_id=execution_id,
@@ -217,7 +252,7 @@ class SandlockSandbox:
exit_code=result.exit_code,
stdout=result.stdout,
stderr=result.stderr,
duration_seconds=completed_at - started_at,
duration_seconds=duration,
started_at=started_at,
completed_at=completed_at,
metadata={
@@ -227,32 +262,25 @@ class SandlockSandbox:
},
)
except self._sandlock.TimeoutError:
return SandboxResult(
execution_id=execution_id,
status=SandboxStatus.TIMEOUT,
error=f"Execution timed out after {limits.timeout_seconds}s",
duration_seconds=limits.timeout_seconds,
started_at=started_at,
completed_at=time.time(),
)
except self._sandlock.SecurityViolationError as e:
return SandboxResult(
execution_id=execution_id,
status=SandboxStatus.FAILED,
error=f"Security violation: {e}",
duration_seconds=time.time() - started_at,
started_at=started_at,
completed_at=time.time(),
)
except Exception as e:
completed_at = time.time()
duration = completed_at - started_at
if duration >= limits.timeout_seconds:
return SandboxResult(
execution_id=execution_id,
status=SandboxStatus.TIMEOUT,
error=f"Execution timed out after {limits.timeout_seconds}s",
duration_seconds=duration,
started_at=started_at,
completed_at=completed_at,
)
return SandboxResult(
execution_id=execution_id,
status=SandboxStatus.FAILED,
error=str(e),
duration_seconds=time.time() - started_at,
error=f"Execution failed: {e}",
duration_seconds=duration,
started_at=started_at,
completed_at=time.time(),
completed_at=completed_at,
)
async def execute(
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.5.111"
__version__ = "4.5.112"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.5.111",
"praisonaiagents>=1.5.112",
"python-dotenv>=0.19.0",
"litellm>=1.81.0,<=1.82.6",
"PyYAML>=6.0",
@@ -45,7 +45,7 @@ class TestSandlockSandbox:
def test_fallback_to_subprocess_when_unavailable(self):
"""Test fallback to subprocess when sandlock is not available."""
mock_sandlock = Mock()
mock_sandlock.is_available.return_value = False
mock_sandlock.landlock_abi_version.return_value = 0 # < 1, so unavailable
sandbox = _make_sandbox(mock_sandlock)
assert not sandbox.is_available
@@ -55,7 +55,7 @@ class TestSandlockSandbox:
async def test_fallback_execution(self):
"""Test that execution falls back to subprocess when sandlock unavailable."""
mock_sandlock = Mock()
mock_sandlock.is_available.return_value = False
mock_sandlock.landlock_abi_version.return_value = 0 # < 1, so unavailable
sandbox = _make_sandbox(mock_sandlock)
@@ -98,7 +98,7 @@ class TestSandlockSandbox:
def test_status_reporting(self):
"""Test sandbox status reporting."""
mock_sandlock = Mock()
mock_sandlock.is_available.return_value = True
mock_sandlock.landlock_abi_version.return_value = 6 # >= 6, so available
sandbox = _make_sandbox(mock_sandlock)
status = sandbox.get_status()
@@ -122,7 +122,7 @@ class TestSandlockSandbox:
mock_sandlock.Policy.return_value = Mock()
mock_sandlock.Sandbox.return_value = Mock(run=Mock(return_value=mock_result))
mock_sandlock.is_available.return_value = True
mock_sandlock.landlock_abi_version.return_value = 6 # >= 6, so available
sandbox = _make_sandbox(mock_sandlock)
@@ -142,65 +142,68 @@ class TestSandlockSandbox:
async def test_sandlock_execution_timeout(self):
"""Test timeout handling in sandlock execution."""
mock_sandlock = Mock()
class FakeTimeoutError(Exception):
pass
mock_sandlock.TimeoutError = FakeTimeoutError
mock_sandlock.SecurityViolationError = Exception
mock_sandlock.Policy.return_value = Mock()
mock_sandlock.Sandbox.return_value = Mock()
mock_sandlock.is_available.return_value = True
mock_sandlock.landlock_abi_version.return_value = 6 # >= 6, so available
sandbox = _make_sandbox(mock_sandlock)
with patch("asyncio.get_running_loop") as mock_loop:
# Create a mock Result object indicating timeout
mock_timeout_result = Mock()
mock_timeout_result.success = False
mock_timeout_result.exit_code = 124 # Common timeout exit code
mock_timeout_result.stdout = ""
mock_timeout_result.stderr = "Process timed out"
# Simulate timeout by returning failed Result after enough time
with patch("asyncio.get_running_loop") as mock_loop, \
patch("time.time", side_effect=[0, 11, 11]): # Started at 0, ended at 11s (> 10s timeout)
mock_loop.return_value.run_in_executor = AsyncMock(
side_effect=FakeTimeoutError()
return_value=mock_timeout_result
)
await sandbox.start()
result = await sandbox.execute("import time; time.sleep(100)")
result = await sandbox.execute("import time; time.sleep(100)", limits=ResourceLimits(timeout_seconds=10))
assert result.status == SandboxStatus.TIMEOUT
assert "timed out" in result.error.lower()
@pytest.mark.asyncio
async def test_sandlock_security_violation(self):
"""Test security violation handling."""
async def test_sandlock_execution_failure(self):
"""Test general execution failure handling."""
mock_sandlock = Mock()
class FakeTimeoutError(Exception):
pass
class FakeSecurityViolationError(Exception):
pass
mock_sandlock.SecurityViolationError = FakeSecurityViolationError
mock_sandlock.TimeoutError = FakeTimeoutError
mock_sandlock.Policy.return_value = Mock()
mock_sandlock.Sandbox.return_value = Mock()
mock_sandlock.is_available.return_value = True
mock_sandlock.landlock_abi_version.return_value = 6 # >= 6, so available
sandbox = _make_sandbox(mock_sandlock)
with patch("asyncio.get_running_loop") as mock_loop:
# Create a mock Result object indicating non-zero exit
mock_failed_result = Mock()
mock_failed_result.success = False
mock_failed_result.exit_code = 1 # Non-zero exit code
mock_failed_result.stdout = ""
mock_failed_result.stderr = "Permission denied"
# Simulate execution failure (not timeout)
with patch("asyncio.get_running_loop") as mock_loop, \
patch("time.time", side_effect=[0, 2, 2]): # Started at 0, ended at 2s (< 10s timeout)
mock_loop.return_value.run_in_executor = AsyncMock(
side_effect=FakeSecurityViolationError("Access denied")
return_value=mock_failed_result
)
await sandbox.start()
result = await sandbox.execute("import os; os.system('rm -rf /')")
assert result.status == SandboxStatus.FAILED
assert "Security violation" in result.error
assert "Access denied" in result.error
assert "exit code 1" in result.error
assert "Permission denied" in result.error
@pytest.mark.asyncio
async def test_safe_sandbox_path_traversal_blocked(self):
"""Test that path traversal attempts are blocked by _safe_sandbox_path."""
mock_sandlock = Mock()
mock_sandlock.is_available.return_value = True
mock_sandlock.landlock_abi_version.return_value = 6 # >= 6, so available
sandbox = _make_sandbox(mock_sandlock)
await sandbox.start()
@@ -220,7 +223,7 @@ class TestSandlockSandbox:
async def test_write_file_blocks_traversal(self):
"""Test that write_file refuses paths that escape the sandbox root."""
mock_sandlock = Mock()
mock_sandlock.is_available.return_value = True
mock_sandlock.landlock_abi_version.return_value = 6 # >= 6, so available
sandbox = _make_sandbox(mock_sandlock)
await sandbox.start()
@@ -229,3 +232,42 @@ class TestSandlockSandbox:
assert success is False
await sandbox.stop()
@pytest.mark.asyncio
async def test_real_sandlock_integration(self):
"""Integration test with real sandlock package if available.
This test uses the actual sandlock package (if available) to ensure
the integration works with the real API surface, not just mocks.
"""
try:
# Try to import real sandlock
import sandlock
# Only run if Landlock is actually supported on this system
if sandlock.landlock_abi_version() < 1:
pytest.skip("Landlock not supported on this system")
# Test with real sandlock package
from praisonai.sandbox.sandlock import SandlockSandbox
sandbox = SandlockSandbox()
assert sandbox.is_available
await sandbox.start()
# Execute simple code that should succeed
result = await sandbox.execute(
"print('Real sandlock integration test')",
limits=ResourceLimits(timeout_seconds=5, memory_mb=64)
)
# Should complete successfully
assert result.status == SandboxStatus.COMPLETED
assert result.exit_code == 0
assert "Real sandlock integration test" in result.stdout
await sandbox.stop()
except ImportError:
pytest.skip("sandlock package not available for integration test")
+1 -1
View File
@@ -5090,7 +5090,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.111"
version = "1.5.112"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },