Skip to content

Commit be167f8

Browse files
committed
Release v4.5.6
1 parent 8c4e339 commit be167f8

22 files changed

Lines changed: 1167 additions & 162 deletions

docker/Dockerfile.chat

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
1616
# Install Python packages (using latest versions)
1717
RUN pip install --no-cache-dir \
1818
praisonai_tools \
19-
"praisonai>=4.5.5" \
19+
"praisonai>=4.5.6" \
2020
"praisonai[chat]" \
2121
"embedchain[github,youtube]"
2222

docker/Dockerfile.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
2020
# Install Python packages (using latest versions)
2121
RUN pip install --no-cache-dir \
2222
praisonai_tools \
23-
"praisonai>=4.5.5" \
23+
"praisonai>=4.5.6" \
2424
"praisonai[ui]" \
2525
"praisonai[chat]" \
2626
"praisonai[realtime]" \

docker/Dockerfile.ui

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
1616
# Install Python packages (using latest versions)
1717
RUN pip install --no-cache-dir \
1818
praisonai_tools \
19-
"praisonai>=4.5.5" \
19+
"praisonai>=4.5.6" \
2020
"praisonai[ui]" \
2121
"praisonai[crewai]"
2222

src/praisonai-agents/praisonaiagents/agent/agent.py

Lines changed: 199 additions & 27 deletions
Large diffs are not rendered by default.

src/praisonai-agents/praisonaiagents/agent/autonomy.py

