mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 11:41:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29facc8f89 | ||
|
|
401521848f | ||
|
|
ab97eae954 | ||
|
|
5dda0643ca | ||
|
|
1b8d7a02d9 | ||
|
|
dbcd36edce | ||
|
|
9ef5391c83 | ||
|
|
7385adbd60 | ||
|
|
78d7883fb7 | ||
|
|
6122531a6a | ||
|
|
d704cf1349 | ||
|
|
8523ef076e | ||
|
|
72889d2f73 | ||
|
|
f17bd7369c | ||
|
|
ed31e9b280 | ||
|
|
69e9e712c7 | ||
|
|
7617b10c70 | ||
|
|
7939a4f12e | ||
|
|
8cbad0c997 | ||
|
|
48ed131577 |
No files matched your search
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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==================================================")
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+3
-3
@@ -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.',
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "4.5.80"
|
||||
__version__ = "4.5.85"
|
||||
@@ -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"])
|
||||
Generated
+5
-5
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user