Compare commits

...
20 Commits
Author SHA1 Message Date
MervinPraison 29facc8f89 Release v4.5.85 2026-03-26 03:13:24 +00:00
MervinPraison 401521848f feat(version): bump version to 1.5.85
Updated the version number in pyproject.toml and uv.lock to reflect the new release.
2026-03-26 03:13:24 +00:00
Mervin Praison ab97eae954 fix: harden env key validation — malformed keys, root/deployment type guards
fix: harden env key validation — malformed keys, root/deployment type guards
2026-03-26 02:30:10 +00:00
Mervin Praison 5dda0643ca security: cap litellm<=1.82.6 (supply chain incident)
security: cap litellm<=1.82.6 (supply chain incident)
2026-03-26 02:29:20 +00:00
MervinPraison 1b8d7a02d9 security: cap litellm<=1.82.6 (supply chain incident CVE)
LiteLLM v1.82.7 and v1.82.8 contained a credential stealer from a
supply chain attack (compromised PyPI maintainer via Trivy CI/CD).
Both versions removed from PyPI.

PraisonAI was NOT affected (installed 1.81.1), but as a defensive
measure, cap the upper bound at 1.82.6 (latest safe) until LiteLLM
completes their supply-chain review and resumes releases.

Ref: https://docs.litellm.ai/blog/security-update-march-2026
2026-03-26 02:28:46 +00:00
Mervin Praison dbcd36edce fix: Tier 1 security & concurrency fixes (#1134, #1129, #1137)
fix: Tier 1 security & concurrency fixes (#1134, #1129, #1137)
2026-03-26 02:12:56 +00:00
MervinPraison 9ef5391c83 fix: Tier 1 security & concurrency fixes (#1134, #1129, #1137)
#1134: Decouple execute_code from optional deps (black/pylint/autopep8)
- execute_code is now a standalone module-level function
- PythonTools class retained for analyze/format/lint (lazy-init)
- Agents can now use execute_code without heavy optional deps

#1129: Thread-safety for remaining global mutable state
- Add _lazy_import_lock with double-checked locking for all 6 lazy-import
  caches (_rich_console, _rich_live, _llm_module, _main_module, etc.)
- Add _env_cache_lock for class-level env var caches
  (_env_output_mode, _default_model)

#1137: Clarify misleading TODO comment
- Updated TODO in llm.py to clarify this is code-level DRY, not
  duplicate API calls per request
2026-03-26 02:09:40 +00:00
Sebastion 7385adbd60 fix: address second round of review feedback — malformed key validation, root/deployment type guards
- Reject empty strings, keys containing "=" or NUL in _validate_env_key()
  (CodeRabbit: malformed keys should fail during validation, not at os.environ)
- Validate root config is a dict after yaml.safe_load() (handles list/scalar/None)
- Validate deployment section is a dict before .get() calls
- Both raise ValueError caught by the existing handler → clean CLI error
- Add test_malformed_env_keys_rejected, test_non_dict_root_config_rejected,
  test_non_dict_deployment_rejected (7 tests total, all passing)
2026-03-26 00:05:11 +00:00
Mervin Praison 78d7883fb7 Merge pull request #1148 from MervinPraison/fix/1145-1147-approval-dry-thread-safety
fix: deduplicate approval logic and add thread-safety (#1147, #1145)
2026-03-25 17:21:44 +00:00
Mervin Praison 6122531a6a Merge pull request #1144 from sebastiondev/security/fix-cwe78-env-injection
Merging — verified locally, both vulnerable occurrences are patched, all tests pass. Great contribution! 🎉
2026-03-25 10:53:28 +00:00
Claude d704cf1349 fix: deduplicate approval logic and add thread-safety to global state (#1147, #1145)
Extract ~120 lines of duplicated approval code from _execute_tool_impl()
and execute_tool_async() into shared helpers _check_tool_approval_sync()
and _check_tool_approval_async(). Add threading.Lock to protect
_agent_counter, _server_started, _registered_agents, and _shared_apps
against concurrent access in multi-agent scenarios.

https://claude.ai/code/session_01WiGKiwTBj7xo5YoF3PYjWF
2026-03-25 10:10:41 +00:00
Sebastion 8523ef076e fix: address review feedback — fail-closed validation, restore env fixture, non-string key guard, perf, coverage 2026-03-25 03:46:33 +00:00
Mervin Praison 72889d2f73 Merge pull request #1119 from eltociear/patch-1
docs: update README.md
2026-03-24 20:42:24 +00:00
Sebastion f17bd7369c fix: reject dangerous environment variable keys from YAML schedule config (CWE-78)
The --schedule-config YAML file allows setting arbitrary environment
variables via the "environment" section. An attacker who can supply or
modify this file could set keys like LD_PRELOAD, PATH, PYTHONPATH, etc.
to achieve arbitrary code execution.

Add _validate_env_key() with a blocklist of security-sensitive env var
names (dynamic linker, search paths, proxies) and call it before each
os.environ assignment in the schedule-config loading path.

Also add unit tests for the validation function.
2026-03-24 20:41:10 +00:00
MervinPraison ed31e9b280 Release v4.5.83 2026-03-24 04:48:37 +00:00
MervinPraison 69e9e712c7 fix(security): remove hardcoded CHAINLIT_AUTH_SECRET and fix critical issues from #1122/#1125
- Replace hardcoded CHAINLIT_AUTH_SECRET with secrets.token_hex(32) fallback in all 5 UI files
  (agents.py, bot.py, chat.py, code.py, realtime.py) — secret was public in git history
- Use hmac.compare_digest() for constant-time API key comparison in recipe/serve.py
- Disable debug=True by default in deploy.py generated api.py; use DEBUG env var
- Restrict exec() __builtins__ to safe subset in job_workflow.py inline Python execution;
  prevents __import__('os').system() and similar arbitrary code execution patterns

Skipped shell=True changes (would break YAML run: steps with pipes/env vars) and
CORS wildcard changes (would break browser extension without env var migration path).
These require separate architectural discussion.
2026-03-24 04:33:35 +00:00
MervinPraison 7617b10c70 Release v4.5.82 2026-03-21 17:51:25 +00:00
MervinPraison 7939a4f12e fix(security): complete remaining eval/exec/XSS fixes from issue #1120
- Replace eval(e) in unified_server.py with AST-based safe math evaluator
- Replace exec(script_content) in run_with_langtrace.py with subprocess.run()
- Add nh3.clean() output sanitization in api.py to prevent XSS from markdown links
- Replace eval() in basic-tools.ts and custom-tool.ts with regex allowlist + Function constructor
- Replace eval() in simplified-api-test.ts and comprehensive-live.test.ts with safe evaluator

All changes are in example files and dev helper scripts only.
No SDK core code modified. Unit tests: 4341 passed, 0 new failures.
2026-03-21 17:49:40 +00:00
MervinPraison 8cbad0c997 Release v4.5.81 2026-03-21 16:51:27 +00:00
Ikko Eltociear Ashimine 48ed131577 docs: update README.md
minor fix
2026-03-20 13:17:09 +09:00
37 changed files with 1937 additions and 569 deletions

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
> - 📦 **Installing?** → [Installation](#-installation)
> - 🐍 **Python SDK?** → [Python Examples](#-using-python-code)
> - 🎯 **CLI user?** → [CLI Quick Reference](#cli-quick-reference)
> - 🤝 **Contributing?** → [Development](#-development)
> - 🤝 **Contributing?** → [Contributing](#-contributing)
---
+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.80" \
"praisonai>=4.5.85" \
"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.80" \
"praisonai>=4.5.85" \
"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.80" \
"praisonai>=4.5.85" \
"praisonai[ui]" \
"praisonai[crewai]"
+9 -1
View File
@@ -19,7 +19,15 @@ async function main() {
},
execute: async ({ expression }) => {
try {
const result = eval(expression);
// Only allow safe numeric math characters
if (!/^[0-9+\-*/.() ]+$/.test(expression)) {
return 'Error: Only basic math expressions are allowed';
}
// Use Function constructor scoped to math only, no globals
const result = new Function(`"use strict"; return (${expression})`)();
if (typeof result !== 'number' || !isFinite(result)) {
return 'Error: Invalid result';
}
return `Result: ${result}`;
} catch (e) {
return 'Error: Invalid expression';
+7 -1
View File
@@ -38,7 +38,13 @@ const calculatorTool = createTool({
},
run: (params: { expression: string }) => {
try {
return eval(params.expression);
// Only allow safe numeric math characters
if (!/^[0-9+\-*/.() ]+$/.test(params.expression)) {
return 'Error: Only basic math expressions are allowed';
}
const result = new Function(`"use strict"; return (${params.expression})`)();
if (typeof result !== 'number' || !isFinite(result)) return 'Error: Invalid result';
return result;
} catch {
return 'Error: Invalid expression';
}
+19 -1
View File
@@ -134,10 +134,28 @@ def main():
except Exception as e:
return {"error": str(e)}
# Safe AST-based math evaluator (no eval on arbitrary expressions)
def _safe_calculate(expression: str) -> str:
import ast
try:
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
tree = ast.parse(expression, mode="eval")
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
result = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
# MCP endpoints
mcp_tools = {
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
"calculate": {"description": "Calculate expression", "func": _safe_calculate},
}
@app.get("/mcp/tools")
@@ -4,6 +4,7 @@ import json
import logging
import asyncio
import contextlib
import threading
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
import inspect
@@ -13,7 +14,8 @@ import inspect
# This reduces import time from ~420ms to ~20ms for silent mode
# ============================================================================
# Lazy-loaded modules (populated on first use)
# Lazy-loaded modules (populated on first use, protected by _lazy_import_lock)
_lazy_import_lock = threading.Lock()
_rich_console = None
_rich_live = None
_llm_module = None
@@ -22,75 +24,87 @@ _hooks_module = None
_stream_emitter_class = None
def _get_console():
"""Lazy load rich.console.Console."""
"""Lazy load rich.console.Console (thread-safe)."""
global _rich_console
if _rich_console is None:
from rich.console import Console
_rich_console = Console
with _lazy_import_lock:
if _rich_console is None:
from rich.console import Console
_rich_console = Console
return _rich_console
def _get_live():
"""Lazy load rich.live.Live."""
"""Lazy load rich.live.Live (thread-safe)."""
global _rich_live
if _rich_live is None:
from rich.live import Live
_rich_live = Live
with _lazy_import_lock:
if _rich_live is None:
from rich.live import Live
_rich_live = Live
return _rich_live
def _get_llm_functions():
"""Lazy load LLM functions."""
"""Lazy load LLM functions (thread-safe)."""
global _llm_module
if _llm_module is None:
from ..llm import get_openai_client, process_stream_chunks
_llm_module = {
'get_openai_client': get_openai_client,
'process_stream_chunks': process_stream_chunks,
}
with _lazy_import_lock:
if _llm_module is None:
from ..llm import get_openai_client, process_stream_chunks
_llm_module = {
'get_openai_client': get_openai_client,
'process_stream_chunks': process_stream_chunks,
}
return _llm_module
def _get_display_functions():
"""Lazy load display functions from main module."""
"""Lazy load display functions from main module (thread-safe)."""
global _main_module
if _main_module is None:
from ..main import (
display_error,
display_instruction,
display_interaction,
display_generating,
display_self_reflection,
ReflectionOutput,
adisplay_instruction,
execute_sync_callback
)
_main_module = {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
with _lazy_import_lock:
if _main_module is None:
from ..main import (
display_error,
display_instruction,
display_interaction,
display_generating,
display_self_reflection,
ReflectionOutput,
adisplay_instruction,
execute_sync_callback
)
_main_module = {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
return _main_module
def _get_hooks_module():
"""Lazy load hooks module for HookRunner and HookRegistry."""
"""Lazy load hooks module for HookRunner and HookRegistry (thread-safe)."""
global _hooks_module
if _hooks_module is None:
from ..hooks import HookRunner, HookRegistry
_hooks_module = {
'HookRunner': HookRunner,
'HookRegistry': HookRegistry,
}
with _lazy_import_lock:
if _hooks_module is None:
from ..hooks import HookRunner, HookRegistry
_hooks_module = {
'HookRunner': HookRunner,
'HookRegistry': HookRegistry,
}
return _hooks_module
def _get_stream_emitter():
"""Lazy load StreamEventEmitter class."""
"""Lazy load StreamEventEmitter class (thread-safe)."""
global _stream_emitter_class
if _stream_emitter_class is None:
from ..streaming.events import StreamEventEmitter
_stream_emitter_class = StreamEventEmitter
with _lazy_import_lock:
if _stream_emitter_class is None:
from ..streaming.events import StreamEventEmitter
_stream_emitter_class = StreamEventEmitter
return _stream_emitter_class
# File extensions that indicate a file path (for output parameter detection)
@@ -139,7 +153,8 @@ from ..config.feature_configs import (
# Applied even when context management is disabled to prevent runaway tool outputs
DEFAULT_TOOL_OUTPUT_LIMIT = 16000
# Global variables for API server
# Global variables for API server (protected by _server_lock for thread safety)
_server_lock = threading.Lock()
_server_started = {} # Dict of port -> started boolean
_registered_agents = {} # Dict of port -> Dict of path -> agent_id
_shared_apps = {} # Dict of port -> FastAPI app
@@ -155,7 +170,10 @@ if TYPE_CHECKING:
class Agent:
# Class-level counter for generating unique display names for nameless agents
_agent_counter = 0
_agent_counter_lock = threading.Lock()
# Class-level cache for environment variables (avoid repeated os.environ.get)
# Protected by _env_cache_lock for thread safety
_env_cache_lock = threading.Lock()
_env_output_mode = None
_env_output_checked = False
_default_model = None
@@ -186,18 +204,22 @@ class Agent:
@classmethod
def _get_env_output_mode(cls):
"""Get cached PRAISONAI_OUTPUT env var value."""
"""Get cached PRAISONAI_OUTPUT env var value (thread-safe)."""
if not cls._env_output_checked:
cls._env_output_mode = os.environ.get('PRAISONAI_OUTPUT', '').lower()
cls._env_output_checked = True
with cls._env_cache_lock:
if not cls._env_output_checked:
cls._env_output_mode = os.environ.get('PRAISONAI_OUTPUT', '').lower()
cls._env_output_checked = True
return cls._env_output_mode
@classmethod
def _get_default_model(cls):
"""Get cached default model name from OPENAI_MODEL_NAME env var."""
"""Get cached default model name from OPENAI_MODEL_NAME env var (thread-safe)."""
if not cls._default_model_checked:
cls._default_model = os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
cls._default_model_checked = True
with cls._env_cache_lock:
if not cls._default_model_checked:
cls._default_model = os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
cls._default_model_checked = True
return cls._default_model
@classmethod
@@ -1251,8 +1273,9 @@ class Agent:
# Display logic will skip Agent Info panel when name is None
self.name = None
# Assign unique index for display_name
Agent._agent_counter += 1
self._agent_index = Agent._agent_counter
with Agent._agent_counter_lock:
Agent._agent_counter += 1
self._agent_index = Agent._agent_counter
self.role = role or "Assistant"
self.goal = goal or instructions
self.backstory = backstory or instructions
@@ -4937,68 +4960,108 @@ Your Goal: {self.goal}"""
tail = text[-tail_limit:] if tail_limit > 0 else ""
return f"{head}\n...[{len(text):,} chars, showing first/last portions]...\n{tail}"
def _check_tool_approval_sync(self, function_name, arguments):
"""Check tool approval synchronously. Returns (decision, arguments) or error dict."""
from ..approval import get_approval_registry
backend = getattr(self, '_approval_backend', None)
approve_all = getattr(self, '_approve_all_tools', False)
if backend is not None:
from ..approval.protocols import ApprovalRequest, ApprovalDecision
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
needs_approval = approve_all or function_name in DEFAULT_DANGEROUS_TOOLS
if needs_approval:
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
cfg_timeout = getattr(self, '_approval_timeout', 0)
if cfg_timeout is None:
orig_timeout = getattr(backend, '_timeout', None)
if orig_timeout is not None:
backend._timeout = 86400 * 365
elif cfg_timeout > 0:
orig_timeout = getattr(backend, '_timeout', None)
if orig_timeout is not None:
backend._timeout = cfg_timeout
else:
orig_timeout = None
try:
if hasattr(backend, 'request_approval_sync'):
decision = backend.request_approval_sync(request)
else:
decision = asyncio.run(backend.request_approval(request))
finally:
if orig_timeout is not None and hasattr(backend, '_timeout'):
backend._timeout = orig_timeout
else:
decision = ApprovalDecision(approved=True, reason="Not a dangerous tool")
else:
decision = get_approval_registry().approve_sync(
getattr(self, 'name', None), function_name, arguments,
)
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
get_approval_registry().mark_approved(function_name)
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
return None, arguments
async def _check_tool_approval_async(self, function_name, arguments):
"""Check tool approval asynchronously. Returns (decision, arguments) or error dict."""
from ..approval import get_approval_registry
backend = getattr(self, '_approval_backend', None)
approve_all = getattr(self, '_approve_all_tools', False)
if backend is not None:
from ..approval.protocols import ApprovalRequest, ApprovalDecision
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
needs_approval = approve_all or function_name in DEFAULT_DANGEROUS_TOOLS
if needs_approval:
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
cfg_timeout = getattr(self, '_approval_timeout', 0)
if cfg_timeout is None:
decision = await backend.request_approval(request)
elif cfg_timeout > 0:
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=cfg_timeout,
)
else:
decision = await backend.request_approval(request)
else:
decision = ApprovalDecision(approved=True, reason="Not a dangerous tool")
else:
decision = await get_approval_registry().approve_async(
getattr(self, 'name', None), function_name, arguments,
)
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
get_approval_registry().mark_approved(function_name)
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
return None, arguments
def _execute_tool_impl(self, function_name, arguments):
"""Internal tool execution implementation."""
# Check if approval is required for this tool (protocol-driven)
# Priority: self._approval_backend (agent param) > registry (global/per-agent)
from ..approval import get_approval_registry
try:
backend = getattr(self, '_approval_backend', None)
approve_all = getattr(self, '_approve_all_tools', False)
if backend is not None:
from ..approval.protocols import ApprovalRequest
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
# Gate: only dangerous tools unless approve_all is True
needs_approval = approve_all or function_name in DEFAULT_DANGEROUS_TOOLS
if needs_approval:
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
# Apply timeout override from ApprovalConfig
cfg_timeout = getattr(self, '_approval_timeout', 0)
if cfg_timeout is None:
# None = indefinite — set backend timeout temporarily
orig_timeout = getattr(backend, '_timeout', None)
if orig_timeout is not None:
backend._timeout = 86400 * 365 # ~1 year as "indefinite"
elif cfg_timeout > 0:
orig_timeout = getattr(backend, '_timeout', None)
if orig_timeout is not None:
backend._timeout = cfg_timeout
else:
orig_timeout = None # 0 = use backend default
try:
if hasattr(backend, 'request_approval_sync'):
decision = backend.request_approval_sync(request)
else:
import asyncio
decision = asyncio.run(backend.request_approval(request))
finally:
# Restore original timeout
if orig_timeout is not None and hasattr(backend, '_timeout'):
backend._timeout = orig_timeout
else:
from ..approval.protocols import ApprovalDecision
decision = ApprovalDecision(approved=True, reason="Not a dangerous tool")
else:
# Fallback to global registry
decision = get_approval_registry().approve_sync(
getattr(self, 'name', None), function_name, arguments,
)
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
# Bridge: mark tool as approved in global registry context so
# @require_approval decorator wrappers skip their own check.
get_approval_registry().mark_approved(function_name)
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
result = self._check_tool_approval_sync(function_name, arguments)
if isinstance(result, dict):
return result # Error dict
_, arguments = result
except Exception as e:
error_msg = f"Error during approval process: {str(e)}"
logging.error(error_msg)
@@ -5335,7 +5398,34 @@ Your Goal: {self.goal}"""
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None):
start_time = time.time()
# --- Context compaction (opt-in via ExecutionConfig.context_compaction) ---
# Compacts message history before sending to LLM. Zero overhead when disabled.
_execution_cfg = getattr(self, 'execution', None)
if _execution_cfg and getattr(_execution_cfg, 'context_compaction', False):
try:
from ..compaction import ContextCompactor
from ..hooks import HookEvent as _HookEvent
_max_tok = getattr(_execution_cfg, 'max_context_tokens', None) or 8000
_compactor = ContextCompactor(max_tokens=_max_tok)
if _compactor.needs_compaction(messages):
try:
self._hook_runner.execute_sync(_HookEvent.BEFORE_COMPACTION, None)
except Exception:
pass
compacted_msgs, _cr = _compactor.compact(messages)
messages[:] = compacted_msgs # in-place update so callers see the change
logging.info(
f"[compaction] {self.name}: {_cr.original_tokens}→{_cr.compacted_tokens} tokens "
f"({_cr.messages_removed} messages removed)"
)
try:
self._hook_runner.execute_sync(_HookEvent.AFTER_COMPACTION, None)
except Exception:
pass
except Exception as _ce:
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
# Trigger BEFORE_LLM hook
from ..hooks import HookEvent, BeforeLLMInput
before_llm_input = BeforeLLMInput(
@@ -6553,7 +6643,33 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self.chat_history[-1].get("content") == normalized_content):
# Add user message to chat history BEFORE LLM call so handoffs can access it
self.chat_history.append({"role": "user", "content": normalized_content})
# --- Context compaction (async custom LLM path) ---
_exec_cfg = getattr(self, 'execution', None)
if _exec_cfg and getattr(_exec_cfg, 'context_compaction', False):
try:
from ..compaction import ContextCompactor
from ..hooks import HookEvent as _HE
_mtok = getattr(_exec_cfg, 'max_context_tokens', None) or 8000
_cw = ContextCompactor(max_tokens=_mtok)
if _cw.needs_compaction(self.chat_history):
try:
await self._hook_runner.execute(_HE.BEFORE_COMPACTION, None)
except Exception:
pass
_ch, _cr = _cw.compact(self.chat_history)
self.chat_history[:] = _ch
logging.info(
f"[compaction] {self.name}: {_cr.original_tokens}→{_cr.compacted_tokens} tokens "
f"({_cr.messages_removed} messages removed)"
)
try:
await self._hook_runner.execute(_HE.AFTER_COMPACTION, None)
except Exception:
pass
except Exception as _ce:
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
try:
response_text = await self.llm_instance.get_response_async(
prompt=prompt,
@@ -6625,6 +6741,32 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Add user message to chat history BEFORE LLM call so handoffs can access it
self.chat_history.append({"role": "user", "content": normalized_content})
# --- Context compaction (async standard OpenAI path) ---
_exec_cfg2 = getattr(self, 'execution', None)
if _exec_cfg2 and getattr(_exec_cfg2, 'context_compaction', False):
try:
from ..compaction import ContextCompactor
from ..hooks import HookEvent as _HE2
_mtok2 = getattr(_exec_cfg2, 'max_context_tokens', None) or 8000
_cw2 = ContextCompactor(max_tokens=_mtok2)
if _cw2.needs_compaction(messages):
try:
await self._hook_runner.execute(_HE2.BEFORE_COMPACTION, None)
except Exception:
pass
_cm2, _cr2 = _cw2.compact(messages)
messages[:] = _cm2
logging.info(
f"[compaction] {self.name}: {_cr2.original_tokens}→{_cr2.compacted_tokens} tokens "
f"({_cr2.messages_removed} messages removed)"
)
try:
await self._hook_runner.execute(_HE2.AFTER_COMPACTION, None)
except Exception:
pass
except Exception as _ce2:
logging.debug(f"[compaction] skipped (non-fatal): {_ce2}")
reflection_count = 0
start_time = time.time()
@@ -6822,7 +6964,30 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if not tool:
_get_display_functions()['display_error'](f"Tool {function_name} not found")
continue
# --- BEFORE_TOOL hook ---
try:
from ..hooks import HookEvent, BeforeToolInput
_before_tool_input = BeforeToolInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.BEFORE_TOOL,
timestamp=str(time.time()),
agent_name=self.name,
tool_name=function_name,
tool_input=arguments,
)
_before_results = await self._hook_runner.execute(HookEvent.BEFORE_TOOL, _before_tool_input)
if self._hook_runner.is_blocked(_before_results):
_block_reason = next(
(r.output.get_reason() for r in _before_results if r.output and r.output.is_blocking()),
"Blocked by hook"
)
results.append(f"[Tool blocked by hook: {_block_reason}]")
continue
except Exception as _hook_err:
logging.debug(f"BEFORE_TOOL hook error (non-fatal): {_hook_err}")
# Check if the tool is async
if asyncio.iscoroutinefunction(tool):
result = await tool(**arguments)
@@ -6830,12 +6995,30 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Run sync function in executor to avoid blocking
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, lambda: tool(**arguments))
# --- AFTER_TOOL hook ---
try:
from ..hooks import AfterToolInput
_after_tool_input = AfterToolInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.AFTER_TOOL,
timestamp=str(time.time()),
agent_name=self.name,
tool_name=function_name,
tool_input=arguments,
tool_output=result,
)
await self._hook_runner.execute(HookEvent.AFTER_TOOL, _after_tool_input)
except Exception as _hook_err:
logging.debug(f"AFTER_TOOL hook error (non-fatal): {_hook_err}")
results.append(result)
except Exception as e:
_get_display_functions()['display_error'](f"Error executing tool {function_name}: {e}")
results.append(None)
# If we have results, format them into a response
if results:
formatted_results = "\n".join([str(r) for r in results if r is not None])
@@ -8148,54 +8331,11 @@ Write the complete compiled report:"""
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
# Check if approval is required for this tool (protocol-driven)
# Priority: self._approval_backend (agent param) > registry (global/per-agent)
from ..approval import get_approval_registry
try:
backend = getattr(self, '_approval_backend', None)
approve_all = getattr(self, '_approve_all_tools', False)
if backend is not None:
from ..approval.protocols import ApprovalRequest
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
# Gate: only dangerous tools unless approve_all is True
needs_approval = approve_all or function_name in DEFAULT_DANGEROUS_TOOLS
if needs_approval:
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
# Apply timeout override from ApprovalConfig
cfg_timeout = getattr(self, '_approval_timeout', 0)
if cfg_timeout is None:
# None = indefinite wait
decision = await backend.request_approval(request)
elif cfg_timeout > 0:
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=cfg_timeout,
)
else:
# 0 = use backend default timeout
decision = await backend.request_approval(request)
else:
from ..approval.protocols import ApprovalDecision
decision = ApprovalDecision(approved=True, reason="Not a dangerous tool")
else:
decision = await get_approval_registry().approve_async(
getattr(self, 'name', None), function_name, arguments,
)
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
# Bridge: mark tool as approved in global registry context so
# @require_approval decorator wrappers skip their own check.
from ..approval import get_approval_registry as _get_reg
_get_reg().mark_approved(function_name)
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
result = await self._check_tool_approval_async(function_name, arguments)
if isinstance(result, dict):
return result # Error dict
_, arguments = result
except Exception as e:
error_msg = f"Error during approval process: {str(e)}"
logging.error(error_msg)
@@ -8255,8 +8395,8 @@ Write the complete compiled report:"""
None
"""
if protocol == "http":
global _server_started, _registered_agents, _shared_apps
global _server_started, _registered_agents, _shared_apps, _server_lock
# Try to import FastAPI dependencies - lazy loading
try:
import uvicorn
@@ -8282,94 +8422,97 @@ Write the complete compiled report:"""
print("pip install 'praisonaiagents[api]'")
return None
# Initialize port-specific collections if needed
if port not in _registered_agents:
_registered_agents[port] = {}
# Initialize shared FastAPI app if not already created for this port
if _shared_apps.get(port) is None:
_shared_apps[port] = FastAPI(
title=f"PraisonAI Agents API (Port {port})",
description="API for interacting with PraisonAI Agents"
)
# Add a root endpoint with a welcome message
@_shared_apps[port].get("/")
async def root():
return {
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
"endpoints": list(_registered_agents[port].keys())
}
# Add healthcheck endpoint
@_shared_apps[port].get("/health")
async def healthcheck():
return {
"status": "ok",
"endpoints": list(_registered_agents[port].keys())
}
# Normalize path to ensure it starts with /
if not path.startswith('/'):
path = f'/{path}'
# Check if path is already registered for this port
if path in _registered_agents[port]:
logging.warning(f"Path '{path}' is already registered on port {port}. Please use a different path.")
print(f"⚠️ Warning: Path '{path}' is already registered on port {port}.")
# Use a modified path to avoid conflicts
original_path = path
path = f"{path}_{self.agent_id[:6]}"
logging.warning(f"Using '{path}' instead of '{original_path}'")
print(f"🔄 Using '{path}' instead")
# Register the agent to this path
_registered_agents[port][path] = self.agent_id
# Define the endpoint handler
@_shared_apps[port].post(path)
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
# Handle both direct JSON with query field and form data
if query_data is None:
try:
request_data = await request.json()
if "query" not in request_data:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
query = request_data["query"]
except Exception:
# Fallback to form data or query params
form_data = await request.form()
if "query" in form_data:
query = form_data["query"]
else:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
else:
query = query_data.query
try:
# Use async version if available, otherwise use sync version
if asyncio.iscoroutinefunction(self.chat):
response = await self.achat(query, task_name=None, task_description=None, task_id=None)
else:
# Run sync function in a thread to avoid blocking
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, lambda p=query: self.chat(p))
return {"response": response}
except Exception as e:
logging.error(f"Error processing query: {str(e)}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": f"Error processing query: {str(e)}"}
with _server_lock:
# Initialize port-specific collections if needed
if port not in _registered_agents:
_registered_agents[port] = {}
# Initialize shared FastAPI app if not already created for this port
if _shared_apps.get(port) is None:
_shared_apps[port] = FastAPI(
title=f"PraisonAI Agents API (Port {port})",
description="API for interacting with PraisonAI Agents"
)
print(f"🚀 Agent '{self.name}' available at http://{host}:{port}")
# Start the server if it's not already running for this port
if not _server_started.get(port, False):
# Mark the server as started first to prevent duplicate starts
_server_started[port] = True
# Add a root endpoint with a welcome message
@_shared_apps[port].get("/")
async def root():
return {
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
"endpoints": list(_registered_agents[port].keys())
}
# Add healthcheck endpoint
@_shared_apps[port].get("/health")
async def healthcheck():
return {
"status": "ok",
"endpoints": list(_registered_agents[port].keys())
}
# Normalize path to ensure it starts with /
if not path.startswith('/'):
path = f'/{path}'
# Check if path is already registered for this port
if path in _registered_agents[port]:
logging.warning(f"Path '{path}' is already registered on port {port}. Please use a different path.")
print(f"⚠️ Warning: Path '{path}' is already registered on port {port}.")
# Use a modified path to avoid conflicts
original_path = path
path = f"{path}_{self.agent_id[:6]}"
logging.warning(f"Using '{path}' instead of '{original_path}'")
print(f"🔄 Using '{path}' instead")
# Register the agent to this path
_registered_agents[port][path] = self.agent_id
# Define the endpoint handler
@_shared_apps[port].post(path)
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
# Handle both direct JSON with query field and form data
if query_data is None:
try:
request_data = await request.json()
if "query" not in request_data:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
query = request_data["query"]
except Exception:
# Fallback to form data or query params
form_data = await request.form()
if "query" in form_data:
query = form_data["query"]
else:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
else:
query = query_data.query
try:
# Use async version if available, otherwise use sync version
if asyncio.iscoroutinefunction(self.chat):
response = await self.achat(query, task_name=None, task_description=None, task_id=None)
else:
# Run sync function in a thread to avoid blocking
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, lambda p=query: self.chat(p))
return {"response": response}
except Exception as e:
logging.error(f"Error processing query: {str(e)}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": f"Error processing query: {str(e)}"}
)
print(f"🚀 Agent '{self.name}' available at http://{host}:{port}")
# Start the server if it's not already running for this port
should_start = not _server_started.get(port, False)
if should_start:
_server_started[port] = True
# Server start/wait outside the lock to avoid holding it during sleep
if should_start:
# Start the server in a separate thread
def run_server():
try:
@@ -8380,11 +8523,11 @@ Write the complete compiled report:"""
except Exception as e:
logging.error(f"Error starting server: {str(e)}", exc_info=True)
print(f"❌ Error starting server: {str(e)}")
# Run server in a background thread
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Wait for a moment to allow the server to start and register endpoints
time.sleep(0.5)
else:
@@ -0,0 +1,399 @@
"""
Tool Loop Detection for PraisonAI Agents.
Detects when an agent is stuck calling the same tool repeatedly with
identical arguments and no progress.
Inspired by OpenClaw's tool-loop-detection.ts (624 lines, 4 detectors).
Adapted to Python with PraisonAI's protocol-driven, zero-dep philosophy.
Zero Performance Impact:
- Disabled by default (enabled=False)
- Uses stdlib only: hashlib, json
- History is per-agent-instance (no global mutable state)
Usage:
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, detect_tool_loop, record_tool_call, record_tool_outcome
)
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal, Optional, TypedDict
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass
class LoopDetectionConfig:
"""
Configuration for tool loop detection.
Attributes:
enabled: Whether loop detection is active. Default False (opt-in).
history_size: Sliding window size for tool call history.
warn_threshold: Number of identical (tool, args) calls before warning.
critical_threshold: Number before critical/circuit-break. Must > warn.
detectors: Which detectors are active.
"""
enabled: bool = False
history_size: int = 30
warn_threshold: int = 10
critical_threshold: int = 20
detectors: Dict[str, bool] = field(default_factory=lambda: {
"generic_repeat": True,
"poll_no_progress": True,
"ping_pong": True,
})
def __post_init__(self) -> None:
# Auto-correct: critical must always be > warn
if self.critical_threshold <= self.warn_threshold:
self.critical_threshold = self.warn_threshold + 1
# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------
class LoopDetectionResult(TypedDict, total=False):
stuck: bool
level: Optional[Literal["warning", "critical"]]
detector: Optional[str]
message: Optional[str]
count: int
_NOT_STUCK: LoopDetectionResult = {"stuck": False}
# ---------------------------------------------------------------------------
# History record
# ---------------------------------------------------------------------------
class ToolCallRecord(TypedDict, total=False):
tool_name: str
args_hash: str
result_hash: Optional[str]
timestamp: float
# ---------------------------------------------------------------------------
# Hashing helpers
# ---------------------------------------------------------------------------
def _stable_json(value: Any) -> str:
"""Deterministic JSON serialization (sorted keys, str fallback)."""
if value is None or isinstance(value, (bool, int, float, str)):
return json.dumps(value)
if isinstance(value, (list, tuple)):
return "[" + ",".join(_stable_json(v) for v in value) + "]"
if isinstance(value, dict):
keys = sorted(value.keys())
parts = [json.dumps(k) + ":" + _stable_json(value[k]) for k in keys]
return "{" + ",".join(parts) + "}"
# Fallback for non-serializable objects
try:
return json.dumps(str(value))
except Exception:
return '"<unserializable>"'
def _sha256_hex(text: str, prefix_len: int = 16) -> str:
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:prefix_len]
def hash_tool_call(tool_name: str, args: Any) -> str:
"""
Compute a stable hash for a (tool_name, args) pair.
Args:
tool_name: Name of the tool being called
args: Arguments dict or any serializable value
Returns:
16-character hex string
"""
stable = _stable_json({"t": tool_name, "a": args})
return _sha256_hex(stable)
def _hash_result(result: Any) -> Optional[str]:
"""Hash a tool result for no-progress detection."""
if result is None:
return None
try:
stable = _stable_json(result)
return _sha256_hex(stable)
except Exception:
return None
# ---------------------------------------------------------------------------
# Record tool call / outcome
# ---------------------------------------------------------------------------
def record_tool_call(
history: List[ToolCallRecord],
tool_name: str,
args: Any,
config: Optional[LoopDetectionConfig] = None,
) -> None:
"""
Append a tool call to the history (before execution).
Args:
history: Mutable list of ToolCallRecord
tool_name: Tool being called
args: Tool arguments
config: Loop detection config (for history_size)
"""
max_size = config.history_size if config else 30
history.append(ToolCallRecord(
tool_name=tool_name,
args_hash=hash_tool_call(tool_name, args),
result_hash=None,
timestamp=time.time(),
))
# Trim to sliding window
if len(history) > max_size:
del history[: len(history) - max_size]
def record_tool_outcome(
history: List[ToolCallRecord],
tool_name: str,
args: Any,
result: Any,
config: Optional[LoopDetectionConfig] = None,
) -> None:
"""
Update the most recent matching record with result hash (after execution).
Args:
history: Mutable list of ToolCallRecord
tool_name: Tool that was called
args: Arguments that were passed
result: The return value of the tool
config: Loop detection config
"""
args_hash = hash_tool_call(tool_name, args)
result_hash = _hash_result(result)
if result_hash is None:
return
# Find the latest matching record without a result_hash
for i in range(len(history) - 1, -1, -1):
rec = history[i]
if rec["tool_name"] == tool_name and rec["args_hash"] == args_hash and rec.get("result_hash") is None:
history[i] = ToolCallRecord(
tool_name=rec["tool_name"],
args_hash=rec["args_hash"],
result_hash=result_hash,
timestamp=rec["timestamp"],
)
return
# ---------------------------------------------------------------------------
# Detectors
# ---------------------------------------------------------------------------
def _known_poll_tool(tool_name: str) -> bool:
"""Heuristic: tools whose names suggest polling/waiting."""
_poll_keywords = ("status", "poll", "check", "wait", "ping", "health", "command_status")
name_lower = tool_name.lower()
return any(kw in name_lower for kw in _poll_keywords)
def _count_generic_repeat(
history: List[ToolCallRecord],
args_hash: str,
tool_name: str,
) -> int:
"""Count how many times this exact (tool, args) appears in history."""
return sum(
1 for rec in history
if rec["tool_name"] == tool_name and rec["args_hash"] == args_hash
)
def _no_progress_streak(
history: List[ToolCallRecord],
args_hash: str,
tool_name: str,
) -> int:
"""
Count consecutive tail records with identical (args_hash, result_hash).
Returns 0 if any record has no result_hash or a different result_hash.
"""
streak = 0
first_result_hash: Optional[str] = None
for rec in reversed(history):
if rec["tool_name"] != tool_name or rec["args_hash"] != args_hash:
continue
rh = rec.get("result_hash")
if rh is None:
continue
if first_result_hash is None:
first_result_hash = rh
streak = 1
elif rh == first_result_hash:
streak += 1
else:
break # result changed — no longer a streak
return streak
def _ping_pong_streak(
history: List[ToolCallRecord],
current_hash: str,
) -> int:
"""
Detect alternating pattern: A, B, A, B, ...
Returns count of consecutive alternating tail entries, 0 if not ping-pong.
"""
if len(history) < 2:
return 0
last = history[-1]
# Find the "other" hash in history (most recent != last)
other_hash: Optional[str] = None
for rec in reversed(history[:-1]):
if rec["args_hash"] != last["args_hash"]:
other_hash = rec["args_hash"]
break
if other_hash is None:
return 0
# Count tail alternation
count = 0
for i, rec in enumerate(reversed(history)):
expected = last["args_hash"] if i % 2 == 0 else other_hash
if rec["args_hash"] != expected:
break
count += 1
# Current call must continue the ping-pong pattern
if current_hash != (other_hash if count % 2 == 1 else last["args_hash"]):
return 0
return count + 1 # include current call
# ---------------------------------------------------------------------------
# Main detection function
# ---------------------------------------------------------------------------
def detect_tool_loop(
history: List[ToolCallRecord],
tool_name: str,
args: Any,
config: Optional[LoopDetectionConfig] = None,
) -> LoopDetectionResult:
"""
Detect if agent is stuck in a tool call loop.
Args:
history: Mutable sliding window of ToolCallRecord
tool_name: Tool being called now
args: Arguments for this call
config: Loop detection configuration
Returns:
LoopDetectionResult with stuck=False (safe) or stuck=True (warning/critical)
"""
if config is None or not config.enabled:
return _NOT_STUCK
args_hash = hash_tool_call(tool_name, args)
detectors = config.detectors or {}
warn = config.warn_threshold
critical = config.critical_threshold
# --- poll_no_progress detector ---
if detectors.get("poll_no_progress", True) and _known_poll_tool(tool_name):
streak = _no_progress_streak(history, args_hash, tool_name)
if streak >= critical:
msg = (
f"CRITICAL: '{tool_name}' called {streak} times with identical args "
f"and identical result. Detected stuck polling loop. Stop and report failure."
)
logger.error(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="critical",
detector="poll_no_progress", message=msg, count=streak,
)
if streak >= warn:
msg = (
f"WARNING: '{tool_name}' has returned identical results {streak} times. "
f"This looks like a stuck poll. Increase wait time or report failure."
)
logger.warning(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="warning",
detector="poll_no_progress", message=msg, count=streak,
)
# --- ping_pong detector ---
if detectors.get("ping_pong", True):
pp_count = _ping_pong_streak(history, args_hash)
if pp_count >= critical:
msg = (
f"CRITICAL: Ping-pong loop detected ({pp_count} alternating calls). "
f"Agent is oscillating between two tool states. Stop and report failure."
)
logger.error(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="critical",
detector="ping_pong", message=msg, count=pp_count,
)
if pp_count >= warn:
msg = (
f"WARNING: Alternating tool pattern detected ({pp_count} calls). "
f"Stop ping-pong and try a different approach."
)
logger.warning(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="warning",
detector="ping_pong", message=msg, count=pp_count,
)
# --- generic_repeat detector ---
if detectors.get("generic_repeat", True) and not _known_poll_tool(tool_name):
count = _count_generic_repeat(history, args_hash, tool_name)
if count >= critical:
msg = (
f"CRITICAL: '{tool_name}' called {count} times with identical args. "
f"Agent is stuck. Execution blocked."
)
logger.error(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="critical",
detector="generic_repeat", message=msg, count=count,
)
if count >= warn:
msg = (
f"WARNING: '{tool_name}' called {count} times with identical args. "
f"If not making progress, stop and report failure."
)
logger.warning(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="warning",
detector="generic_repeat", message=msg, count=count,
)
return _NOT_STUCK
@@ -698,7 +698,15 @@ class ExecutionConfig:
# Rate limiter instance (consolidated from standalone rate_limiter param)
rate_limiter: Optional[Any] = None
# Context compaction: automatically compact chat_history when approaching token limit.
# Opt-in only (safe default: False). Uses existing praisonaiagents.compaction module.
# Usage: Agent(execution=ExecutionConfig(context_compaction=True, max_context_tokens=8000))
context_compaction: bool = False
# Token limit before compaction triggers. None = auto-detect from model metadata.
max_context_tokens: Optional[int] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -708,6 +716,8 @@ class ExecutionConfig:
"max_retry_limit": self.max_retry_limit,
"code_execution": self.code_execution,
"code_mode": self.code_mode,
"context_compaction": self.context_compaction,
"max_context_tokens": self.max_context_tokens,
}
@@ -71,7 +71,9 @@ except ImportError:
# Logging is already configured in _logging.py via __init__.py
# TODO: Include in-build tool calling in LLM class
# TODO: Restructure so that duplicate calls are not made (Sync with agent.py)
# NOTE: The custom-LLM path (Agent.chat → get_response) and OpenAI path
# (Agent.chat → _chat_completion) are separate code paths, not duplicate
# API calls per request. This is a DRY/maintenance concern, not a billing issue.
class LLMContextLengthExceededException(Exception):
"""Raised when LLM context length is exceeded"""
def __init__(self, message: str):
@@ -0,0 +1,135 @@
"""
Tool Loop Detection Plugin for PraisonAI Agents.
Registers a BEFORE_TOOL hook that detects repetitive tool call patterns
and blocks the agent when stuck in a loop.
Inspired by OpenClaw's tool-loop-detection.ts (624 lines, 4 detectors).
Usage:
# Import to auto-register the hook globally
import praisonaiagents.plugins.loop_detection_plugin
# Or use with an agent via explicit hook registration
from praisonaiagents import Agent
from praisonaiagents.hooks.registry import get_default_registry
from praisonaiagents.hooks.types import HookEvent
Zero Performance Impact:
- Only active when this module is imported
- Uses stdlib only (hashlib, json)
- Disabled in production unless explicitly imported
"""
from __future__ import annotations
import logging
import threading
from typing import List
from praisonaiagents.hooks.registry import add_hook
from praisonaiagents.hooks.types import HookEvent, HookInput, HookResult
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig,
LoopDetectionResult,
detect_tool_loop,
record_tool_call,
ToolCallRecord,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Default configuration (can be overridden before import)
# ---------------------------------------------------------------------------
DEFAULT_CONFIG = LoopDetectionConfig(
enabled=True,
history_size=30,
warn_threshold=10,
critical_threshold=20,
)
# ---------------------------------------------------------------------------
# Per-thread history (avoids shared mutable state across agents/threads)
# ---------------------------------------------------------------------------
_thread_local = threading.local()
def _get_history() -> List[ToolCallRecord]:
"""Get the thread-local tool call history."""
if not hasattr(_thread_local, "history"):
_thread_local.history = []
return _thread_local.history
def reset_history() -> None:
"""Reset the tool call history for the current thread. Useful in tests."""
_thread_local.history = []
# ---------------------------------------------------------------------------
# Hook implementation
# ---------------------------------------------------------------------------
def _loop_detection_hook(event: HookInput) -> HookResult:
"""
BEFORE_TOOL hook: detect if agent is stuck in a tool call loop.
Returns:
- HookResult.allow() if not stuck (or below warning threshold)
- HookResult.allow(reason=warning_msg) if at warning threshold
- HookResult.block(reason=critical_msg) if at critical threshold
"""
# BeforeToolInput has tool_name and tool_input fields.
# Fall back to event.extra for compatibility with custom callers.
tool_name: str = (
getattr(event, "tool_name", None)
or event.extra.get("tool_name", "")
)
tool_args = (
getattr(event, "tool_input", None)
or event.extra.get("tool_args", {})
or {}
)
history = _get_history()
# Check before recording
result: LoopDetectionResult = detect_tool_loop(history, tool_name, tool_args, DEFAULT_CONFIG)
# Record the call (after detection to count existing history)
record_tool_call(history, tool_name, tool_args, DEFAULT_CONFIG)
if not result.get("stuck"):
return HookResult.allow()
level = result.get("level")
message = result.get("message", "Tool loop detected")
count = result.get("count", 0)
detector = result.get("detector", "unknown")
if level == "critical":
logger.error(
f"[loop_detection_plugin] BLOCKED: {detector} detector, "
f"tool={tool_name!r}, count={count}"
)
return HookResult.block(message)
# Warning: allow but annotate (agent can see the warning in hook output)
logger.warning(
f"[loop_detection_plugin] WARNING: {detector} detector, "
f"tool={tool_name!r}, count={count}"
)
# Return allow with additional_context so the runner can pass it back
return HookResult(decision="allow", reason=message)
# ---------------------------------------------------------------------------
# Register hook on import (side effect is intentional for plugin pattern)
# ---------------------------------------------------------------------------
_hook_id = add_hook(HookEvent.BEFORE_TOOL, _loop_detection_hook)
logger.debug(f"[loop_detection_plugin] Registered BEFORE_TOOL hook: {_hook_id}")
@@ -17,276 +17,296 @@ from contextlib import redirect_stdout, redirect_stderr
import traceback
from ..approval import require_approval
def _safe_getattr(obj, name, *default):
"""getattr wrapper that blocks access to dunder attributes."""
if isinstance(name, str) and name.startswith('_'):
raise AttributeError(
f"Access to private/protected attribute '{name}' is restricted"
)
return getattr(obj, name, *default) if default else getattr(obj, name)
def _validate_code_ast(code: str):
"""Validate code using AST — catches attacks that bypass text checks.
Returns error message string if dangerous, None if safe.
"""
import ast
try:
tree = ast.parse(code)
except SyntaxError:
return None # let compile() handle syntax errors later
# Dangerous dunder attributes attackers use for sandbox escape
_blocked_attrs = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
})
for node in ast.walk(tree):
# Block import statements
if isinstance(node, (ast.Import, ast.ImportFrom)):
return f"Import statements are not allowed"
# Block attribute access to dangerous dunders
if isinstance(node, ast.Attribute):
if node.attr in _blocked_attrs:
return (
f"Access to attribute '{node.attr}' is restricted"
)
# Block calls to dangerous builtins by name
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in (
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir',
):
return f"Call to '{func.id}' is not allowed"
return None
# ──────────────────────────────────────────────────────────────────────
# Standalone execute_code — NO optional deps required (no black/pylint)
# ──────────────────────────────────────────────────────────────────────
@require_approval(risk_level="critical")
def execute_code(
code: str,
globals_dict: Optional[Dict[str, Any]] = None,
locals_dict: Optional[Dict[str, Any]] = None,
timeout: int = 30,
max_output_size: int = 10000
) -> Dict[str, Any]:
"""Execute Python code safely with restricted builtins.
This function is available without any optional dependencies.
It uses a 3-layer security sandbox:
1. AST-based validation (blocks imports, dangerous dunders, eval/exec)
2. Text-pattern blocklist (defense-in-depth)
3. Restricted __builtins__ (only safe functions exposed)
"""
try:
# Create safe builtins - restricted set of functions
safe_builtins = {
# Basic functions
'print': print,
'len': len,
'range': range,
'enumerate': enumerate,
'zip': zip,
'map': map,
'filter': filter,
'sum': sum,
'min': min,
'max': max,
'abs': abs,
'round': round,
'sorted': sorted,
'reversed': reversed,
'any': any,
'all': all,
# Type constructors
'int': int,
'float': float,
'str': str,
'bool': bool,
'list': list,
'tuple': tuple,
'dict': dict,
'set': set,
# Math functions
'pow': pow,
'divmod': divmod,
# Exceptions
'Exception': Exception,
'ValueError': ValueError,
'TypeError': TypeError,
'KeyError': KeyError,
'IndexError': IndexError,
'RuntimeError': RuntimeError,
# Safe introspection (dunder-blocked wrapper)
'isinstance': isinstance,
'type': type,
'hasattr': hasattr,
'getattr': _safe_getattr,
# Class definition support
'__build_class__': __builtins__['__build_class__'] if isinstance(__builtins__, dict) else getattr(__builtins__, '__build_class__', None),
# Disable dangerous functions
'__import__': None,
'eval': None,
'exec': None,
'compile': None,
'open': None,
'input': None,
'globals': None,
'locals': None,
'vars': None,
}
# Set up execution environment with safe builtins
if globals_dict is None:
globals_dict = {'__builtins__': safe_builtins}
else:
# Override builtins in provided globals
globals_dict['__builtins__'] = safe_builtins
if locals_dict is None:
locals_dict = {}
# Security check 1: AST-based validation (cannot be bypassed
# by string concatenation or runtime tricks)
ast_error = _validate_code_ast(code)
if ast_error:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: {ast_error}',
'success': False
}
# Security check 2: text-based patterns (defense-in-depth)
dangerous_patterns = [
'__import__', 'import ', 'from ', 'exec', 'eval',
'compile', 'open(', 'file(', 'input(', 'raw_input',
'__subclasses__', '__bases__', '__globals__', '__code__',
'__class__', 'globals(', 'locals(', 'vars('
]
code_lower = code.lower()
for pattern in dangerous_patterns:
if pattern.lower() in code_lower:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: Code contains restricted pattern: {pattern}',
'success': False
}
# Capture output
stdout_buffer = io.StringIO()
stderr_buffer = io.StringIO()
try:
# Compile code with restricted mode
compiled_code = compile(code, '<string>', 'exec')
# Execute with output capture
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
exec(compiled_code, globals_dict, locals_dict)
# Get last expression value if any
import ast
tree = ast.parse(code)
if tree.body and isinstance(tree.body[-1], ast.Expr):
result = eval(
compile(ast.Expression(tree.body[-1].value), '<string>', 'eval'),
globals_dict,
locals_dict
)
else:
result = None
# Get output
stdout = stdout_buffer.getvalue()
stderr = stderr_buffer.getvalue()
# Truncate output if too large (use smart format)
if len(stdout) > max_output_size:
tail_size = min(max_output_size // 5, 500)
stdout = stdout[:max_output_size - tail_size] + f"\n...[{len(stdout):,} chars, showing first/last portions]...\n" + stdout[-tail_size:]
if len(stderr) > max_output_size:
tail_size = min(max_output_size // 5, 500)
stderr = stderr[:max_output_size - tail_size] + f"\n...[{len(stderr):,} chars, showing first/last portions]...\n" + stderr[-tail_size:]
return {
'result': result,
'stdout': stdout,
'stderr': stderr,
'success': True
}
except Exception as e:
error_msg = f"Error executing code: {str(e)}"
logging.error(error_msg)
return {
'result': None,
'stdout': stdout_buffer.getvalue(),
'stderr': error_msg,
'success': False
}
except Exception as e:
error_msg = f"Error executing code: {str(e)}"
logging.error(error_msg)
return {
'result': None,
'stdout': '',
'stderr': error_msg,
'success': False
}
# ──────────────────────────────────────────────────────────────────────
# PythonTools class — requires optional deps (black, pylint, autopep8)
# Only instantiated when analyze_code/format_code/lint_code are needed.
# ──────────────────────────────────────────────────────────────────────
class PythonTools:
"""Tools for Python code execution and analysis."""
"""Tools for Python code analysis, formatting, and linting.
Requires: pip install black pylint autopep8
For code execution only, use the standalone execute_code() function.
"""
def __init__(self):
"""Initialize PythonTools."""
"""Initialize PythonTools — checks for required packages."""
self._check_dependencies()
def _check_dependencies(self):
"""Check if required packages are installed."""
missing = []
for package in ['black', 'pylint', 'autopep8']:
if util.find_spec(package) is None:
missing.append(package)
if missing:
raise ImportError(
f"Required packages not available. Please install: {', '.join(missing)}\n"
f"Run: pip install {' '.join(missing)}"
)
@staticmethod
def _safe_getattr(obj, name, *default):
"""getattr wrapper that blocks access to dunder attributes."""
if isinstance(name, str) and name.startswith('_'):
raise AttributeError(
f"Access to private/protected attribute '{name}' is restricted"
)
return getattr(obj, name, *default) if default else getattr(obj, name)
@staticmethod
def _validate_code_ast(code: str):
"""Validate code using AST — catches attacks that bypass text checks.
Returns error message string if dangerous, None if safe.
"""
import ast
try:
tree = ast.parse(code)
except SyntaxError:
return None # let compile() handle syntax errors later
# Dangerous dunder attributes attackers use for sandbox escape
_blocked_attrs = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
})
for node in ast.walk(tree):
# Block import statements
if isinstance(node, (ast.Import, ast.ImportFrom)):
return f"Import statements are not allowed"
# Block attribute access to dangerous dunders
if isinstance(node, ast.Attribute):
if node.attr in _blocked_attrs:
return (
f"Access to attribute '{node.attr}' is restricted"
)
# Block calls to dangerous builtins by name
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in (
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir',
):
return f"Call to '{func.id}' is not allowed"
return None
@require_approval(risk_level="critical")
def execute_code(
self,
code: str,
globals_dict: Optional[Dict[str, Any]] = None,
locals_dict: Optional[Dict[str, Any]] = None,
timeout: int = 30,
max_output_size: int = 10000
) -> Dict[str, Any]:
"""Execute Python code safely with restricted builtins."""
try:
# Create safe builtins - restricted set of functions
safe_builtins = {
# Basic functions
'print': print,
'len': len,
'range': range,
'enumerate': enumerate,
'zip': zip,
'map': map,
'filter': filter,
'sum': sum,
'min': min,
'max': max,
'abs': abs,
'round': round,
'sorted': sorted,
'reversed': reversed,
'any': any,
'all': all,
# Type constructors
'int': int,
'float': float,
'str': str,
'bool': bool,
'list': list,
'tuple': tuple,
'dict': dict,
'set': set,
# Math functions
'pow': pow,
'divmod': divmod,
# Exceptions
'Exception': Exception,
'ValueError': ValueError,
'TypeError': TypeError,
'KeyError': KeyError,
'IndexError': IndexError,
'RuntimeError': RuntimeError,
# Safe introspection (dunder-blocked wrapper)
'isinstance': isinstance,
'type': type,
'hasattr': hasattr,
'getattr': self._safe_getattr,
# Class definition support
'__build_class__': __builtins__['__build_class__'] if isinstance(__builtins__, dict) else getattr(__builtins__, '__build_class__', None),
# Disable dangerous functions
'__import__': None,
'eval': None,
'exec': None,
'compile': None,
'open': None,
'input': None,
'globals': None,
'locals': None,
'vars': None,
}
# Set up execution environment with safe builtins
if globals_dict is None:
globals_dict = {'__builtins__': safe_builtins}
else:
# Override builtins in provided globals
globals_dict['__builtins__'] = safe_builtins
if locals_dict is None:
locals_dict = {}
# Security check 1: AST-based validation (cannot be bypassed
# by string concatenation or runtime tricks)
ast_error = self._validate_code_ast(code)
if ast_error:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: {ast_error}',
'success': False
}
# Security check 2: text-based patterns (defense-in-depth)
dangerous_patterns = [
'__import__', 'import ', 'from ', 'exec', 'eval',
'compile', 'open(', 'file(', 'input(', 'raw_input',
'__subclasses__', '__bases__', '__globals__', '__code__',
'__class__', 'globals(', 'locals(', 'vars('
]
code_lower = code.lower()
for pattern in dangerous_patterns:
if pattern.lower() in code_lower:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: Code contains restricted pattern: {pattern}',
'success': False
}
# Capture output
stdout_buffer = io.StringIO()
stderr_buffer = io.StringIO()
try:
# Compile code with restricted mode
compiled_code = compile(code, '<string>', 'exec')
# Execute with output capture
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
exec(compiled_code, globals_dict, locals_dict)
# Get last expression value if any
import ast
tree = ast.parse(code)
if tree.body and isinstance(tree.body[-1], ast.Expr):
result = eval(
compile(ast.Expression(tree.body[-1].value), '<string>', 'eval'),
globals_dict,
locals_dict
)
else:
result = None
# Get output
stdout = stdout_buffer.getvalue()
stderr = stderr_buffer.getvalue()
# Truncate output if too large (use smart format)
if len(stdout) > max_output_size:
tail_size = min(max_output_size // 5, 500)
stdout = stdout[:max_output_size - tail_size] + f"\n...[{len(stdout):,} chars, showing first/last portions]...\n" + stdout[-tail_size:]
if len(stderr) > max_output_size:
tail_size = min(max_output_size // 5, 500)
stderr = stderr[:max_output_size - tail_size] + f"\n...[{len(stderr):,} chars, showing first/last portions]...\n" + stderr[-tail_size:]
return {
'result': result,
'stdout': stdout,
'stderr': stderr,
'success': True
}
except Exception as e:
error_msg = f"Error executing code: {str(e)}"
logging.error(error_msg)
return {
'result': None,
'stdout': stdout_buffer.getvalue(),
'stderr': error_msg,
'success': False
}
except Exception as e:
error_msg = f"Error executing code: {str(e)}"
logging.error(error_msg)
return {
'result': None,
'stdout': '',
'stderr': error_msg,
'success': False
}
def analyze_code(
self,
code: str
) -> Optional[Dict[str, Any]]:
"""Analyze Python code structure and quality.
Args:
code: Python code to analyze
Returns:
Dictionary with analysis results
"""
try:
# Import ast only when needed
import ast
# Parse code
tree = ast.parse(code)
# Analyze structure
analysis = {
'imports': [],
@@ -300,7 +320,7 @@ class PythonTools:
'branches': 0
}
}
# Analyze nodes
for node in ast.walk(tree):
if isinstance(node, ast.Import):
@@ -338,7 +358,7 @@ class PythonTools:
analysis['variables'].append(target.id)
elif isinstance(node, (ast.If, ast.While, ast.For)):
analysis['complexity']['branches'] += 1
return analysis
except Exception as e:
error_msg = f"Error analyzing code: {str(e)}"
@@ -352,12 +372,12 @@ class PythonTools:
line_length: int = 88
) -> Optional[str]:
"""Format Python code according to style guide.
Args:
code: Python code to format
style: Formatting style ('black' or 'pep8')
line_length: Maximum line length
Returns:
Formatted code
"""
@@ -393,10 +413,10 @@ class PythonTools:
code: str
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
"""Lint Python code for potential issues.
Args:
code: Python code to lint
Returns:
Dictionary with linting results
"""
@@ -409,7 +429,7 @@ class PythonTools:
# Import pylint only when needed
from pylint.reporters import JSONReporter
from pylint.lint.run import Run
# Create temporary file for pylint
import tempfile
with tempfile.NamedTemporaryFile(
@@ -419,7 +439,7 @@ class PythonTools:
) as f:
f.write(code)
temp_path = f.name
# Run pylint
reporter = JSONReporter()
Run(
@@ -427,14 +447,14 @@ class PythonTools:
reporter=reporter,
exit=False
)
# Process results
results = {
'errors': [],
'warnings': [],
'conventions': []
}
for msg in reporter.messages:
item = {
'type': msg.category,
@@ -447,18 +467,18 @@ class PythonTools:
'message': msg.msg,
'message-id': msg.msg_id
}
if msg.category in ['error', 'fatal']:
results['errors'].append(item)
elif msg.category == 'warning':
results['warnings'].append(item)
else:
results['conventions'].append(item)
# Clean up
import os
os.unlink(temp_path)
return results
except Exception as e:
error_msg = f"Error linting code: {str(e)}"
@@ -474,38 +494,60 @@ class PythonTools:
code: str
) -> Optional[str]:
"""Disassemble Python code to bytecode.
Args:
code: Python code to disassemble
Returns:
Disassembled bytecode as string
"""
try:
# Import dis only when needed
import dis
# Compile code
compiled_code = compile(code, '<string>', 'exec')
# Capture disassembly
output = io.StringIO()
with redirect_stdout(output):
dis.dis(compiled_code)
return output.getvalue()
except Exception as e:
error_msg = f"Error disassembling code: {str(e)}"
logging.error(error_msg)
return None
# Create instance for direct function access
_python_tools = PythonTools()
execute_code = _python_tools.execute_code
analyze_code = _python_tools.analyze_code
format_code = _python_tools.format_code
lint_code = _python_tools.lint_code
disassemble_code = _python_tools.disassemble_code
# Lazy accessors for optional-dep tools (PythonTools requires black/pylint/autopep8)
# execute_code is already a standalone function above — always available.
def _get_python_tools():
"""Lazy-init PythonTools (requires black/pylint/autopep8)."""
global _python_tools_instance
try:
return _python_tools_instance
except NameError:
_python_tools_instance = PythonTools()
return _python_tools_instance
_python_tools_instance = None
def analyze_code(code: str) -> Optional[Dict[str, Any]]:
"""Analyze Python code structure and quality. Requires: pip install black pylint autopep8"""
return _get_python_tools().analyze_code(code)
def format_code(code: str, style: str = 'black', line_length: int = 88) -> Optional[str]:
"""Format Python code. Requires: pip install black pylint autopep8"""
return _get_python_tools().format_code(code, style, line_length)
def lint_code(code: str) -> Optional[Dict[str, List[Dict[str, Any]]]]:
"""Lint Python code. Requires: pip install black pylint autopep8"""
return _get_python_tools().lint_code(code)
def disassemble_code(code: str) -> Optional[str]:
"""Disassemble Python code to bytecode. Requires: pip install black pylint autopep8"""
return _get_python_tools().disassemble_code(code)
if __name__ == "__main__":
print("\n==================================================")
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.80"
version = "1.5.85"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -29,7 +29,7 @@ mcp = [
memory = [
"chromadb>=1.0.0",
"litellm>=1.81.0",
"litellm>=1.81.0,<=1.82.6",
]
knowledge = [
@@ -47,7 +47,7 @@ graph = [
# Add LLM dependencies
llm = [
"litellm>=1.81.0",
"litellm>=1.81.0,<=1.82.6",
"pydantic>=2.10.0"
]
+9 -6
View File
@@ -24,9 +24,12 @@ if __name__ == "__main__":
script_to_run = sys.argv[1]
# Execute the target script
with open(script_to_run, 'r') as f:
script_content = f.read()
# Execute in the same namespace
exec(script_content, {'__name__': '__main__'})
# Execute the target script in a subprocess to avoid sharing the current namespace
import subprocess
result = subprocess.run(
[sys.executable, script_to_run],
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
)
sys.exit(result.returncode)
@@ -0,0 +1,73 @@
"""
TDD tests for compaction wiring into agent.
Tests are written BEFORE implementation (TDD).
Verifies ExecutionConfig gets context_compaction field,
and ContextCompactor is called when enabled.
"""
import pytest
from unittest.mock import MagicMock, patch
class TestExecutionConfigCompactionFields:
"""ExecutionConfig must have context_compaction and max_context_tokens fields."""
def test_has_context_compaction_field(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig()
assert hasattr(cfg, "context_compaction")
assert cfg.context_compaction is False # safe default
def test_has_max_context_tokens_field(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig()
assert hasattr(cfg, "max_context_tokens")
assert cfg.max_context_tokens is None # auto by default
def test_context_compaction_opt_in(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig(context_compaction=True, max_context_tokens=8000)
assert cfg.context_compaction is True
assert cfg.max_context_tokens == 8000
class TestContextCompactorExists:
"""ContextCompactor must be importable and functional (existing module)."""
def test_import(self):
from praisonaiagents.compaction import ContextCompactor
assert ContextCompactor is not None
def test_needs_compaction_false_within_limit(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=10000)
short_msgs = [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi"}]
assert compactor.needs_compaction(short_msgs) is False
def test_needs_compaction_true_over_limit(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=10) # tiny limit
long_msgs = [{"role": "user", "content": "A" * 1000}]
assert compactor.needs_compaction(long_msgs) is True
def test_compact_reduces_tokens(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=100, preserve_recent=1)
many_msgs = [{"role": "user", "content": "Message " + str(i) * 50} for i in range(20)]
compacted_msgs, result = compactor.compact(many_msgs)
assert result.compacted_tokens < result.original_tokens
class TestCompactionHookEvents:
"""BEFORE_COMPACTION and AFTER_COMPACTION must be defined in HookEvent."""
def test_before_compaction_event_exists(self):
from praisonaiagents.hooks.types import HookEvent
assert hasattr(HookEvent, "BEFORE_COMPACTION")
assert HookEvent.BEFORE_COMPACTION == "before_compaction"
def test_after_compaction_event_exists(self):
from praisonaiagents.hooks.types import HookEvent
assert hasattr(HookEvent, "AFTER_COMPACTION")
assert HookEvent.AFTER_COMPACTION == "after_compaction"
@@ -0,0 +1,223 @@
"""
TDD tests for PraisonAI tool loop detection.
Tests are written BEFORE implementation (TDD).
Port of OpenClaw's tool-loop-detection.ts patterns to Python.
"""
import hashlib
import json
import pytest
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Helper: compute expected hash the same way the module will
# ---------------------------------------------------------------------------
def _hash(tool_name: str, args: dict) -> str:
stable = json.dumps({"t": tool_name, "a": args}, sort_keys=True, default=str)
return hashlib.sha256(stable.encode()).hexdigest()[:16]
# ---------------------------------------------------------------------------
# Import the module under test (will fail until implemented — that's TDD)
# ---------------------------------------------------------------------------
class TestLoopDetectionImport:
"""The module must be importable with zero side-effects."""
def test_module_importable(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig,
LoopDetectionResult,
detect_tool_loop,
record_tool_call,
record_tool_outcome,
hash_tool_call,
)
assert LoopDetectionConfig is not None
assert detect_tool_loop is not None
def test_no_side_effects_on_import(self):
"""Importing must not change global state or call LLM."""
import praisonaiagents.agent.loop_detection # noqa
class TestLoopDetectionConfig:
def setup_method(self):
from praisonaiagents.agent.loop_detection import LoopDetectionConfig
self.Config = LoopDetectionConfig
def test_default_disabled(self):
cfg = self.Config()
assert cfg.enabled is False # safe default: opt-in only
def test_custom_thresholds(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=5)
assert cfg.warn_threshold == 3
assert cfg.critical_threshold == 5
def test_critical_must_be_greater_than_warn(self):
"""If critical <= warn, it should be auto-corrected."""
cfg = self.Config(enabled=True, warn_threshold=10, critical_threshold=5)
assert cfg.critical_threshold > cfg.warn_threshold
class TestHashToolCall:
def test_hash_is_deterministic(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("search", {"query": "foo"})
assert h1 == h2
def test_different_args_produce_different_hashes(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("search", {"query": "bar"})
assert h1 != h2
def test_different_tools_produce_different_hashes(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("fetch", {"query": "foo"})
assert h1 != h2
def test_arg_order_does_not_matter(self):
"""Stable JSON sort keys: {'a':1,'b':2} == {'b':2,'a':1}"""
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("t", {"b": 2, "a": 1})
h2 = hash_tool_call("t", {"a": 1, "b": 2})
assert h1 == h2
class TestDetectToolLoop:
def setup_method(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig,
detect_tool_loop,
record_tool_call,
)
self.Config = LoopDetectionConfig
self.detect = detect_tool_loop
self.record = record_tool_call
# --- disabled ---
def test_disabled_always_returns_not_stuck(self):
cfg = self.Config(enabled=False)
history = []
# Fill history with many identical calls
for _ in range(50):
self.record(history, "search", {"q": "x"}, cfg)
result = self.detect(history, "search", {"q": "x"}, cfg)
assert result["stuck"] is False
# --- generic_repeat ---
def test_below_warn_threshold_not_stuck(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(4):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is False
def test_at_warn_threshold_warning(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(5):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is True
assert result["level"] == "warning"
assert result["detector"] == "generic_repeat"
def test_at_critical_threshold_critical(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(10):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is True
assert result["level"] == "critical"
def test_different_tools_no_false_positive(self):
"""Alternating different tools should NOT trigger generic_repeat."""
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=6)
history = []
for i in range(6):
self.record(history, "search", {"q": str(i)}, cfg) # different args each time
result = self.detect(history, "search", {"q": "0"}, cfg)
# Only 1 occurrence of {"q": "0"} — not stuck
assert result["stuck"] is False
# --- message content ---
def test_warning_message_contains_tool_name(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=10)
history = []
for _ in range(3):
self.record(history, "my_tool", {"x": 1}, cfg)
result = self.detect(history, "my_tool", {"x": 1}, cfg)
if result["stuck"]:
assert "my_tool" in result["message"]
def test_result_has_count_field(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=10)
history = []
for _ in range(3):
self.record(history, "t", {}, cfg)
result = self.detect(history, "t", {}, cfg)
assert "count" in result
class TestRecordToolCall:
def test_record_appends_to_history(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call
)
cfg = LoopDetectionConfig(enabled=True)
history = []
record_tool_call(history, "search", {"q": "test"}, cfg)
assert len(history) == 1
assert history[0]["tool_name"] == "search"
def test_history_respects_max_size(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call
)
cfg = LoopDetectionConfig(enabled=True, history_size=5)
history = []
for i in range(10):
record_tool_call(history, "t", {"i": i}, cfg)
assert len(history) <= 5
class TestRecordToolOutcome:
def test_outcome_updates_result_hash(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call, record_tool_outcome
)
cfg = LoopDetectionConfig(enabled=True)
history = []
record_tool_call(history, "search", {"q": "x"}, cfg)
assert history[0].get("result_hash") is None
record_tool_outcome(history, "search", {"q": "x"}, "some result", cfg)
assert history[0].get("result_hash") is not None
class TestLoopDetectionPlugin:
"""Tests for the plugin interface (hooks integration)."""
def test_plugin_importable(self):
from praisonaiagents.plugins import loop_detection_plugin # noqa
assert loop_detection_plugin is not None
def test_plugin_registers_hook(self):
"""Plugin import should register a BEFORE_TOOL hook."""
from praisonaiagents.hooks.registry import get_default_registry
from praisonaiagents.hooks.types import HookEvent
import praisonaiagents.plugins.loop_detection_plugin # noqa ensure imported
hooks = get_default_registry().get_hooks(HookEvent.BEFORE_TOOL)
assert len(hooks) >= 1
@@ -14,15 +14,15 @@ import pytest
class _SandboxHarness:
"""Directly exercises PythonTools sandbox without installing black/pylint."""
"""Directly exercises execute_code sandbox without installing black/pylint."""
def __init__(self):
# Import the class but bypass _check_dependencies
from praisonaiagents.tools.python_tools import PythonTools
self._pt = PythonTools.__new__(PythonTools)
# Import the standalone execute_code function (no optional deps needed)
from praisonaiagents.tools.python_tools import execute_code
self._execute_code = execute_code
def run(self, code: str) -> dict:
return self._pt.execute_code.__wrapped__(self._pt, code)
return self._execute_code.__wrapped__(code)
@pytest.fixture
+3 -3
View File
@@ -2951,7 +2951,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.80"
version = "1.5.85"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -3054,8 +3054,8 @@ requires-dist = [
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0,<=1.82.6" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0,<=1.82.6" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
@@ -23,7 +23,9 @@ async function testAgentWithTools() {
const getWeather = (city: string) => `Weather in ${city}: 22°C, sunny`;
const calculate = (expr: string) => {
try {
return String(eval(expr));
if (!/^[0-9+\-*/.() ]+$/.test(expr)) return 'Error: Invalid expression';
const result = new Function(`"use strict"; return (${expr})`)();
return typeof result === 'number' && isFinite(result) ? String(result) : 'Error';
} catch {
return 'Error';
}
@@ -96,7 +96,13 @@ async function main() {
});
await runTest('Agent: With Custom Tool', async () => {
const calculator = (expression: string) => String(eval(expression));
const calculator = (expression: string) => {
try {
if (!/^[0-9+\-*/.() ]+$/.test(expression)) return 'Error: Invalid expression';
const result = new Function(`"use strict"; return (${expression})`)();
return typeof result === 'number' && isFinite(result) ? String(result) : 'Error';
} catch { return 'Error'; }
};
const agent = new Agent({
name: 'ToolAgent',
instructions: 'Use the calculator tool for math.',
+1 -1
View File
@@ -44,7 +44,7 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
> - 📦 **Installing?** → [Installation](#-installation)
> - 🐍 **Python SDK?** → [Python Examples](#-using-python-code)
> - 🎯 **CLI user?** → [CLI Quick Reference](#cli-quick-reference)
> - 🤝 **Contributing?** → [Development](#-development)
> - 🤝 **Contributing?** → [Contributing](#-contributing)
---
+11 -1
View File
@@ -3,6 +3,16 @@ import markdown
app = Flask(__name__)
try:
import nh3
def _sanitize_html(html: str) -> str:
"""Sanitize HTML to prevent XSS from markdown-generated content."""
return nh3.clean(html)
except ImportError:
def _sanitize_html(html: str) -> str:
"""Fallback: no nh3, return as-is (install nh3 for XSS protection)."""
return html
def basic():
from praisonai import PraisonAI
praisonai = PraisonAI(agent_file="agents.yaml")
@@ -11,7 +21,7 @@ def basic():
@app.route('/')
def home():
output = basic()
html_output = markdown.markdown(output)
html_output = _sanitize_html(markdown.markdown(str(output)))
return f'<html><body>{html_output}</body></html>'
if __name__ == "__main__":
+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.80.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.85.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.85.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -267,12 +267,24 @@ class JobWorkflowExecutor:
def _exec_inline_python(self, code: str, step: Dict, flags: Dict) -> Dict:
"""Execute inline Python code in an isolated namespace."""
_safe_builtins = {
"True": True, "False": False, "None": None,
"int": int, "float": float, "str": str, "bool": bool,
"list": list, "dict": dict, "tuple": tuple, "set": set,
"len": len, "range": range, "enumerate": enumerate,
"zip": zip, "map": map, "filter": filter,
"sorted": sorted, "reversed": reversed,
"min": min, "max": max, "sum": sum, "abs": abs, "round": round,
"isinstance": isinstance, "type": type,
"print": print, "repr": repr,
"hasattr": hasattr, "getattr": getattr, "setattr": setattr,
}
namespace = {
"flags": flags,
"vars": {k: self._resolve_var_value(v) for k, v in self._vars.items()},
"env": os.environ.copy(),
"cwd": self._cwd,
"__builtins__": __builtins__,
"__builtins__": _safe_builtins,
}
try:
exec(code, namespace)
+68 -1
View File
@@ -67,6 +67,56 @@ import importlib
# REMOVED: from praisonai.inbuilt_tools import * - causes ~3200ms crewai import
# REMOVED: from praisonai.inc.config import generate_config - causes ~3500ms langchain import
# Security: blocklist of environment variable keys that must not be set from
# untrusted YAML config files. These keys can alter code-loading behaviour
# (LD_PRELOAD, PYTHONPATH, …) or redirect subprocesses (PATH) and are
# therefore a vector for arbitrary code execution (CWE-78).
_BLOCKED_ENV_KEYS = frozenset({
# Dynamic linker injection
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT",
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
# Executable / module search paths
"PATH",
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP",
"NODE_PATH", "NODE_OPTIONS",
"RUBYLIB", "PERL5LIB", "PERL5OPT",
"CLASSPATH",
# Proxy / redirect (could exfiltrate traffic)
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
# Miscellaneous dangerous keys
"BASH_ENV", "ENV", "CDPATH",
"PROMPT_COMMAND",
"SHLVL",
})
# Pre-compute uppercase lookup set once at module load (avoids rebuilding per call)
_BLOCKED_ENV_KEYS_UPPER = frozenset(k.upper() for k in _BLOCKED_ENV_KEYS)
def _validate_env_key(key) -> None:
"""Raise ``ValueError`` if *key* is a blocked environment variable name.
The check is case-insensitive so that ``ld_preload`` is caught as well as
``LD_PRELOAD``. Non-string keys (e.g. YAML integer or null keys) are
rejected with a clear validation error.
"""
if not isinstance(key, str):
raise ValueError(
f"Environment variable key must be a string, got {type(key).__name__}: {key!r}"
)
if not key or "=" in key or "\x00" in key:
raise ValueError(
"Environment variable key must be a non-empty string without '=' or NUL characters."
)
if key.upper() in _BLOCKED_ENV_KEYS_UPPER:
raise ValueError(
f"Setting environment variable '{key}' is not allowed in schedule "
f"config files because it can be used to execute arbitrary code."
)
# Lazy import helpers for inbuilt_tools and config
def _get_inbuilt_tools():
"""Lazy import inbuilt_tools only when crewai/autogen features are used."""
@@ -444,21 +494,38 @@ class PraisonAI:
try:
with open(args.schedule_config, 'r') as f:
file_config = yaml.safe_load(f)
if file_config is None:
file_config = {}
if not isinstance(file_config, dict):
raise ValueError("Schedule config must be a YAML mapping")
# Extract deployment config
deploy_config = file_config.get('deployment', {})
if deploy_config is None:
deploy_config = {}
if not isinstance(deploy_config, dict):
raise ValueError("'deployment' must be a mapping")
schedule_expr = schedule_expr or deploy_config.get('schedule')
provider = deploy_config.get('provider', provider)
config['max_retries'] = deploy_config.get('max_retries', config['max_retries'])
# Apply environment variables if specified
env_vars = file_config.get('environment', {})
if not isinstance(env_vars, dict):
raise ValueError("'environment' must be a mapping of KEY: value pairs")
# Validate all keys first (fail-closed) before mutating os.environ
validated_env = {}
for key, value in env_vars.items():
os.environ[key] = str(value)
_validate_env_key(key)
validated_env[key] = str(value)
os.environ.update(validated_env)
except FileNotFoundError:
print(f"Configuration file not found: {args.schedule_config}")
sys.exit(1)
except ValueError as e:
print(f"Invalid schedule configuration: {e}")
sys.exit(1)
except yaml.YAMLError as e:
print(f"Error parsing configuration file: {e}")
sys.exit(1)
+3 -2
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.80 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.85 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -87,7 +87,8 @@ class CloudDeployer:
file.write(" html_output = markdown.markdown(output)\n")
file.write(" return f'<html><body>{html_output}</body></html>'\n\n")
file.write("if __name__ == \"__main__\":\n")
file.write(" app.run(debug=True)\n")
file.write(" import os\n")
file.write(" app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
def set_environment_variables(self):
"""Sets environment variables with fallback to .env values or defaults."""
+2 -1
View File
@@ -40,6 +40,7 @@ trace_exporter: none # none, otlp, jaeger, zipkin
```
"""
import hmac
import json
import os
import time
@@ -332,7 +333,7 @@ def create_auth_middleware(auth_type: str, api_key: Optional[str] = None, jwt_se
# No key configured, allow request
return await call_next(request)
if provided_key != expected_key:
if not provided_key or not hmac.compare_digest(provided_key, expected_key):
return JSONResponse(
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}},
status_code=401
+3 -1
View File
@@ -583,10 +583,12 @@ print(f"[DEBUG] tools_dict: {tools_dict}")
with open(agent_file, 'r') as f:
config = yaml.safe_load(f)
import secrets
AUTH_PASSWORD_ENABLED = os.getenv("AUTH_PASSWORD_ENABLED", "true").lower() == "true"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
username_env = os.getenv("CHAINLIT_USERNAME", "admin")
password_env = os.getenv("CHAINLIT_PASSWORD", "admin")
+5 -1
View File
@@ -25,9 +25,13 @@ logger = logging.getLogger(__name__)
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
import secrets
# Auth secret (required by Chainlit)
if not os.getenv("CHAINLIT_AUTH_SECRET"):
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
# ---------------------------------------------------------------------------
# Lazy imports
+5 -2
View File
@@ -194,10 +194,13 @@ def _ensure_env_loaded():
_cached_modules['env_loaded'] = True
# Auth secret setup (required early)
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
def save_setting(key: str, value: str):
"""Save a setting to the database"""
+5 -2
View File
@@ -193,10 +193,13 @@ def _ensure_env_loaded():
_cached_modules['env_loaded'] = True
# Auth secret setup (required early)
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
def save_setting(key: str, value: str):
"""Save a setting to the database"""
+5 -3
View File
@@ -36,11 +36,13 @@ logger.addHandler(console_handler)
logger.setLevel(log_level)
# Set up CHAINLIT_AUTH_SECRET
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
# Database path
DB_PATH = os.path.expanduser("~/.praison/database.sqlite")
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.5.80"
__version__ = "4.5.85"
+2 -2
View File
@@ -12,9 +12,9 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.5.80",
"praisonaiagents>=1.5.85",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"litellm>=1.81.0,<=1.82.6",
"PyYAML>=6.0",
"mcp>=1.20.0",
"typer>=0.9.0",
@@ -0,0 +1,193 @@
"""
PoC test for CWE-78: YAML-loaded environment variables set via
os.environ without sanitization.
The schedule config YAML can set arbitrary environment variables
including dangerous ones like LD_PRELOAD, PATH, PYTHONPATH, etc.,
which can lead to arbitrary code execution.
This test verifies that dangerous environment variable keys are
rejected by the _validate_env_key function added in the fix.
"""
import os
import textwrap
import pytest
import yaml
DANGEROUS_KEYS = [
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"PATH",
"PYTHONPATH",
"PYTHONSTARTUP",
"PYTHONHOME",
"NODE_OPTIONS",
"NODE_PATH",
"RUBYLIB",
"PERL5LIB",
"PERL5OPT",
"CLASSPATH",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"BASH_ENV",
"ENV",
"CDPATH",
"PROMPT_COMMAND",
"SHLVL",
"ld_preload", # lowercase variant
"Ld_Preload", # mixed case variant
]
# Generic key names that won't trigger provider-detection
SAFE_KEYS = [
"MODEL_NAME",
"MY_API_KEY",
"MY_MODEL_NAME",
"MY_BASE_URL",
"MY_CUSTOM_VAR",
]
@pytest.fixture(autouse=True)
def _clean_env():
"""Snapshot env vars before each test and restore them afterwards."""
# Save original values (None means the key was not set)
_snapshot = {k: os.environ.get(k) for k in DANGEROUS_KEYS + SAFE_KEYS}
yield
# Restore: reinstate original values or remove if they didn't exist
for key, original in _snapshot.items():
if original is None:
os.environ.pop(key, None)
else:
os.environ[key] = original
def _get_validate_env_key():
"""Import _validate_env_key, failing clearly if absent."""
try:
from praisonai.cli.main import _validate_env_key
return _validate_env_key
except ImportError:
pytest.fail(
"_validate_env_key not found in praisonai.cli.main"
" — the env-var validation fix has not been applied"
)
@pytest.mark.unit
def test_dangerous_env_keys_are_blocked():
"""Dangerous keys must be rejected with a ValueError."""
validate = _get_validate_env_key()
for key in DANGEROUS_KEYS:
with pytest.raises(
ValueError,
match=r"[Nn]ot allowed|[Bb]locked|[Dd]angerous",
):
validate(key)
@pytest.mark.unit
def test_safe_env_keys_are_allowed():
"""Safe keys must pass validation without error."""
validate = _get_validate_env_key()
for key in SAFE_KEYS:
# Should not raise
validate(key)
@pytest.mark.unit
def test_non_string_key_rejected():
"""Non-string YAML keys (int, None) must produce a clear ValueError."""
validate = _get_validate_env_key()
for bad_key in [123, None, 3.14, True]:
with pytest.raises(ValueError, match=r"must be a string"):
validate(bad_key)
@pytest.mark.unit
def test_vulnerability_scenario_ld_preload():
"""LD_PRELOAD in a YAML environment section must be blocked.
This test simulates the schedule-config loading path: parse the YAML,
then validate-all-then-apply (matching the fail-closed logic in main.py).
"""
validate = _get_validate_env_key()
config_content = textwrap.dedent("""\
deployment:
schedule: "*/5 * * * *"
provider: gcp
environment:
LD_PRELOAD: /tmp/evil.so
MODEL_NAME: test-model
""")
file_config = yaml.safe_load(config_content)
env_vars = file_config.get("environment", {})
# Simulate the validate-all-then-apply pattern from main.py
with pytest.raises(ValueError):
validated_env = {}
for key, value in env_vars.items():
validate(key)
validated_env[key] = str(value)
# If we reach here, no key was blocked — the test fails via pytest.raises
os.environ.update(validated_env)
# LD_PRELOAD must NOT have been applied (fail-closed)
assert "LD_PRELOAD" not in os.environ or os.environ.get("LD_PRELOAD") != "/tmp/evil.so"
@pytest.mark.unit
def test_malformed_env_keys_rejected():
"""Empty strings, keys with '=', and keys with NUL must be rejected."""
validate = _get_validate_env_key()
for bad_key in ["", "A=B", "FOO\x00BAR"]:
with pytest.raises(ValueError, match=r"non-empty string without"):
validate(bad_key)
@pytest.mark.unit
def test_non_dict_root_config_rejected():
"""A YAML schedule config whose root is not a mapping must be rejected.
This validates the root type-check added after yaml.safe_load().
When the root config is a list or scalar, earlier .get() calls would
raise AttributeError; after the fix they raise ValueError, which is
caught and presented cleanly.
"""
import yaml as _yaml
for bad_doc in ["- item1\n- item2", "42", "just a string"]:
file_config = _yaml.safe_load(bad_doc)
# The check from main.py: if not isinstance(file_config, dict)
assert not isinstance(file_config, dict), (
f"Expected non-dict for {bad_doc!r}, got {type(file_config)}"
)
@pytest.mark.unit
def test_non_dict_deployment_rejected():
"""A 'deployment' value that is not a mapping must be caught."""
import yaml as _yaml
bad_config = _yaml.safe_load("deployment: not-a-mapping\nenvironment:\n MODEL: x")
deploy_config = bad_config.get("deployment", {})
assert not isinstance(deploy_config, dict), (
f"Expected non-dict deployment, got {type(deploy_config)}"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+5 -5
View File
@@ -4925,7 +4925,7 @@ requires-dist = [
{ name = "greenlet", marker = "extra == 'code'", specifier = ">=3.0.3" },
{ name = "greenlet", marker = "extra == 'realtime'", specifier = ">=3.0.3" },
{ name = "greenlet", marker = "extra == 'ui'", specifier = ">=3.0.3" },
{ name = "litellm", specifier = ">=1.81.0" },
{ name = "litellm", specifier = ">=1.81.0,<=1.82.6" },
{ name = "markdown", specifier = ">=3.5" },
{ name = "mcp", specifier = ">=1.20.0" },
{ name = "neonize", marker = "extra == 'bot-whatsapp-web'", specifier = ">=0.3.14" },
@@ -4939,7 +4939,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=1.5.80" },
{ name = "praisonaiagents", specifier = ">=1.5.85" },
{ name = "praisonaiagents", extras = ["all"], marker = "extra == 'claw'", specifier = ">=1.5.40" },
{ name = "psutil", marker = "extra == 'all'", specifier = ">=5.9.0" },
{ name = "psutil", marker = "extra == 'bot'", specifier = ">=5.9.0" },
@@ -5009,7 +5009,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.80"
version = "1.5.85"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -5018,9 +5018,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/3b/6c88188227bac16f88e7b141568cebb03d6ef46f38935e3bb8408a400017/praisonaiagents-1.5.80.tar.gz", hash = "sha256:fa0b803943e5aab8ce4099c8257e8f5378648a19668ec046749aa3babad99d57", size = 1204298, upload-time = "2026-03-18T21:06:55.71Z" }
sdist = { url = "https://files.pythonhosted.org/packages/da/cd/5a13bd53998fd20cf3bba2ed435cb4626382cef92c0b99cfc0b79a1e4fc7/praisonaiagents-1.5.85.tar.gz", hash = "sha256:e7677b9a480a5ae6a3405d82e4968df38c78c02c36acb820ba2a8edf2d25da24", size = 1207471, upload-time = "2026-03-26T03:12:29.383Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/76/ab37e313ad3e756c39df8f5b180613169ff8074a293aaba52fbdef88928e/praisonaiagents-1.5.80-py3-none-any.whl", hash = "sha256:e1590e6bbbda526e13d3654ab17cab6a2a602af4508fabe7a8cd389213658efe", size = 1248857, upload-time = "2026-03-18T21:06:53.898Z" },
{ url = "https://files.pythonhosted.org/packages/c8/34/41275a9a85806bae7ad6ac8fa629034d28b4724bd6e27a882a64fa0e17e0/praisonaiagents-1.5.85-py3-none-any.whl", hash = "sha256:6549edd10dc6f1924d04c7f41c100b7f85bf3fcb449286a0ba07fb48c1c15986", size = 1255763, upload-time = "2026-03-26T03:12:27.162Z" },
]
[package.optional-dependencies]