Lines changed: 115 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
This module integrates escalation, doom loop detection, and observability
66
directly into the Agent class as first-class capabilities.
77
8+
Unified Architecture:
9+
- AutonomyStage is an alias for EscalationStage (single source of truth)
10+
- AutonomyTrigger delegates to EscalationTrigger (DRY)
11+
- DoomLoopTracker adds recovery actions on top of basic detection
12+
813
Usage:
914
from praisonaiagents import Agent
1015
@@ -27,19 +32,45 @@
2732
"""
2833

2934
from dataclasses import dataclass, field
30-
from typing import Optional, Dict, Any, List, Set, Callable, Protocol, runtime_checkable
35+
from typing import Optional, Dict, Any, List, Set
3136
from enum import Enum
3237
import logging
3338

3439
logger = logging.getLogger(__name__)
3540

41+
# ============================================================================
42+
# Unified Stage Enum (G-DUP-1 fix: single source of truth)
43+
# ============================================================================
44+
# AutonomyStage is an alias for EscalationStage so both systems share
45+
# the same IntEnum. Backward-compatible: AutonomyStage.DIRECT == EscalationStage.DIRECT.
46+
from ..escalation.types import EscalationStage as AutonomyStage # noqa: F401
47+
48+
# Valid autonomy levels for AutonomyConfig.level
49+
VALID_AUTONOMY_LEVELS = {"suggest", "auto_edit", "full_auto"}
3650

37-
class AutonomyStage(str, Enum):
38-
"""Autonomy execution stages."""
39-
DIRECT = "direct"
40-
HEURISTIC = "heuristic"
41-
PLANNED = "planned"
42-
AUTONOMOUS = "autonomous"
51+
# Signal name mapping: EscalationSignal.value → AutonomyTrigger string
52+
# Keeps backward compat for existing code that checks string signal names.
53+
_ESCALATION_TO_AUTONOMY_SIGNAL = {
54+
"simple_question": "simple_question",
55+
"file_references": "file_references",
56+
"code_blocks": "code_blocks",
57+
"edit_intent": "edit_intent",
58+
"test_intent": "test_intent",
59+
"refactor_intent": "refactor_intent",
60+
"multi_step_intent": "multi_step",
61+
"complex_keywords": "complex_keywords",
62+
"long_prompt": "long_prompt",
63+
"repo_context": "repo_context",
64+
"build_intent": "build_intent",
65+
"tool_failure": "tool_failure",
66+
"ambiguous_result": "ambiguous_result",
67+
"incomplete_task": "incomplete_task",
68+
"clarification": "clarification",
69+
"acknowledgment": "acknowledgment",
70+
}
71+
72+
# Reverse map for converting autonomy signal strings back to EscalationSignal values
73+
_AUTONOMY_TO_ESCALATION_SIGNAL = {v: k for k, v in _ESCALATION_TO_AUTONOMY_SIGNAL.items()}
4374

4475

4576
@dataclass
@@ -48,6 +79,7 @@ class AutonomyConfig:
4879
4980
Attributes:
5081
enabled: Whether autonomy is enabled
82+
level: Autonomy level (suggest, auto_edit, full_auto)
5183
max_iterations: Maximum iterations before stopping
5284
doom_loop_threshold: Number of repeated actions to trigger doom loop
5385
auto_escalate: Whether to automatically escalate complexity
@@ -66,12 +98,25 @@ class AutonomyConfig:
6698
clear_context: bool = False
6799
verification_hooks: Optional[List[Any]] = None
68100

101+
def __post_init__(self):
102+
if self.level not in VALID_AUTONOMY_LEVELS:
103+
raise ValueError(
104+
f"Invalid autonomy level: {self.level!r}. "
105+
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
106+
)
107+
69108
@classmethod
70109
def from_dict(cls, data: Dict[str, Any]) -> "AutonomyConfig":
71110
"""Create config from dictionary."""
111+
level = data.get("level", "suggest")
112+
if level not in VALID_AUTONOMY_LEVELS:
113+
raise ValueError(
114+
f"Invalid autonomy level: {level!r}. "
115+
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
116+
)
72117
return cls(
73118
enabled=data.get("enabled", True),
74-
level=data.get("level", "suggest"),
119+
level=level,
75120
max_iterations=data.get("max_iterations", 20),
76121
doom_loop_threshold=data.get("doom_loop_threshold", 3),
77122
auto_escalate=data.get("auto_escalate", True),
@@ -97,45 +142,13 @@ class AutonomySignal(str, Enum):
97142
class AutonomyTrigger:
98143
"""Detects signals from prompts for autonomy decisions.
99144
100-
Uses fast heuristics (no LLM calls) to analyze prompts.
145+
Delegates to EscalationTrigger (DRY, G-DUP-3 fix) and maps
146+
signal names for backward compatibility.
101147
"""
102148

103-
# Keywords that indicate simple questions
104-
SIMPLE_KEYWORDS = {
105-
"what is", "what's", "define", "explain", "describe",
106-
"how does", "why is", "when was", "who is", "where is"
107-
}
108-
109-
# Keywords that indicate complex tasks
110-
COMPLEX_KEYWORDS = {
111-
"analyze", "refactor", "optimize", "implement", "design",
112-
"architect", "debug", "fix", "modify", "update", "change",
113-
"create", "build", "develop", "integrate", "migrate"
114-
}
115-
116-
# Keywords that indicate edit intent
117-
EDIT_KEYWORDS = {
118-
"edit", "modify", "change", "update", "fix", "add", "remove",
119-
"delete", "replace", "insert", "write", "rewrite"
120-
}
121-
122-
# Keywords that indicate test intent
123-
TEST_KEYWORDS = {
124-
"test", "verify", "validate", "check", "assert", "ensure",
125-
"unit test", "integration test", "e2e", "coverage"
126-
}
127-
128-
# Keywords that indicate refactor intent
129-
REFACTOR_KEYWORDS = {
130-
"refactor", "restructure", "reorganize", "clean up",
131-
"simplify", "extract", "inline", "rename"
132-
}
133-
134-
# Multi-step indicators
135-
MULTI_STEP_INDICATORS = {
136-
"first", "then", "next", "after", "finally", "step",
137-
"1.", "2.", "3.", "and then", "followed by"
138-
}
149+
def __init__(self):
150+
from ..escalation.triggers import EscalationTrigger
151+
self._delegate = EscalationTrigger()
139152

140153
def analyze(self, prompt: str) -> Set[str]:
141154
"""Analyze prompt and return detected signals.
@@ -146,46 +159,11 @@ def analyze(self, prompt: str) -> Set[str]:
146159
Returns:
147160
Set of signal names (lowercase strings)
148161
"""
149-
signals: Set[str] = set()
150-
prompt_lower = prompt.lower()
151-
152-
# Check for simple questions
153-
if any(kw in prompt_lower for kw in self.SIMPLE_KEYWORDS):
154-
word_count = len(prompt.split())
155-
if word_count < 30:
156-
signals.add("simple_question")
157-
158-
# Check for file references
159-
import re
160-
file_pattern = r'[\w\-./]+\.(py|js|ts|tsx|jsx|java|go|rs|cpp|c|h|md|txt|json|yaml|yml|toml)'
161-
if re.search(file_pattern, prompt):
162-
signals.add("file_references")
163-
164-
# Check for code blocks
165-
if "```" in prompt:
166-
signals.add("code_blocks")
167-
168-
# Check for edit intent
169-
if any(kw in prompt_lower for kw in self.EDIT_KEYWORDS):
170-
signals.add("edit_intent")
171-
172-
# Check for test intent
173-
if any(kw in prompt_lower for kw in self.TEST_KEYWORDS):
174-
signals.add("test_intent")
175-
176-
# Check for refactor intent
177-
if any(kw in prompt_lower for kw in self.REFACTOR_KEYWORDS):
178-
signals.add("refactor_intent")
179-
180-
# Check for multi-step
181-
if any(ind in prompt_lower for ind in self.MULTI_STEP_INDICATORS):
182-
signals.add("multi_step")
183-
184-
# Check for complex keywords
185-
if any(kw in prompt_lower for kw in self.COMPLEX_KEYWORDS):
186-
signals.add("complex_keywords")
187-
188-
return signals
162+
escalation_signals = self._delegate.analyze(prompt)
163+
return {
164+
_ESCALATION_TO_AUTONOMY_SIGNAL.get(s.value, s.value)
165+
for s in escalation_signals
166+
}
189167

190168
def recommend_stage(self, signals: Set[str]) -> AutonomyStage:
191169
"""Recommend execution stage based on signals.
@@ -194,22 +172,17 @@ def recommend_stage(self, signals: Set[str]) -> AutonomyStage:
194172
signals: Set of detected signal names
195173
196174
Returns:
197-
Recommended AutonomyStage
175+
Recommended AutonomyStage (alias for EscalationStage)
198176
"""
199-
# AUTONOMOUS: multi-step or refactor
200-
if "multi_step" in signals or "refactor_intent" in signals:
201-
return AutonomyStage.AUTONOMOUS
202-
203-
# PLANNED: edit or test intent
204-
if "edit_intent" in signals or "test_intent" in signals:
205-
return AutonomyStage.PLANNED
206-
207-
# HEURISTIC: file references or code blocks
208-
if "file_references" in signals or "code_blocks" in signals:
209-
return AutonomyStage.HEURISTIC
210-
211-
# DIRECT: simple questions or no signals
212-
return AutonomyStage.DIRECT
177+
from ..escalation.types import EscalationSignal
178+
esc_signals = set()
179+
for s in signals:
180+
signal_value = _AUTONOMY_TO_ESCALATION_SIGNAL.get(s, s)
181+
try:
182+
esc_signals.add(EscalationSignal(signal_value))
183+
except ValueError:
184+
pass
185+
return self._delegate.recommend_stage(esc_signals)
213186

214187

215188
@dataclass
@@ -239,10 +212,13 @@ class AutonomyResult:
239212

240213

241214
class DoomLoopTracker:
242-
"""Tracks actions to detect doom loops.
215+
"""Tracks actions to detect doom loops with recovery actions.
243216
244217
A doom loop occurs when the agent repeats the same action
245218
multiple times without making progress.
219+
220+
Enhanced (G-DUP-2 fix): adds get_recovery_action() for
221+
graduated recovery instead of immediate abort.
246222
"""
247223

