mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 14:38:40 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed31e9b280 | ||
|
|
69e9e712c7 | ||
|
|
7617b10c70 | ||
|
|
7939a4f12e | ||
|
|
8cbad0c997 |
No files matched your search
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Generated
+1
-1
@@ -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
@@ -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.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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.83"
|
||||
@@ -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",
|
||||
|
||||
Generated
+4
-4
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user