Compare commits

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

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

All changes are in example files and dev helper scripts only.
No SDK core code modified. Unit tests: 4341 passed, 0 new failures.
2026-03-21 17:49:40 +00:00
MervinPraison 8cbad0c997 Release v4.5.81 2026-03-21 16:51:27 +00:00
30 changed files with 1083 additions and 42 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.80" \
"praisonai>=4.5.83" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.80" \
"praisonai>=4.5.83" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.80" \
"praisonai>=4.5.83" \
"praisonai[ui]" \
"praisonai[crewai]"
+9 -1
View File
@@ -19,7 +19,15 @@ async function main() {
},
execute: async ({ expression }) => {
try {
const result = eval(expression);
// Only allow safe numeric math characters
if (!/^[0-9+\-*/.() ]+$/.test(expression)) {
return 'Error: Only basic math expressions are allowed';
}
// Use Function constructor scoped to math only, no globals
const result = new Function(`"use strict"; return (${expression})`)();
if (typeof result !== 'number' || !isFinite(result)) {
return 'Error: Invalid result';
}
return `Result: ${result}`;
} catch (e) {
return 'Error: Invalid expression';
+7 -1
View File
@@ -38,7 +38,13 @@ const calculatorTool = createTool({
},
run: (params: { expression: string }) => {
try {
return eval(params.expression);
// Only allow safe numeric math characters
if (!/^[0-9+\-*/.() ]+$/.test(params.expression)) {
return 'Error: Only basic math expressions are allowed';
}
const result = new Function(`"use strict"; return (${params.expression})`)();
if (typeof result !== 'number' || !isFinite(result)) return 'Error: Invalid result';
return result;
} catch {
return 'Error: Invalid expression';
}
+19 -1
View File
@@ -134,10 +134,28 @@ def main():
except Exception as e:
return {"error": str(e)}
# Safe AST-based math evaluator (no eval on arbitrary expressions)
def _safe_calculate(expression: str) -> str:
import ast
try:
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
tree = ast.parse(expression, mode="eval")
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
result = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
# MCP endpoints
mcp_tools = {
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
"calculate": {"description": "Calculate expression", "func": _safe_calculate},
}
@app.get("/mcp/tools")
@@ -5335,7 +5335,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 +6580,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 +6678,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 +6901,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 +6932,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])
@@ -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,
}
@@ -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}")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.80"
version = "1.5.83"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+9 -6
View File
@@ -24,9 +24,12 @@ if __name__ == "__main__":
script_to_run = sys.argv[1]
# Execute the target script
with open(script_to_run, 'r') as f:
script_content = f.read()
# Execute in the same namespace
exec(script_content, {'__name__': '__main__'})
# Execute the target script in a subprocess to avoid sharing the current namespace
import subprocess
result = subprocess.run(
[sys.executable, script_to_run],
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
)
sys.exit(result.returncode)
@@ -0,0 +1,73 @@
"""
TDD tests for compaction wiring into agent.
Tests are written BEFORE implementation (TDD).
Verifies ExecutionConfig gets context_compaction field,
and ContextCompactor is called when enabled.
"""
import pytest
from unittest.mock import MagicMock, patch
class TestExecutionConfigCompactionFields:
"""ExecutionConfig must have context_compaction and max_context_tokens fields."""
def test_has_context_compaction_field(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig()
assert hasattr(cfg, "context_compaction")
assert cfg.context_compaction is False # safe default
def test_has_max_context_tokens_field(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig()
assert hasattr(cfg, "max_context_tokens")
assert cfg.max_context_tokens is None # auto by default
def test_context_compaction_opt_in(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig(context_compaction=True, max_context_tokens=8000)
assert cfg.context_compaction is True
assert cfg.max_context_tokens == 8000
class TestContextCompactorExists:
"""ContextCompactor must be importable and functional (existing module)."""
def test_import(self):
from praisonaiagents.compaction import ContextCompactor
assert ContextCompactor is not None
def test_needs_compaction_false_within_limit(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=10000)
short_msgs = [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi"}]
assert compactor.needs_compaction(short_msgs) is False
def test_needs_compaction_true_over_limit(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=10) # tiny limit
long_msgs = [{"role": "user", "content": "A" * 1000}]
assert compactor.needs_compaction(long_msgs) is True
def test_compact_reduces_tokens(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=100, preserve_recent=1)
many_msgs = [{"role": "user", "content": "Message " + str(i) * 50} for i in range(20)]
compacted_msgs, result = compactor.compact(many_msgs)
assert result.compacted_tokens < result.original_tokens
class TestCompactionHookEvents:
"""BEFORE_COMPACTION and AFTER_COMPACTION must be defined in HookEvent."""
def test_before_compaction_event_exists(self):
from praisonaiagents.hooks.types import HookEvent
assert hasattr(HookEvent, "BEFORE_COMPACTION")
assert HookEvent.BEFORE_COMPACTION == "before_compaction"
def test_after_compaction_event_exists(self):
from praisonaiagents.hooks.types import HookEvent
assert hasattr(HookEvent, "AFTER_COMPACTION")
assert HookEvent.AFTER_COMPACTION == "after_compaction"
@@ -0,0 +1,223 @@
"""
TDD tests for PraisonAI tool loop detection.
Tests are written BEFORE implementation (TDD).
Port of OpenClaw's tool-loop-detection.ts patterns to Python.
"""
import hashlib
import json
import pytest
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Helper: compute expected hash the same way the module will
# ---------------------------------------------------------------------------
def _hash(tool_name: str, args: dict) -> str:
stable = json.dumps({"t": tool_name, "a": args}, sort_keys=True, default=str)
return hashlib.sha256(stable.encode()).hexdigest()[:16]
# ---------------------------------------------------------------------------
# Import the module under test (will fail until implemented — that's TDD)
# ---------------------------------------------------------------------------
class TestLoopDetectionImport:
"""The module must be importable with zero side-effects."""
def test_module_importable(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig,
LoopDetectionResult,
detect_tool_loop,
record_tool_call,
record_tool_outcome,
hash_tool_call,
)
assert LoopDetectionConfig is not None
assert detect_tool_loop is not None
def test_no_side_effects_on_import(self):
"""Importing must not change global state or call LLM."""
import praisonaiagents.agent.loop_detection # noqa
class TestLoopDetectionConfig:
def setup_method(self):
from praisonaiagents.agent.loop_detection import LoopDetectionConfig
self.Config = LoopDetectionConfig
def test_default_disabled(self):
cfg = self.Config()
assert cfg.enabled is False # safe default: opt-in only
def test_custom_thresholds(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=5)
assert cfg.warn_threshold == 3
assert cfg.critical_threshold == 5
def test_critical_must_be_greater_than_warn(self):
"""If critical <= warn, it should be auto-corrected."""
cfg = self.Config(enabled=True, warn_threshold=10, critical_threshold=5)
assert cfg.critical_threshold > cfg.warn_threshold
class TestHashToolCall:
def test_hash_is_deterministic(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("search", {"query": "foo"})
assert h1 == h2
def test_different_args_produce_different_hashes(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("search", {"query": "bar"})
assert h1 != h2
def test_different_tools_produce_different_hashes(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("fetch", {"query": "foo"})
assert h1 != h2
def test_arg_order_does_not_matter(self):
"""Stable JSON sort keys: {'a':1,'b':2} == {'b':2,'a':1}"""
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("t", {"b": 2, "a": 1})
h2 = hash_tool_call("t", {"a": 1, "b": 2})
assert h1 == h2
class TestDetectToolLoop:
def setup_method(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig,
detect_tool_loop,
record_tool_call,
)
self.Config = LoopDetectionConfig
self.detect = detect_tool_loop
self.record = record_tool_call
# --- disabled ---
def test_disabled_always_returns_not_stuck(self):
cfg = self.Config(enabled=False)
history = []
# Fill history with many identical calls
for _ in range(50):
self.record(history, "search", {"q": "x"}, cfg)
result = self.detect(history, "search", {"q": "x"}, cfg)
assert result["stuck"] is False
# --- generic_repeat ---
def test_below_warn_threshold_not_stuck(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(4):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is False
def test_at_warn_threshold_warning(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(5):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is True
assert result["level"] == "warning"
assert result["detector"] == "generic_repeat"
def test_at_critical_threshold_critical(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(10):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is True
assert result["level"] == "critical"
def test_different_tools_no_false_positive(self):
"""Alternating different tools should NOT trigger generic_repeat."""
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=6)
history = []
for i in range(6):
self.record(history, "search", {"q": str(i)}, cfg) # different args each time
result = self.detect(history, "search", {"q": "0"}, cfg)
# Only 1 occurrence of {"q": "0"} — not stuck
assert result["stuck"] is False
# --- message content ---
def test_warning_message_contains_tool_name(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=10)
history = []
for _ in range(3):
self.record(history, "my_tool", {"x": 1}, cfg)
result = self.detect(history, "my_tool", {"x": 1}, cfg)
if result["stuck"]:
assert "my_tool" in result["message"]
def test_result_has_count_field(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=10)
history = []
for _ in range(3):
self.record(history, "t", {}, cfg)
result = self.detect(history, "t", {}, cfg)
assert "count" in result
class TestRecordToolCall:
def test_record_appends_to_history(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call
)
cfg = LoopDetectionConfig(enabled=True)
history = []
record_tool_call(history, "search", {"q": "test"}, cfg)
assert len(history) == 1
assert history[0]["tool_name"] == "search"
def test_history_respects_max_size(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call
)
cfg = LoopDetectionConfig(enabled=True, history_size=5)
history = []
for i in range(10):
record_tool_call(history, "t", {"i": i}, cfg)
assert len(history) <= 5
class TestRecordToolOutcome:
def test_outcome_updates_result_hash(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call, record_tool_outcome
)
cfg = LoopDetectionConfig(enabled=True)
history = []
record_tool_call(history, "search", {"q": "x"}, cfg)
assert history[0].get("result_hash") is None
record_tool_outcome(history, "search", {"q": "x"}, "some result", cfg)
assert history[0].get("result_hash") is not None
class TestLoopDetectionPlugin:
"""Tests for the plugin interface (hooks integration)."""
def test_plugin_importable(self):
from praisonaiagents.plugins import loop_detection_plugin # noqa
assert loop_detection_plugin is not None
def test_plugin_registers_hook(self):
"""Plugin import should register a BEFORE_TOOL hook."""
from praisonaiagents.hooks.registry import get_default_registry
from praisonaiagents.hooks.types import HookEvent
import praisonaiagents.plugins.loop_detection_plugin # noqa ensure imported
hooks = get_default_registry().get_hooks(HookEvent.BEFORE_TOOL)
assert len(hooks) >= 1
+1 -1
View File
@@ -2951,7 +2951,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.80"
version = "1.5.83"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -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.',
+11 -1
View File
@@ -3,6 +3,16 @@ import markdown
app = Flask(__name__)
try:
import nh3
def _sanitize_html(html: str) -> str:
"""Sanitize HTML to prevent XSS from markdown-generated content."""
return nh3.clean(html)
except ImportError:
def _sanitize_html(html: str) -> str:
"""Fallback: no nh3, return as-is (install nh3 for XSS protection)."""
return html
def basic():
from praisonai import PraisonAI
praisonai = PraisonAI(agent_file="agents.yaml")
@@ -11,7 +21,7 @@ def basic():
@app.route('/')
def home():
output = basic()
html_output = markdown.markdown(output)
html_output = _sanitize_html(markdown.markdown(str(output)))
return f'<html><body>{html_output}</body></html>'
if __name__ == "__main__":
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.83.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.83.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)
+3 -2
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.5.80 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.83 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -87,7 +87,8 @@ class CloudDeployer:
file.write(" html_output = markdown.markdown(output)\n")
file.write(" return f'<html><body>{html_output}</body></html>'\n\n")
file.write("if __name__ == \"__main__\":\n")
file.write(" app.run(debug=True)\n")
file.write(" import os\n")
file.write(" app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
def set_environment_variables(self):
"""Sets environment variables with fallback to .env values or defaults."""
+2 -1
View File
@@ -40,6 +40,7 @@ trace_exporter: none # none, otlp, jaeger, zipkin
```
"""
import hmac
import json
import os
import time
@@ -332,7 +333,7 @@ def create_auth_middleware(auth_type: str, api_key: Optional[str] = None, jwt_se
# No key configured, allow request
return await call_next(request)
if provided_key != expected_key:
if not provided_key or not hmac.compare_digest(provided_key, expected_key):
return JSONResponse(
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}},
status_code=401
+3 -1
View File
@@ -583,10 +583,12 @@ print(f"[DEBUG] tools_dict: {tools_dict}")
with open(agent_file, 'r') as f:
config = yaml.safe_load(f)
import secrets
AUTH_PASSWORD_ENABLED = os.getenv("AUTH_PASSWORD_ENABLED", "true").lower() == "true"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
username_env = os.getenv("CHAINLIT_USERNAME", "admin")
password_env = os.getenv("CHAINLIT_PASSWORD", "admin")
+5 -1
View File
@@ -25,9 +25,13 @@ logger = logging.getLogger(__name__)
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
import secrets
# Auth secret (required by Chainlit)
if not os.getenv("CHAINLIT_AUTH_SECRET"):
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
# ---------------------------------------------------------------------------
# Lazy imports
+5 -2
View File
@@ -194,10 +194,13 @@ def _ensure_env_loaded():
_cached_modules['env_loaded'] = True
# Auth secret setup (required early)
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
def save_setting(key: str, value: str):
"""Save a setting to the database"""
+5 -2
View File
@@ -193,10 +193,13 @@ def _ensure_env_loaded():
_cached_modules['env_loaded'] = True
# Auth secret setup (required early)
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
def save_setting(key: str, value: str):
"""Save a setting to the database"""
+5 -3
View File
@@ -36,11 +36,13 @@ logger.addHandler(console_handler)
logger.setLevel(log_level)
# Set up CHAINLIT_AUTH_SECRET
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
# Database path
DB_PATH = os.path.expanduser("~/.praison/database.sqlite")
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.5.80"
__version__ = "4.5.83"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.5.80",
"praisonaiagents>=1.5.83",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
+4 -4
View File
@@ -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.83" },
{ 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.83"
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/e4/cb/ca80e2024983694a0b2831bd7f3fd09dd5d797c12d7c008bd72c863a9b6f/praisonaiagents-1.5.83.tar.gz", hash = "sha256:0790932edd3d0f48ddd88098eb51c35dcfad771ec9f922425a0c2fa5aed13798", size = 1210563, upload-time = "2026-03-24T04:47:01.225Z" }
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/a0/bb/e51bdea45d20f3a70218c6eb83d71241f1ef72a39d7be983b73bbb67b6bf/praisonaiagents-1.5.83-py3-none-any.whl", hash = "sha256:51fa11dc9e9c91462f108359eaca7e18f1163a1fbacf435addf775976ccefdfa", size = 1255587, upload-time = "2026-03-24T04:46:59.012Z" },
]
[package.optional-dependencies]