248224
def __init__(self, threshold: int = 3):
@@ -254,6 +230,8 @@ def __init__(self, threshold: int = 3):
254230
self.threshold = threshold
255231
self.actions: List[str] = []
256232
self.action_counts: Dict[str, int] = {}
233+
self._consecutive_failures: int = 0
234+
self._recovery_attempts: int = 0
257235

258236
def record(self, action_type: str, args: Dict[str, Any], result: Any, success: bool) -> None:
259237
"""Record an action.
@@ -268,6 +246,12 @@ def record(self, action_type: str, args: Dict[str, Any], result: Any, success: b
268246
sig = f"{action_type}:{hash(str(sorted(args.items())))}"
269247
self.actions.append(sig)
270248
self.action_counts[sig] = self.action_counts.get(sig, 0) + 1
249+
250+
# Track consecutive failures
251+
if not success:
252+
self._consecutive_failures += 1
253+
else:
254+
self._consecutive_failures = 0
271255

272256
def is_doom_loop(self) -> bool:
273257
"""Check if we're in a doom loop.
@@ -283,12 +267,42 @@ def is_doom_loop(self) -> bool:
283267
if count >= self.threshold:
284268
return True
285269

270+
# Check consecutive failures
271+
if self._consecutive_failures >= self.threshold:
272+
return True
273+
286274
return False
287275

276+
def get_recovery_action(self) -> str:
277+
"""Get recommended recovery action when doom loop detected.
278+
279+
Returns graduated recovery actions:
280+
- "retry_different": Try a different approach (1st detection)
281+
- "escalate_model": Escalate to stronger model (2nd detection)
282+
- "request_help": Request human intervention (3rd detection)
283+
- "abort": Stop execution (4th+ detection)
284+
- "continue": No doom loop, continue normally
285+
"""
286+
if not self.is_doom_loop():
287+
return "continue"
288+
289+
self._recovery_attempts += 1
290+
291+
if self._recovery_attempts <= 1:
292+
return "retry_different"
293+
elif self._recovery_attempts <= 2:
294+
return "escalate_model"
295+
elif self._recovery_attempts <= 3:
296+
return "request_help"
297+
else:
298+
return "abort"
299+
288300
def reset(self) -> None:
289301
"""Reset the tracker."""
290302
self.actions.clear()
291303
self.action_counts.clear()
304+
self._consecutive_failures = 0
305+
self._recovery_attempts = 0
292306

293307

294308
class AutonomyMixin:

src/praisonai-agents/praisonaiagents/approval/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
4040

4141
# ── New protocol-driven exports ──────────────────────────────────────────────
4242

43-
from .protocols import ApprovalProtocol, ApprovalRequest # noqa: F401
43+
from .protocols import ApprovalProtocol, ApprovalRequest, ApprovalConfig # noqa: F401
4444
from .protocols import ApprovalDecision # noqa: F401
4545
from .backends import AutoApproveBackend, ConsoleBackend, CallbackBackend, AgentApproval # noqa: F401
4646
from .registry import ApprovalRegistry, DEFAULT_DANGEROUS_TOOLS # noqa: F401

src/praisonai-agents/praisonaiagents/approval/protocols.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,35 @@ class ApprovalDecision:
5252
metadata: Dict[str, Any] = field(default_factory=dict)
5353

5454

55+
@dataclass
56+
class ApprovalConfig:
57+
"""Configuration for agent-level approval behaviour.
58+
59+
Follows PraisonAI's ``False/True/Config`` progressive-disclosure pattern:
60+
61+
- ``approval=False`` — disabled (no approval checks).
62+
- ``approval=True`` — auto-approve all tools.
63+
- ``approval=SlackApproval()`` — custom backend, dangerous tools only.
64+
- ``approval=ApprovalConfig(...)`` — full control.
65+
66+
Attributes:
67+
backend: An :class:`ApprovalProtocol` backend (``SlackApproval``,
68+
``ConsoleBackend``, etc.). ``None`` falls back to the
69+
global :class:`ApprovalRegistry`.
70+
all_tools: When ``True`` every tool call goes through approval.
71+
When ``False`` (default) only tools in
72+
``DEFAULT_DANGEROUS_TOOLS`` are checked.
73+
timeout: Seconds to wait for an approval response.
74+
- *positive float* — wait up to that many seconds.
75+
- ``None`` — wait **indefinitely** (no timeout).
76+
- *unset / 0* — use the backend's own default timeout.
77+
"""
78+
79+
backend: Any = None
80+
all_tools: bool = False
81+
timeout: Optional[float] = 0
82+
83+
5584
@runtime_checkable
5685
class ApprovalProtocol(Protocol):
5786
"""Protocol for tool-execution approval backends.

src/praisonai-agents/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "praisonaiagents"
7-
version = "1.5.5"
7+
version = "1.5.6"
88
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
99
readme = "README.md"
1010
requires-python = ">=3.10"

0 commit comments

Comments
 (0)