Compare commits

...
6 Commits
Author SHA1 Message Date
Mervin Praison 91aaa9a310 Release v4.5.7 2026-02-16 06:13:47 +00:00
github-actions[bot] 2b54eb571e docs: auto-update api.md [skip ci] 2026-02-15 06:50:27 +00:00
Mervin Praison be167f885e Release v4.5.6 2026-02-15 06:50:13 +00:00
github-actions[bot] 8c4e339aa6 docs: auto-update api.md [skip ci] 2026-02-15 01:04:23 +00:00
Mervin Praison 841c51df84 Release v4.5.5 2026-02-15 01:04:07 +00:00
Mervin Praison 6709ae17e9 Release v4.5.3 2026-02-14 14:24:05 +00:00
44 changed files with 4212 additions and 649 deletions

No files matched your search

+5 -1
View File
@@ -40,6 +40,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat</a>(prompt, temperature = 1.0, tools = None, output_json = None, output_pydantic = None, reasoning_steps = False, stream = None, task_name = None, task_description = None, task_id = None, config = None, force_retrieval = False, skip_retrieval = False, attachments = None, tool_choice = None)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">check_pending_approvals</a>() -> Dict[str, Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clean_json_output</a>(output: str) -> str</code>
@@ -70,6 +71,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">pending_approval_count</a>() -> int</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">policy</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">policy</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">prune_history</a>(keep_last: int = 5) -> int</code>
@@ -90,6 +92,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">store_memory</a>(content: str, memory_type: str = 'short_term', **kwargs)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">submit_for_approval</a>(function_name: str, arguments: Dict[str, Any]) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">switch_model</a>(new_model: str) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value)</code>
@@ -599,11 +602,12 @@ Methods:
Types:
```python
from praisonaiagents import AutonomyLevel, CachingConfig, ExecutionConfig, ExecutionPreset, GuardrailConfig, HooksConfig, KnowledgeConfig, MemoryConfig, MultiAgentExecutionConfig, MultiAgentHooksConfig, MultiAgentMemoryConfig, MultiAgentOutputConfig, MultiAgentPlanningConfig, OutputConfig, OutputPreset, PlanningConfig, ReflectionConfig, SkillsConfig, TemplateConfig, WebConfig, WebSearchProvider
from praisonaiagents import AutonomyConfig, AutonomyLevel, CachingConfig, ExecutionConfig, ExecutionPreset, GuardrailConfig, HooksConfig, KnowledgeConfig, MemoryConfig, MultiAgentExecutionConfig, MultiAgentHooksConfig, MultiAgentMemoryConfig, MultiAgentOutputConfig, MultiAgentPlanningConfig, OutputConfig, OutputPreset, PlanningConfig, ReflectionConfig, SkillsConfig, TemplateConfig, WebConfig, WebSearchProvider
```
Methods:
* <code title="class AutonomyConfig">AutonomyConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/autonomy.py">from_dict</a>(data: Dict[str, Any]) -> 'AutonomyConfig'</code>
* <code title="class CachingConfig">CachingConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class ExecutionConfig">ExecutionConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
+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.2" \
"praisonai>=4.5.7" \
"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.2" \
"praisonai>=4.5.7" \
"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.2" \
"praisonai>=4.5.7" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -337,7 +337,12 @@ _LAZY_IMPORTS = {
'CachingConfig': ('praisonaiagents.config.feature_configs', 'CachingConfig'),
'HooksConfig': ('praisonaiagents.config.feature_configs', 'HooksConfig'),
'SkillsConfig': ('praisonaiagents.config.feature_configs', 'SkillsConfig'),
'AutonomyConfig': ('praisonaiagents.config.feature_configs', 'AutonomyConfig'),
'AutonomyConfig': ('praisonaiagents.agent.autonomy', 'AutonomyConfig'),
'EscalationStage': ('praisonaiagents.escalation.types', 'EscalationStage'),
'EscalationPipeline': ('praisonaiagents.escalation.pipeline', 'EscalationPipeline'),
'ObservabilityHooks': ('praisonaiagents.escalation.observability', 'ObservabilityHooks'),
'ObservabilityEventType': ('praisonaiagents.escalation.observability', 'EventType'),
'DoomLoopDetector': ('praisonaiagents.escalation.doom_loop', 'DoomLoopDetector'),
'MemoryBackend': ('praisonaiagents.config.feature_configs', 'MemoryBackend'),
'ChunkingStrategy': ('praisonaiagents.config.feature_configs', 'ChunkingStrategy'),
'GuardrailAction': ('praisonaiagents.config.feature_configs', 'GuardrailAction'),
@@ -664,6 +669,10 @@ __all__ = [
'EmbeddingResult',
'get_dimensions',
# Autonomy
'AutonomyConfig',
'AutonomyLevel',
# Sub-packages for organized imports
# Usage: import praisonaiagents as pa; pa.config.MemoryConfig
'config',
@@ -1430,14 +1430,38 @@ Your Goal: {self.goal}
self._setup_guardrail()
# Initialize approval backend (agent-centric approval)
# True = AutoApproveBackend, False/None = registry fallback, object = custom backend
# True = AutoApproveBackend, False/None = registry fallback,
# object = custom backend (dangerous tools only),
# ApprovalConfig = full control (all_tools, timeout, etc.)
from ..approval.protocols import ApprovalConfig
if approval is True:
from ..approval.backends import AutoApproveBackend
self._approval_backend = AutoApproveBackend()
self._approve_all_tools = False
self._approval_timeout = 0 # 0 = use backend default
elif approval is False or approval is None:
self._approval_backend = None
self._approve_all_tools = False
self._approval_timeout = 0
elif isinstance(approval, ApprovalConfig):
self._approval_backend = approval.backend
self._approve_all_tools = approval.all_tools
self._approval_timeout = approval.timeout # None = indefinite, 0 = backend default
else:
# Plain backend object — dangerous tools only, backend default timeout
self._approval_backend = approval
self._approve_all_tools = False
self._approval_timeout = 0
# Per-agent autonomy→approval bridge (G-BRIDGE-1 fix)
# If autonomy level is full_auto and no explicit approval was set,
# auto-approve all tools for this agent (no global env var).
autonomy_level = getattr(self, '_autonomy_level_for_approval', None)
if autonomy_level == "full_auto" and (approval is False or approval is None):
from ..approval.backends import AutoApproveBackend
self._approval_backend = AutoApproveBackend()
# Pending approvals for async (non-blocking) mode
self._pending_approvals = {}
# Cache for system prompts and formatted tools with lazy thread-safe lock
self._system_prompt_cache = {}
@@ -1902,20 +1926,13 @@ Summary:"""
from .autonomy import AutonomyConfig, AutonomyTrigger, DoomLoopTracker
if autonomy is True:
self.autonomy_config = {}
config = AutonomyConfig()
elif isinstance(autonomy, dict):
self.autonomy_config = autonomy.copy()
config = AutonomyConfig.from_dict(autonomy)
# Extract verification_hooks from dict if provided
if "verification_hooks" in autonomy and not verification_hooks:
self._verification_hooks = autonomy.get("verification_hooks", [])
elif isinstance(autonomy, AutonomyConfig):
self.autonomy_config = {
"max_iterations": autonomy.max_iterations,
"doom_loop_threshold": autonomy.doom_loop_threshold,
"auto_escalate": autonomy.auto_escalate,
}
config = autonomy
# Extract verification_hooks from AutonomyConfig if provided
if autonomy.verification_hooks and not verification_hooks:
@@ -1927,8 +1944,38 @@ Summary:"""
self._doom_loop_tracker = None
return
# Preserve ALL AutonomyConfig fields in the dict (G14 fix: no lossy extraction)
self.autonomy_config = {
"enabled": config.enabled,
"level": config.level,
"max_iterations": config.max_iterations,
"doom_loop_threshold": config.doom_loop_threshold,
"auto_escalate": config.auto_escalate,
"observe": config.observe,
"completion_promise": config.completion_promise,
"clear_context": config.clear_context,
}
# Also preserve any extra user-provided keys from dict input
if isinstance(autonomy, dict):
for k, v in autonomy.items():
if k not in self.autonomy_config:
self.autonomy_config[k] = v
# Store the AutonomyConfig object for typed access
self._autonomy_config_obj = config
self._autonomy_trigger = AutonomyTrigger()
self._doom_loop_tracker = DoomLoopTracker(threshold=config.doom_loop_threshold)
# Wire ObservabilityHooks when observe=True (G-UNUSED-2 fix)
if config.observe:
from ..escalation.observability import ObservabilityHooks
self._observability_hooks = ObservabilityHooks()
else:
self._observability_hooks = None
# Wire level → approval bridge (G3 fix)
self._bridge_autonomy_level(config.level)
def analyze_prompt(self, prompt: str) -> set:
"""Analyze prompt for autonomy signals.
@@ -1957,7 +2004,7 @@ Summary:"""
signals = self._autonomy_trigger.analyze(prompt)
stage = self._autonomy_trigger.recommend_stage(signals)
return stage.value
return stage.name.lower()
def _record_action(self, action_type: str, args: dict, result: Any, success: bool) -> None:
"""Record an action for doom loop tracking.
@@ -1986,6 +2033,79 @@ Summary:"""
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.reset()
@staticmethod
def _is_completion_signal(response_text: str) -> bool:
"""Check if response contains a completion signal using word boundaries.
Uses regex word boundaries to avoid false positives like
'abandoned' matching 'done' or 'unfinished' matching 'finished'.
Args:
response_text: The agent response to check
Returns:
True if a completion signal is detected
"""
import re
response_lower = response_text.lower()
# Negation patterns that should NOT be treated as completion
_NEGATION_RE = re.compile(
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
r'.{0,20}' # up to 20 chars between negation and keyword
)
# Word-boundary patterns to avoid substring false positives
_COMPLETION_PATTERNS = [
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
(re.compile(r'\bcompleted\s+successfully\b'), False),
(re.compile(r'\ball\s+done\b'), False),
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
]
for pattern, needs_negation_check in _COMPLETION_PATTERNS:
match = pattern.search(response_lower)
if match:
if needs_negation_check:
# Check if a negation word precedes the match within 30 chars
start = max(0, match.start() - 30)
prefix = response_lower[start:match.start()]
if _NEGATION_RE.search(prefix):
continue # Skip — negated completion
# Also check "not X yet" pattern
end = min(len(response_lower), match.end() + 10)
suffix = response_lower[match.end():end]
if 'yet' in suffix:
neg_prefix = response_lower[max(0, match.start() - 15):match.start()]
if 'not' in neg_prefix:
continue
return True
return False
def _get_doom_recovery(self) -> str:
"""Get doom loop recovery action from tracker.
Returns:
Recovery action string: continue, retry_different, escalate_model,
request_help, or abort
"""
if self._doom_loop_tracker is None:
return "continue"
return self._doom_loop_tracker.get_recovery_action()
def _bridge_autonomy_level(self, level: str) -> None:
"""Bridge autonomy level to per-agent approval backend (G3/G-BRIDGE-1 fix).
Sets a flag so the approval backend setup (later in __init__) can
configure per-agent approval based on autonomy level.
Does NOT use global env var — ensures multi-agent isolation.
Args:
level: Autonomy level string (suggest, auto_edit, full_auto)
"""
# Store the requested autonomy level for the approval setup to use.
# The actual _approval_backend is configured later in __init__
# (after _init_autonomy), so we set a flag here.
self._autonomy_level_for_approval = level
def run_autonomous(
self,
prompt: str,
@@ -2082,18 +2202,44 @@ Summary:"""
started_at=started_at,
)
# Check doom loop
# Check doom loop with graduated recovery (G-RECOVERY-2 fix)
if self._is_doom_loop():
return AutonomyResult(
success=False,
output="Task stopped due to repeated actions (doom loop)",
completion_reason="doom_loop",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
recovery = self._get_doom_recovery()
obs = getattr(self, '_observability_hooks', None)
if obs is not None:
from ..escalation.observability import EventType as _EvtType
obs.emit(_EvtType.STEP_END, {
"doom_loop": True,
"recovery_action": recovery,
"iteration": iterations,
})
if recovery == "retry_different":
prompt = prompt + "\n\n[System: Previous approach repeated. Try a completely different strategy.]"
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
continue
elif recovery == "request_help":
return AutonomyResult(
success=False,
output="Task needs human guidance (doom loop recovery exhausted)",
completion_reason="needs_help",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
else:
return AutonomyResult(
success=False,
output="Task stopped due to repeated actions (doom loop)",
completion_reason="doom_loop",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# Execute one turn using the agent's chat method
# Always use the original prompt (prompt re-injection)
@@ -2112,13 +2258,49 @@ Summary:"""
started_at=started_at,
)
# Record the action
response_str = str(response)
# Record the action for doom loop tracking (G1 fix: was missing)
self._record_action(
"chat", {"prompt": prompt}, response_str[:200], True
)
# Record for action history
actions_taken.append({
"iteration": iterations,
"response": str(response)[:500],
"response": response_str[:500],
})
response_str = str(response)
# Observability logging via ObservabilityHooks (G-UNUSED-2 fix)
obs = getattr(self, '_observability_hooks', None)
if obs is not None:
from ..escalation.observability import EventType as _EvtType
obs.increment_step()
obs.emit(_EvtType.STEP_END, {
"iteration": iterations,
"stage": stage,
"response_len": len(response_str),
"agent_name": getattr(self, 'name', None),
})
elif self.autonomy_config.get("observe"):
logging.getLogger(__name__).info(
f"[autonomy] iteration={iterations} stage={stage} "
f"response_len={len(response_str)}"
)
# Auto-save session after each iteration (memory integration)
self._auto_save_session()
# Auto-escalate stage if stuck (G11 fix: wire auto_escalate)
if (self.autonomy_config.get("auto_escalate")
and iterations > 1
and stage in ("direct", "heuristic")):
stage_order = ["direct", "heuristic", "planned", "autonomous"]
idx = stage_order.index(stage) if stage in stage_order else 0
if idx < len(stage_order) - 1:
stage = stage_order[idx + 1]
if obs is not None:
obs.emit(_EvtType.STAGE_ESCALATE, {"from": stage_order[idx], "to": stage})
# Check for completion promise FIRST (structured signal)
if effective_promise:
@@ -2135,14 +2317,8 @@ Summary:"""
started_at=started_at,
)
# Check for keyword-based completion signals (fallback)
response_lower = response_str.lower()
completion_signals = [
"task completed", "task complete", "done",
"finished", "completed successfully",
]
if any(signal in response_lower for signal in completion_signals):
# Check for keyword-based completion signals (word-boundary, G-COMPLETION-1 fix)
if self._is_completion_signal(response_str):
return AutonomyResult(
success=True,
output=response_str,
@@ -2182,7 +2358,18 @@ Summary:"""
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
except KeyboardInterrupt:
return AutonomyResult(
success=False,
output="Task cancelled by user",
completion_reason="cancelled",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
except Exception as e:
return AutonomyResult(
success=False,
@@ -2298,18 +2485,44 @@ Summary:"""
started_at=started_at,
)
# Check doom loop
# Check doom loop with graduated recovery (G-RECOVERY-2 fix)
if self._is_doom_loop():
return AutonomyResult(
success=False,
output="Task stopped due to repeated actions (doom loop)",
completion_reason="doom_loop",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
recovery = self._get_doom_recovery()
obs = getattr(self, '_observability_hooks', None)
if obs is not None:
from ..escalation.observability import EventType as _EvtType
obs.emit(_EvtType.STEP_END, {
"doom_loop": True,
"recovery_action": recovery,
"iteration": iterations,
})
if recovery == "retry_different":
prompt = prompt + "\n\n[System: Previous approach repeated. Try a completely different strategy.]"
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
continue
elif recovery == "request_help":
return AutonomyResult(
success=False,
output="Task needs human guidance (doom loop recovery exhausted)",
completion_reason="needs_help",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
else:
return AutonomyResult(
success=False,
output="Task stopped due to repeated actions (doom loop)",
completion_reason="doom_loop",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# Execute one turn using the agent's async chat method
# Always use the original prompt (prompt re-injection)
@@ -2328,13 +2541,49 @@ Summary:"""
started_at=started_at,
)
# Record the action
response_str = str(response)
# Record the action for doom loop tracking (G2 fix: was missing)
self._record_action(
"chat", {"prompt": prompt}, response_str[:200], True
)
# Record for action history
actions_taken.append({
"iteration": iterations,
"response": str(response)[:500],
"response": response_str[:500],
})
response_str = str(response)
# Observability logging via ObservabilityHooks (G-UNUSED-2 fix)
obs = getattr(self, '_observability_hooks', None)
if obs is not None:
from ..escalation.observability import EventType as _EvtType
obs.increment_step()
obs.emit(_EvtType.STEP_END, {
"iteration": iterations,
"stage": stage,
"response_len": len(response_str),
"agent_name": getattr(self, 'name', None),
})
elif self.autonomy_config.get("observe"):
logging.getLogger(__name__).info(
f"[autonomy-async] iteration={iterations} stage={stage} "
f"response_len={len(response_str)}"
)
# Auto-save session after each async iteration (memory integration)
self._auto_save_session()
# Auto-escalate stage if stuck (G11 fix: wire auto_escalate)
if (self.autonomy_config.get("auto_escalate")
and iterations > 1
and stage in ("direct", "heuristic")):
stage_order = ["direct", "heuristic", "planned", "autonomous"]
idx = stage_order.index(stage) if stage in stage_order else 0
if idx < len(stage_order) - 1:
stage = stage_order[idx + 1]
if obs is not None:
obs.emit(_EvtType.STAGE_ESCALATE, {"from": stage_order[idx], "to": stage})
# Check for completion promise FIRST (structured signal)
if effective_promise:
@@ -2351,14 +2600,8 @@ Summary:"""
started_at=started_at,
)
# Check for keyword-based completion signals (fallback)
response_lower = response_str.lower()
completion_signals = [
"task completed", "task complete", "done",
"finished", "completed successfully",
]
if any(signal in response_lower for signal in completion_signals):
# Check for keyword-based completion signals (word-boundary, G-COMPLETION-1 fix)
if self._is_completion_signal(response_str):
return AutonomyResult(
success=True,
output=response_str,
@@ -2390,6 +2633,9 @@ Summary:"""
# Yield control to allow other async tasks to run
await asyncio.sleep(0)
# Final auto-save before returning
self._auto_save_session()
# Max iterations reached
return AutonomyResult(
success=False,
@@ -2401,7 +2647,18 @@ Summary:"""
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
except (KeyboardInterrupt, asyncio.CancelledError):
return AutonomyResult(
success=False,
output="Task cancelled",
completion_reason="cancelled",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
except Exception as e:
return AutonomyResult(
success=False,
@@ -4142,25 +4399,45 @@ Your Goal: {self.goal}"""
from ..approval import get_approval_registry
try:
backend = getattr(self, '_approval_backend', None)
approve_all = getattr(self, '_approve_all_tools', False)
if backend is not None:
# Agent-level approval backend — bypass registry, call directly
from ..approval.protocols import ApprovalRequest
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
if function_name in DEFAULT_DANGEROUS_TOOLS:
# Gate: only dangerous tools unless approve_all is True
needs_approval = approve_all or function_name in DEFAULT_DANGEROUS_TOOLS
if needs_approval:
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
if hasattr(backend, 'request_approval_sync'):
decision = backend.request_approval_sync(request)
# Apply timeout override from ApprovalConfig
cfg_timeout = getattr(self, '_approval_timeout', 0)
if cfg_timeout is None:
# None = indefinite — set backend timeout temporarily
orig_timeout = getattr(backend, '_timeout', None)
if orig_timeout is not None:
backend._timeout = 86400 * 365 # ~1 year as "indefinite"
elif cfg_timeout > 0:
orig_timeout = getattr(backend, '_timeout', None)
if orig_timeout is not None:
backend._timeout = cfg_timeout
else:
import asyncio
decision = asyncio.run(backend.request_approval(request))
orig_timeout = None # 0 = use backend default
try:
if hasattr(backend, 'request_approval_sync'):
decision = backend.request_approval_sync(request)
else:
import asyncio
decision = asyncio.run(backend.request_approval(request))
finally:
# Restore original timeout
if orig_timeout is not None and hasattr(backend, '_timeout'):
backend._timeout = orig_timeout
else:
from ..approval.protocols import ApprovalDecision
decision = ApprovalDecision(approved=True, reason="No approval required")
decision = ApprovalDecision(approved=True, reason="Not a dangerous tool")
else:
# Fallback to global registry
decision = get_approval_registry().approve_sync(
@@ -6869,6 +7146,91 @@ Write the complete compiled report:"""
if response:
yield response
# ── Async (non-blocking) approval helpers ───────────────────────────
async def submit_for_approval(self, function_name: str, arguments: Dict[str, Any]) -> str:
"""Fire an approval request in the background without blocking.
Returns a tracking ID. The agent can continue other work while the
approval is pending. Call :meth:`check_pending_approvals` to poll
for results and auto-execute approved tools.
"""
import uuid
backend = getattr(self, '_approval_backend', None)
if backend is None:
raise RuntimeError("No approval backend configured on this agent")
from ..approval.protocols import ApprovalRequest
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
tracking_id = str(uuid.uuid4())[:8]
task = asyncio.ensure_future(backend.request_approval(request))
self._pending_approvals[tracking_id] = {
"task": task,
"function_name": function_name,
"arguments": arguments,
"request": request,
}
logging.info(f"Approval request submitted: {tracking_id} for {function_name}")
return tracking_id
async def check_pending_approvals(self) -> Dict[str, Any]:
"""Check and process any completed approval requests.
Returns a dict of ``{tracking_id: result}`` for approvals that
completed since the last check. Approved tools are auto-executed
and their results included.
"""
results = {}
completed_ids = []
for tid, info in self._pending_approvals.items():
task = info["task"]
if task.done():
completed_ids.append(tid)
try:
decision = task.result()
if decision.approved:
# Auto-execute the approved tool
tool_result = await self.execute_tool_async(
info["function_name"], info["arguments"],
)
results[tid] = {
"status": "approved_and_executed",
"tool_name": info["function_name"],
"decision": decision,
"result": tool_result,
}
else:
results[tid] = {
"status": "denied",
"tool_name": info["function_name"],
"decision": decision,
}
except Exception as e:
results[tid] = {
"status": "error",
"tool_name": info["function_name"],
"error": str(e),
}
for tid in completed_ids:
del self._pending_approvals[tid]
return results
@property
def pending_approval_count(self) -> int:
"""Number of approval requests still waiting."""
return len(self._pending_approvals)
def execute(self, task, context=None):
"""Execute a task synchronously - backward compatibility method"""
if hasattr(task, 'description'):
@@ -6903,20 +7265,35 @@ Write the complete compiled report:"""
from ..approval import get_approval_registry
try:
backend = getattr(self, '_approval_backend', None)
approve_all = getattr(self, '_approve_all_tools', False)
if backend is not None:
from ..approval.protocols import ApprovalRequest
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
if function_name in DEFAULT_DANGEROUS_TOOLS:
# Gate: only dangerous tools unless approve_all is True
needs_approval = approve_all or function_name in DEFAULT_DANGEROUS_TOOLS
if needs_approval:
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
decision = await backend.request_approval(request)
# Apply timeout override from ApprovalConfig
cfg_timeout = getattr(self, '_approval_timeout', 0)
if cfg_timeout is None:
# None = indefinite wait
decision = await backend.request_approval(request)
elif cfg_timeout > 0:
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=cfg_timeout,
)
else:
# 0 = use backend default timeout
decision = await backend.request_approval(request)
else:
from ..approval.protocols import ApprovalDecision
decision = ApprovalDecision(approved=True, reason="No approval required")
decision = ApprovalDecision(approved=True, reason="Not a dangerous tool")
else:
decision = await get_approval_registry().approve_async(
getattr(self, 'name', None), function_name, arguments,
@@ -5,6 +5,11 @@ Provides agent-centric autonomy configuration and helpers.
This module integrates escalation, doom loop detection, and observability
directly into the Agent class as first-class capabilities.
Unified Architecture:
- AutonomyStage is an alias for EscalationStage (single source of truth)
- AutonomyTrigger delegates to EscalationTrigger (DRY)
- DoomLoopTracker adds recovery actions on top of basic detection
Usage:
from praisonaiagents import Agent
@@ -27,20 +32,45 @@ Usage:
"""
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Set, Callable, Protocol, runtime_checkable
from typing import Optional, Dict, Any, List, Set
from enum import Enum
import time
import logging
logger = logging.getLogger(__name__)
# ============================================================================
# Unified Stage Enum (G-DUP-1 fix: single source of truth)
# ============================================================================
# AutonomyStage is an alias for EscalationStage so both systems share
# the same IntEnum. Backward-compatible: AutonomyStage.DIRECT == EscalationStage.DIRECT.
from ..escalation.types import EscalationStage as AutonomyStage # noqa: F401
class AutonomyStage(str, Enum):
"""Autonomy execution stages."""
DIRECT = "direct"
HEURISTIC = "heuristic"
PLANNED = "planned"
AUTONOMOUS = "autonomous"
# Valid autonomy levels for AutonomyConfig.level
VALID_AUTONOMY_LEVELS = {"suggest", "auto_edit", "full_auto"}
# Signal name mapping: EscalationSignal.value → AutonomyTrigger string
# Keeps backward compat for existing code that checks string signal names.
_ESCALATION_TO_AUTONOMY_SIGNAL = {
"simple_question": "simple_question",
"file_references": "file_references",
"code_blocks": "code_blocks",
"edit_intent": "edit_intent",
"test_intent": "test_intent",
"refactor_intent": "refactor_intent",
"multi_step_intent": "multi_step",
"complex_keywords": "complex_keywords",
"long_prompt": "long_prompt",
"repo_context": "repo_context",
"build_intent": "build_intent",
"tool_failure": "tool_failure",
"ambiguous_result": "ambiguous_result",
"incomplete_task": "incomplete_task",
"clarification": "clarification",
"acknowledgment": "acknowledgment",
}
# Reverse map for converting autonomy signal strings back to EscalationSignal values
_AUTONOMY_TO_ESCALATION_SIGNAL = {v: k for k, v in _ESCALATION_TO_AUTONOMY_SIGNAL.items()}
@dataclass
@@ -49,6 +79,7 @@ class AutonomyConfig:
Attributes:
enabled: Whether autonomy is enabled
level: Autonomy level (suggest, auto_edit, full_auto)
max_iterations: Maximum iterations before stopping
doom_loop_threshold: Number of repeated actions to trigger doom loop
auto_escalate: Whether to automatically escalate complexity
@@ -58,6 +89,7 @@ class AutonomyConfig:
verification_hooks: List of VerificationHook instances for output verification
"""
enabled: bool = True
level: str = "suggest"
max_iterations: int = 20
doom_loop_threshold: int = 3
auto_escalate: bool = True
@@ -66,11 +98,25 @@ class AutonomyConfig:
clear_context: bool = False
verification_hooks: Optional[List[Any]] = None
def __post_init__(self):
if self.level not in VALID_AUTONOMY_LEVELS:
raise ValueError(
f"Invalid autonomy level: {self.level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AutonomyConfig":
"""Create config from dictionary."""
level = data.get("level", "suggest")
if level not in VALID_AUTONOMY_LEVELS:
raise ValueError(
f"Invalid autonomy level: {level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
return cls(
enabled=data.get("enabled", True),
level=level,
max_iterations=data.get("max_iterations", 20),
doom_loop_threshold=data.get("doom_loop_threshold", 3),
auto_escalate=data.get("auto_escalate", True),
@@ -82,7 +128,13 @@ class AutonomyConfig:
class AutonomySignal(str, Enum):
"""Signals detected from prompts for autonomy decisions."""
"""Signals detected from prompts for autonomy decisions.
.. deprecated::
AutonomySignal is deprecated. Use EscalationSignal from
praisonaiagents.escalation.types instead. AutonomyTrigger
returns plain strings, not AutonomySignal values.
"""
SIMPLE_QUESTION = "simple_question"
FILE_REFERENCES = "file_references"
CODE_BLOCKS = "code_blocks"
@@ -91,50 +143,38 @@ class AutonomySignal(str, Enum):
REFACTOR_INTENT = "refactor_intent"
MULTI_STEP = "multi_step"
COMPLEX_KEYWORDS = "complex_keywords"
def __init_subclass__(cls, **kwargs):
import warnings
warnings.warn(
"AutonomySignal is deprecated. Use EscalationSignal instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init_subclass__(**kwargs)
def _warn_autonomy_signal():
"""Emit deprecation warning when AutonomySignal is accessed."""
import warnings
warnings.warn(
"AutonomySignal is deprecated. Use EscalationSignal from "
"praisonaiagents.escalation.types instead.",
DeprecationWarning,
stacklevel=3,
)
class AutonomyTrigger:
"""Detects signals from prompts for autonomy decisions.
Uses fast heuristics (no LLM calls) to analyze prompts.
Delegates to EscalationTrigger (DRY, G-DUP-3 fix) and maps
signal names for backward compatibility.
"""
# Keywords that indicate simple questions
SIMPLE_KEYWORDS = {
"what is", "what's", "define", "explain", "describe",
"how does", "why is", "when was", "who is", "where is"
}
# Keywords that indicate complex tasks
COMPLEX_KEYWORDS = {
"analyze", "refactor", "optimize", "implement", "design",
"architect", "debug", "fix", "modify", "update", "change",
"create", "build", "develop", "integrate", "migrate"
}
# Keywords that indicate edit intent
EDIT_KEYWORDS = {
"edit", "modify", "change", "update", "fix", "add", "remove",
"delete", "replace", "insert", "write", "rewrite"
}
# Keywords that indicate test intent
TEST_KEYWORDS = {
"test", "verify", "validate", "check", "assert", "ensure",
"unit test", "integration test", "e2e", "coverage"
}
# Keywords that indicate refactor intent
REFACTOR_KEYWORDS = {
"refactor", "restructure", "reorganize", "clean up",
"simplify", "extract", "inline", "rename"
}
# Multi-step indicators
MULTI_STEP_INDICATORS = {
"first", "then", "next", "after", "finally", "step",
"1.", "2.", "3.", "and then", "followed by"
}
def __init__(self):
from ..escalation.triggers import EscalationTrigger
self._delegate = EscalationTrigger()
def analyze(self, prompt: str) -> Set[str]:
"""Analyze prompt and return detected signals.
@@ -145,46 +185,11 @@ class AutonomyTrigger:
Returns:
Set of signal names (lowercase strings)
"""
signals: Set[str] = set()
prompt_lower = prompt.lower()
# Check for simple questions
if any(kw in prompt_lower for kw in self.SIMPLE_KEYWORDS):
word_count = len(prompt.split())
if word_count < 30:
signals.add("simple_question")
# Check for file references
import re
file_pattern = r'[\w\-./]+\.(py|js|ts|tsx|jsx|java|go|rs|cpp|c|h|md|txt|json|yaml|yml|toml)'
if re.search(file_pattern, prompt):
signals.add("file_references")
# Check for code blocks
if "```" in prompt:
signals.add("code_blocks")
# Check for edit intent
if any(kw in prompt_lower for kw in self.EDIT_KEYWORDS):
signals.add("edit_intent")
# Check for test intent
if any(kw in prompt_lower for kw in self.TEST_KEYWORDS):
signals.add("test_intent")
# Check for refactor intent
if any(kw in prompt_lower for kw in self.REFACTOR_KEYWORDS):
signals.add("refactor_intent")
# Check for multi-step
if any(ind in prompt_lower for ind in self.MULTI_STEP_INDICATORS):
signals.add("multi_step")
# Check for complex keywords
if any(kw in prompt_lower for kw in self.COMPLEX_KEYWORDS):
signals.add("complex_keywords")
return signals
escalation_signals = self._delegate.analyze(prompt)
return {
_ESCALATION_TO_AUTONOMY_SIGNAL.get(s.value, s.value)
for s in escalation_signals
}
def recommend_stage(self, signals: Set[str]) -> AutonomyStage:
"""Recommend execution stage based on signals.
@@ -193,22 +198,17 @@ class AutonomyTrigger:
signals: Set of detected signal names
Returns:
Recommended AutonomyStage
Recommended AutonomyStage (alias for EscalationStage)
"""
# AUTONOMOUS: multi-step or refactor
if "multi_step" in signals or "refactor_intent" in signals:
return AutonomyStage.AUTONOMOUS
# PLANNED: edit or test intent
if "edit_intent" in signals or "test_intent" in signals:
return AutonomyStage.PLANNED
# HEURISTIC: file references or code blocks
if "file_references" in signals or "code_blocks" in signals:
return AutonomyStage.HEURISTIC
# DIRECT: simple questions or no signals
return AutonomyStage.DIRECT
from ..escalation.types import EscalationSignal
esc_signals = set()
for s in signals:
signal_value = _AUTONOMY_TO_ESCALATION_SIGNAL.get(s, s)
try:
esc_signals.add(EscalationSignal(signal_value))
except ValueError:
pass
return self._delegate.recommend_stage(esc_signals)
@dataclass
@@ -238,24 +238,36 @@ class AutonomyResult:
class DoomLoopTracker:
"""Tracks actions to detect doom loops.
"""Tracks actions to detect doom loops with graduated recovery.
A doom loop occurs when the agent repeats the same action
multiple times without making progress.
Delegates to DoomLoopDetector (DRY, G-DUP-2 fix) for detection
while providing graduated recovery actions on top.
Recovery progression:
1st doom loop → retry_different (try new approach)
2nd doom loop → escalate_model (use stronger model)
3rd doom loop → request_help (ask human)
4th+ doom loop → abort (stop execution)
"""
def __init__(self, threshold: int = 3):
"""Initialize tracker.
"""Initialize tracker with DoomLoopDetector delegation.
Args:
threshold: Number of repeated actions to trigger doom loop
"""
from ..escalation.doom_loop import DoomLoopDetector, DoomLoopConfig
self.threshold = threshold
self.actions: List[str] = []
self.action_counts: Dict[str, int] = {}
self._delegate = DoomLoopDetector(DoomLoopConfig(
max_identical_actions=threshold,
max_consecutive_failures=threshold,
max_similar_actions=threshold + 2,
))
self._delegate.start_session()
self._recovery_attempts: int = 0
def record(self, action_type: str, args: Dict[str, Any], result: Any, success: bool) -> None:
"""Record an action.
"""Record an action for loop detection.
Args:
action_type: Type of action (e.g., "read_file")
@@ -263,10 +275,12 @@ class DoomLoopTracker:
result: Action result
success: Whether action succeeded
"""
# Create action signature
sig = f"{action_type}:{hash(str(sorted(args.items())))}"
self.actions.append(sig)
self.action_counts[sig] = self.action_counts.get(sig, 0) + 1
self._delegate.record_action(
action_type=action_type,
args=args,
result=result,
success=success,
)
def is_doom_loop(self) -> bool:
"""Check if we're in a doom loop.
@@ -274,411 +288,63 @@ class DoomLoopTracker:
Returns:
True if doom loop detected
"""
if not self.actions:
return False
return self._delegate.is_doom_loop()
def get_recovery_action(self) -> str:
"""Get recommended recovery action when doom loop detected.
# Check if any action repeated too many times
for count in self.action_counts.values():
if count >= self.threshold:
return True
Returns graduated recovery actions:
- "retry_different": Try a different approach (1st detection)
- "escalate_model": Escalate to stronger model (2nd detection)
- "request_help": Request human intervention (3rd detection)
- "abort": Stop execution (4th+ detection)
- "continue": No doom loop, continue normally
"""
if not self.is_doom_loop():
return "continue"
return False
self._recovery_attempts += 1
if self._recovery_attempts <= 1:
return "retry_different"
elif self._recovery_attempts <= 2:
return "escalate_model"
elif self._recovery_attempts <= 3:
return "request_help"
else:
return "abort"
def clear_actions(self) -> None:
"""Clear action history but keep recovery attempt count."""
self._delegate.start_session()
def mark_progress(self, marker: str) -> None:
"""Mark meaningful progress to reset no-progress detection.
Args:
marker: Description of progress made
"""
self._delegate.mark_progress(marker)
def reset(self) -> None:
"""Reset the tracker."""
self.actions.clear()
self.action_counts.clear()
"""Reset the tracker completely."""
self._delegate.start_session()
self._recovery_attempts = 0
class AutonomyMixin:
"""Mixin class that adds autonomy capabilities to Agent.
"""Helper-only trait for autonomy utilities.
This mixin provides:
- Signal detection from prompts
- Stage recommendation
- Doom loop detection
- Observability hooks
This module provides reusable helper classes (AutonomyConfig, AutonomyTrigger,
DoomLoopTracker, AutonomyResult, etc.) that the Agent class uses.
The Agent class is the SINGLE OWNER of all autonomy methods:
- _init_autonomy()
- run_autonomous() / run_autonomous_async()
- analyze_prompt() / get_recommended_stage()
- _record_action() / _is_doom_loop() / _reset_doom_loop()
This class is intentionally empty — kept only for backward compatibility
in case external code references it.
"""
def _init_autonomy(self, autonomy: Any, verification_hooks: Optional[List[Any]] = None) -> None:
"""Initialize autonomy features.
Args:
autonomy: True, False, dict config, or AutonomyConfig
verification_hooks: Optional list of verification hooks
"""
# Initialize verification hooks (always available, even without autonomy)
self._verification_hooks = verification_hooks or []
if autonomy is None or autonomy is False:
self.autonomy_enabled = False
self.autonomy_config = {}
self._autonomy_trigger = None
self._doom_loop_tracker = None
return
self.autonomy_enabled = True
if autonomy is True:
self.autonomy_config = {}
config = AutonomyConfig()
elif isinstance(autonomy, dict):
self.autonomy_config = autonomy.copy()
config = AutonomyConfig.from_dict(autonomy)
# Extract verification_hooks from dict if provided
if "verification_hooks" in autonomy and not verification_hooks:
self._verification_hooks = autonomy.get("verification_hooks", [])
elif isinstance(autonomy, AutonomyConfig):
self.autonomy_config = {
"max_iterations": autonomy.max_iterations,
"doom_loop_threshold": autonomy.doom_loop_threshold,
"auto_escalate": autonomy.auto_escalate,
}
config = autonomy
else:
self.autonomy_enabled = False
self.autonomy_config = {}
self._autonomy_trigger = None
self._doom_loop_tracker = None
return
self._autonomy_trigger = AutonomyTrigger()
self._doom_loop_tracker = DoomLoopTracker(threshold=config.doom_loop_threshold)
def analyze_prompt(self, prompt: str) -> Set[str]:
"""Analyze prompt for autonomy signals.
Args:
prompt: The user prompt
Returns:
Set of detected signal names
"""
if not self.autonomy_enabled or self._autonomy_trigger is None:
return set()
return self._autonomy_trigger.analyze(prompt)
def get_recommended_stage(self, prompt: str) -> str:
"""Get recommended execution stage for prompt.
Args:
prompt: The user prompt
Returns:
Stage name as string (direct, heuristic, planned, autonomous)
"""
if not self.autonomy_enabled or self._autonomy_trigger is None:
return "direct"
signals = self._autonomy_trigger.analyze(prompt)
stage = self._autonomy_trigger.recommend_stage(signals)
return stage.value
def _record_action(self, action_type: str, args: Dict[str, Any], result: Any, success: bool) -> None:
"""Record an action for doom loop tracking.
Args:
action_type: Type of action
args: Action arguments
result: Action result
success: Whether action succeeded
"""
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.record(action_type, args, result, success)
def _is_doom_loop(self) -> bool:
"""Check if we're in a doom loop.
Returns:
True if doom loop detected
"""
if self._doom_loop_tracker is None:
return False
return self._doom_loop_tracker.is_doom_loop()
def _reset_doom_loop(self) -> None:
"""Reset doom loop tracking."""
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.reset()
def run_autonomous(
self,
prompt: str,
max_iterations: Optional[int] = None,
timeout_seconds: Optional[float] = None,
completion_promise: Optional[str] = None,
clear_context: bool = False,
) -> AutonomyResult:
"""Run an autonomous task execution loop.
This method executes a task autonomously, using the agent's tools
and capabilities to complete the task. It handles:
- Progressive escalation based on task complexity
- Doom loop detection and recovery
- Iteration limits and timeouts
- Completion detection (keyword-based or promise-based)
- Optional context clearing between iterations
Args:
prompt: The task to execute
max_iterations: Override max iterations (default from config)
timeout_seconds: Timeout in seconds (default: no timeout)
completion_promise: Optional string that signals completion when
wrapped in <promise>TEXT</promise> tags in the response
clear_context: Whether to clear chat history between iterations
(forces agent to rely on external state like files)
Returns:
AutonomyResult with success status, output, and metadata
Raises:
ValueError: If autonomy is not enabled
Example:
agent = Agent(instructions="...", autonomy=True)
result = agent.run_autonomous(
"Refactor the auth module",
completion_promise="DONE",
clear_context=True
)
if result.success:
print(result.output)
"""
if not self.autonomy_enabled:
raise ValueError(
"Autonomy must be enabled to use run_autonomous(). "
"Create agent with autonomy=True or autonomy={...}"
)
start_time = time.time()
iterations = 0
actions_taken: List[Dict[str, Any]] = []
# Get config values
config_max_iter = self.autonomy_config.get("max_iterations", 20)
effective_max_iter = max_iterations if max_iterations is not None else config_max_iter
# Get completion_promise from config if not provided as param
effective_promise = completion_promise
if effective_promise is None:
effective_promise = self.autonomy_config.get("completion_promise")
# Get clear_context from config if not explicitly set
effective_clear_context = clear_context
if not clear_context:
effective_clear_context = self.autonomy_config.get("clear_context", False)
# Analyze prompt and get recommended stage
stage = self.get_recommended_stage(prompt)
# Reset doom loop tracker for new task
self._reset_doom_loop()
try:
# Execute the autonomous loop
while iterations < effective_max_iter:
iterations += 1
# Check timeout
if timeout_seconds and (time.time() - start_time) > timeout_seconds:
return AutonomyResult(
success=False,
output="Task timed out",
completion_reason="timeout",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time.time() - start_time,
)
# Check doom loop
if self._is_doom_loop():
logger.warning(f"Doom loop detected after {iterations} iterations")
return AutonomyResult(
success=False,
output="Task stopped due to repeated actions (doom loop)",
completion_reason="doom_loop",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time.time() - start_time,
)
# Execute one turn using the agent's chat method
# Always use the original prompt (prompt re-injection)
try:
response = self.chat(prompt)
except Exception as e:
logger.error(f"Error during autonomous execution: {e}")
return AutonomyResult(
success=False,
output=str(e),
completion_reason="error",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time.time() - start_time,
error=str(e),
)
# Record the action
actions_taken.append({
"iteration": iterations,
"response": str(response)[:500], # Truncate for storage
})
response_str = str(response)
# Check for completion promise FIRST (structured signal)
if effective_promise:
promise_tag = f"<promise>{effective_promise}</promise>"
if promise_tag in response_str:
return AutonomyResult(
success=True,
output=response_str,
completion_reason="promise",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time.time() - start_time,
)
# Check for keyword-based completion signals (fallback)
response_lower = response_str.lower()
completion_signals = [
"task completed",
"task complete",
"done",
"finished",
"completed successfully",
]
if any(signal in response_lower for signal in completion_signals):
return AutonomyResult(
success=True,
output=response_str,
completion_reason="goal",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time.time() - start_time,
)
# For DIRECT stage, complete after first response
if stage == "direct":
return AutonomyResult(
success=True,
output=response_str,
completion_reason="goal",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time.time() - start_time,
)
# Clear context between iterations if enabled
if effective_clear_context:
self.clear_history()
# Max iterations reached
return AutonomyResult(
success=False,
output="Max iterations reached",
completion_reason="max_iterations",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time.time() - start_time,
)
except Exception as e:
logger.error(f"Unexpected error in run_autonomous: {e}")
return AutonomyResult(
success=False,
output=str(e),
completion_reason="error",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time.time() - start_time,
error=str(e),
)
def delegate(
self,
task: str,
profile: str = "general",
timeout_seconds: float = 300.0,
context: Optional[Dict[str, Any]] = None,
) -> Any:
"""Delegate a task to a subagent.
Args:
task: Task description for the subagent
profile: Agent profile to use (explorer, coder, tester, etc.)
timeout_seconds: Timeout for the delegated task
context: Additional context to pass to subagent
Returns:
Result from the subagent
"""
subagent = self._create_subagent(profile, context)
return subagent.chat(task)
def _create_subagent(
self,
profile: str,
context: Optional[Dict[str, Any]] = None,
) -> Any:
"""Create a subagent with the specified profile.
Args:
profile: Agent profile name
context: Additional context
Returns:
Configured Agent instance
"""
# Import here to avoid circular imports
from .agent import Agent
from ..agents.profiles import get_profile, BUILTIN_PROFILES
# Get profile config
profile_config = get_profile(profile) if profile in BUILTIN_PROFILES else None
if profile_config:
return Agent(
name=f"subagent_{profile}",
instructions=profile_config.system_prompt,
tools=profile_config.tools if hasattr(profile_config, 'tools') else None,
)
else:
# Default subagent
return Agent(
name=f"subagent_{profile}",
instructions=f"You are a {profile} assistant.",
)
def _run_verification_hooks(self) -> List[Dict[str, Any]]:
"""Run all registered verification hooks.
Returns:
List of verification results
"""
results = []
if hasattr(self, '_verification_hooks') and self._verification_hooks:
for hook in self._verification_hooks:
try:
result = hook.run()
results.append({
"hook": hook.name,
"success": result.get("success", False),
"output": result.get("output", ""),
})
except Exception as e:
results.append({
"hook": getattr(hook, 'name', 'unknown'),
"success": False,
"error": str(e),
})
return results
pass
@@ -414,9 +414,13 @@ class AgentTeam:
self.user_id = _user_id
self.max_iter = _max_iter
# Pass user_id to each agent
# Pass user_id and autonomy to each agent
for agent in agents:
agent.user_id = self.user_id
# Propagate autonomy config to agents that don't already have it
if self._autonomy is not None and self._autonomy is not False:
if not agent.autonomy_enabled:
agent._init_autonomy(self._autonomy)
self.agents: List[Agent] = agents
self.tasks: Dict[int, Task] = {}
@@ -40,7 +40,7 @@ from typing import Callable, Dict, List, Literal, Optional, Set
# ── New protocol-driven exports ──────────────────────────────────────────────
from .protocols import ApprovalProtocol, ApprovalRequest # noqa: F401
from .protocols import ApprovalProtocol, ApprovalRequest, ApprovalConfig # noqa: F401
from .protocols import ApprovalDecision # noqa: F401
from .backends import AutoApproveBackend, ConsoleBackend, CallbackBackend, AgentApproval # noqa: F401
from .registry import ApprovalRegistry, DEFAULT_DANGEROUS_TOOLS # noqa: F401
@@ -52,6 +52,35 @@ class ApprovalDecision:
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ApprovalConfig:
"""Configuration for agent-level approval behaviour.
Follows PraisonAI's ``False/True/Config`` progressive-disclosure pattern:
- ``approval=False`` — disabled (no approval checks).
- ``approval=True`` — auto-approve all tools.
- ``approval=SlackApproval()`` — custom backend, dangerous tools only.
- ``approval=ApprovalConfig(...)`` — full control.
Attributes:
backend: An :class:`ApprovalProtocol` backend (``SlackApproval``,
``ConsoleBackend``, etc.). ``None`` falls back to the
global :class:`ApprovalRegistry`.
all_tools: When ``True`` every tool call goes through approval.
When ``False`` (default) only tools in
``DEFAULT_DANGEROUS_TOOLS`` are checked.
timeout: Seconds to wait for an approval response.
- *positive float* — wait up to that many seconds.
- ``None`` — wait **indefinitely** (no timeout).
- *unset / 0* — use the backend's own default timeout.
"""
backend: Any = None
all_tools: bool = False
timeout: Optional[float] = 0
@runtime_checkable
class ApprovalProtocol(Protocol):
"""Protocol for tool-execution approval backends.
@@ -72,6 +72,11 @@ class ShellTools:
if cwd:
cwd = os.path.expanduser(cwd)
cwd = os.path.expandvars(cwd) # Also expand $HOME, $USER, etc.
if not os.path.isdir(cwd):
# Fallback: try home directory, then current working directory
fallback = os.path.expanduser("~") if os.path.isdir(os.path.expanduser("~")) else os.getcwd()
logging.warning(f"Working directory '{cwd}' does not exist, using '{fallback}'")
cwd = fallback
# Set up process environment
process_env = os.environ.copy()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.2"
version = "1.5.7"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -240,8 +240,8 @@ class TestProgressiveEscalation:
stage1 = agent.get_recommended_stage("What is Python?")
assert stage1 == "direct"
# Then ask about a file
stage2 = agent.get_recommended_stage("Show me main.py")
# A prompt with file references but no simple-question signal → heuristic
stage2 = agent.get_recommended_stage("Read the contents of main.py and config.yaml")
assert stage2 == "heuristic"
@@ -0,0 +1,129 @@
"""Tests for praisonai.cli.features.approval — resolve_approval_backend."""
import os
import sys
import pytest
# Ensure praisonai wrapper package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "praisonai"))
class TestResolveApprovalBackend:
"""Test resolve_approval_backend() maps CLI strings to backend objects."""
def _resolve(self, value):
from praisonai.cli.features.approval import resolve_approval_backend
return resolve_approval_backend(value)
def test_none_returns_none(self):
assert self._resolve(None) is None
def test_none_string_returns_none(self):
assert self._resolve("none") is None
assert self._resolve("None") is None
def test_console_returns_console_backend(self):
b = self._resolve("console")
assert type(b).__name__ == "ConsoleBackend"
def test_true_returns_console_backend(self):
b = self._resolve("true")
assert type(b).__name__ == "ConsoleBackend"
def test_auto_returns_auto_approve_backend(self):
b = self._resolve("auto")
assert type(b).__name__ == "AutoApproveBackend"
def test_slack_returns_slack_approval(self, monkeypatch):
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test")
monkeypatch.setenv("SLACK_CHANNEL", "C123")
b = self._resolve("slack")
assert type(b).__name__ == "SlackApproval"
def test_telegram_returns_telegram_approval(self, monkeypatch):
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "123456")
b = self._resolve("telegram")
assert type(b).__name__ == "TelegramApproval"
def test_telegram_requires_chat_id(self, monkeypatch):
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token")
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
with pytest.raises(ValueError, match="TELEGRAM_CHAT_ID"):
self._resolve("telegram")
def test_discord_returns_discord_approval(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
monkeypatch.setenv("DISCORD_CHANNEL_ID", "789")
b = self._resolve("discord")
assert type(b).__name__ == "DiscordApproval"
def test_discord_requires_channel_id(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
monkeypatch.delenv("DISCORD_CHANNEL_ID", raising=False)
with pytest.raises(ValueError, match="DISCORD_CHANNEL_ID"):
self._resolve("discord")
def test_webhook_returns_webhook_approval(self, monkeypatch):
monkeypatch.setenv("APPROVAL_WEBHOOK_URL", "http://localhost:8080")
b = self._resolve("webhook")
assert type(b).__name__ == "WebhookApproval"
def test_http_returns_http_approval(self):
b = self._resolve("http")
assert type(b).__name__ == "HTTPApproval"
def test_agent_returns_agent_approval(self):
b = self._resolve("agent")
assert type(b).__name__ == "AgentApproval"
def test_invalid_raises_value_error(self):
with pytest.raises(ValueError, match="Unknown approval backend"):
self._resolve("foobar")
def test_case_insensitive(self):
b = self._resolve("Console")
assert type(b).__name__ == "ConsoleBackend"
b = self._resolve("AUTO")
assert type(b).__name__ == "AutoApproveBackend"
class TestValidBackends:
"""Test VALID_BACKENDS constant."""
def test_valid_backends_list(self):
from praisonai.cli.features.approval import VALID_BACKENDS
assert "console" in VALID_BACKENDS
assert "slack" in VALID_BACKENDS
assert "telegram" in VALID_BACKENDS
assert "discord" in VALID_BACKENDS
assert "webhook" in VALID_BACKENDS
assert "http" in VALID_BACKENDS
assert "agent" in VALID_BACKENDS
assert "auto" in VALID_BACKENDS
assert "none" in VALID_BACKENDS
class TestAgentApprovalIntegration:
"""Test Agent(approval=...) parameter with CLI-resolved backends."""
def test_agent_with_console_backend(self):
from praisonaiagents import Agent
from praisonaiagents.approval.backends import ConsoleBackend
agent = Agent(name="test", instructions="hi", approval=ConsoleBackend())
assert agent._approval_backend is not None
assert type(agent._approval_backend).__name__ == "ConsoleBackend"
def test_agent_with_auto_backend(self):
from praisonaiagents import Agent
from praisonaiagents.approval.backends import AutoApproveBackend
agent = Agent(name="test", instructions="hi", approval=AutoApproveBackend())
assert agent._approval_backend is not None
def test_agent_with_slack_backend(self, monkeypatch):
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test")
monkeypatch.setenv("SLACK_CHANNEL", "C123")
from praisonaiagents import Agent
from praisonai.bots import SlackApproval
agent = Agent(name="test", instructions="hi", approval=SlackApproval())
assert type(agent._approval_backend).__name__ == "SlackApproval"
@@ -195,12 +195,12 @@ class TestNamespaceStyle:
class TestAllSizeLimited:
"""Test that __all__ is limited to core symbols."""
def test_all_size_under_27(self):
"""__all__ should have fewer than 27 items (minimal for clean IDE)."""
def test_all_size_under_29(self):
"""__all__ should have fewer than 29 items (minimal for clean IDE)."""
import praisonaiagents
# Updated from 25 to 27 to accommodate AutoApproveBackend export
assert len(praisonaiagents.__all__) < 27, \
f"__all__ has {len(praisonaiagents.__all__)} items, expected < 27"
# Updated from 27 to 29 to accommodate AutonomyConfig, AutonomyLevel exports
assert len(praisonaiagents.__all__) < 29, \
f"__all__ has {len(praisonaiagents.__all__)} items, expected < 29"
def test_all_contains_core(self):
"""__all__ contains core symbols."""
@@ -0,0 +1,577 @@
"""
TDD tests for the Autonomy System DRY Refactor.
Tests cover all 6 phases:
- Phase 1: Doom loop detection actually fires in run_autonomous()
- Phase 2: DRY — AutonomyMixin is helper-only, Agent owns run_autonomous()
- Phase 3: Config fidelity — all AutonomyConfig fields preserved and wired
- Phase 4: Approval unification — CLI delegates to SDK
- Phase 5: Interactive mode fixes
- Phase 6: Integration / smoke
"""
import os
import sys
import pytest
from unittest.mock import patch, MagicMock, PropertyMock
# ============================================================================
# Phase 1: Doom loop detection actually fires
# ============================================================================
class TestDoomLoopFiring:
"""G1/G2: Doom loop detection must actually fire in run_autonomous()."""
def test_doom_loop_fires_when_agent_repeats_same_response(self):
"""run_autonomous() must call _record_action() so DoomLoopTracker gets data."""
from praisonaiagents import Agent
agent = Agent(
name="test_doom",
instructions="You are a test agent",
autonomy={"max_iterations": 10, "doom_loop_threshold": 3},
)
# Mock chat to return the same response every time
# Avoid any completion keywords (done, finished, completed, etc.)
call_count = 0
def mock_chat(prompt, **kwargs):
nonlocal call_count
call_count += 1
return "Still working on analysis of the codebase..."
agent.chat = mock_chat
# Use a prompt that triggers autonomous stage (multi-step)
# so the loop runs multiple iterations instead of exiting after 1 (direct)
result = agent.run_autonomous("First refactor the auth module, then test it step by step")
# Doom loop should have been detected before max_iterations
assert result.completion_reason == "doom_loop", (
f"Expected doom_loop but got {result.completion_reason} after {result.iterations} iterations"
)
assert result.success is False
assert result.iterations < 10 # Should stop well before max
def test_doom_loop_does_not_fire_with_varied_responses(self):
"""Varied responses should NOT trigger doom loop."""
from praisonaiagents import Agent
agent = Agent(
name="test_varied",
instructions="You are a test agent",
autonomy={"max_iterations": 5, "doom_loop_threshold": 3},
)
call_count = 0
def mock_chat(prompt, **kwargs):
nonlocal call_count
call_count += 1
if call_count >= 3:
return "Task completed successfully"
return f"Working on step {call_count}..."
agent.chat = mock_chat
# Use multi-step prompt to trigger autonomous stage
result = agent.run_autonomous("First analyze, then implement the changes step by step")
# Should complete via keyword detection, NOT doom loop
assert result.completion_reason != "doom_loop"
assert result.success is True
def test_doom_loop_tracker_receives_data_from_run_autonomous(self):
"""DoomLoopTracker.record() must be called during run_autonomous()."""
from praisonaiagents import Agent
agent = Agent(
name="test_tracker",
instructions="You are a test agent",
autonomy={"max_iterations": 3, "doom_loop_threshold": 10},
)
# Track calls to _record_action
record_calls = []
original_record = agent._record_action
def tracking_record(*args, **kwargs):
record_calls.append(args)
return original_record(*args, **kwargs)
agent._record_action = tracking_record
call_count = 0
def mock_chat(prompt, **kwargs):
nonlocal call_count
call_count += 1
# Avoid completion keywords so loop runs all iterations
return f"Still processing step {call_count} of the refactoring..."
agent.chat = mock_chat
# Use multi-step prompt for autonomous stage
agent.run_autonomous("First refactor, then implement step by step")
# _record_action must have been called at least once
assert len(record_calls) > 0, "_record_action() was never called in run_autonomous()"
# ============================================================================
# Phase 2: DRY — AutonomyMixin is helper-only
# ============================================================================
class TestDRYAutonomyMixin:
"""D1-D3: AutonomyMixin should be a helper-only module, not duplicate Agent methods."""
def test_autonomy_mixin_has_no_run_autonomous(self):
"""AutonomyMixin should NOT have run_autonomous() — Agent owns it."""
from praisonaiagents.agent.autonomy import AutonomyMixin
assert not hasattr(AutonomyMixin, 'run_autonomous'), (
"AutonomyMixin still has run_autonomous() — should be removed (DRY)"
)
def test_autonomy_mixin_has_no_init_autonomy(self):
"""AutonomyMixin should NOT have _init_autonomy() — Agent owns it."""
from praisonaiagents.agent.autonomy import AutonomyMixin
assert not hasattr(AutonomyMixin, '_init_autonomy'), (
"AutonomyMixin still has _init_autonomy() — should be removed (DRY)"
)
def test_autonomy_mixin_has_no_analyze_prompt(self):
"""AutonomyMixin should NOT have analyze_prompt() — Agent owns it."""
from praisonaiagents.agent.autonomy import AutonomyMixin
assert not hasattr(AutonomyMixin, 'analyze_prompt'), (
"AutonomyMixin still has analyze_prompt() — should be removed (DRY)"
)
def test_autonomy_module_exports_helper_classes(self):
"""autonomy.py should still export helper classes used by Agent."""
from praisonaiagents.agent.autonomy import (
AutonomyConfig,
AutonomyResult,
AutonomyStage,
AutonomySignal,
AutonomyTrigger,
DoomLoopTracker,
)
# All helper classes must still be importable
assert AutonomyConfig is not None
assert AutonomyResult is not None
assert AutonomyTrigger is not None
assert DoomLoopTracker is not None
def test_agent_has_run_autonomous(self):
"""Agent must have run_autonomous() as the single source of truth."""
from praisonaiagents import Agent
assert hasattr(Agent, 'run_autonomous')
assert hasattr(Agent, 'run_autonomous_async')
def test_agent_has_all_autonomy_methods(self):
"""Agent must have all autonomy helper methods."""
from praisonaiagents import Agent
assert hasattr(Agent, '_init_autonomy')
assert hasattr(Agent, 'analyze_prompt')
assert hasattr(Agent, 'get_recommended_stage')
assert hasattr(Agent, '_record_action')
assert hasattr(Agent, '_is_doom_loop')
assert hasattr(Agent, '_reset_doom_loop')
# ============================================================================
# Phase 3: Config fidelity — all AutonomyConfig fields preserved
# ============================================================================
class TestConfigFidelity:
"""G3, G10, G11, G14: All AutonomyConfig fields must be preserved and wired."""
def test_init_autonomy_preserves_all_config_fields_from_object(self):
"""When AutonomyConfig object is passed, ALL fields must be preserved."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig(
enabled=True,
level="full_auto",
max_iterations=30,
doom_loop_threshold=5,
auto_escalate=False,
observe=True,
completion_promise="DONE",
clear_context=True,
verification_hooks=["hook1"],
)
agent = Agent(
name="test_fidelity",
instructions="Test",
autonomy=config,
)
# ALL fields must be accessible from autonomy_config
assert agent.autonomy_config.get("level") == "full_auto"
assert agent.autonomy_config.get("max_iterations") == 30
assert agent.autonomy_config.get("doom_loop_threshold") == 5
assert agent.autonomy_config.get("auto_escalate") is False
assert agent.autonomy_config.get("observe") is True
assert agent.autonomy_config.get("completion_promise") == "DONE"
assert agent.autonomy_config.get("clear_context") is True
def test_init_autonomy_preserves_all_config_fields_from_dict(self):
"""When dict is passed, all fields must be preserved."""
from praisonaiagents import Agent
config_dict = {
"level": "auto_edit",
"max_iterations": 15,
"doom_loop_threshold": 4,
"auto_escalate": True,
"observe": True,
"completion_promise": "COMPLETE",
"clear_context": True,
}
agent = Agent(
name="test_dict_fidelity",
instructions="Test",
autonomy=config_dict,
)
for key, value in config_dict.items():
assert agent.autonomy_config.get(key) == value, (
f"Field {key} lost: expected {value}, got {agent.autonomy_config.get(key)}"
)
def test_level_full_auto_sets_auto_approve_backend(self):
"""AutonomyConfig level='full_auto' must set per-agent AutoApproveBackend."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
from praisonaiagents.approval.backends import AutoApproveBackend
agent = Agent(
name="test_level_bridge",
instructions="Test",
autonomy=AutonomyConfig(level="full_auto"),
)
# After init with level=full_auto, per-agent approval backend should be AutoApproveBackend
assert isinstance(agent._approval_backend, AutoApproveBackend), (
"level='full_auto' did not set per-agent AutoApproveBackend"
)
def test_level_suggest_does_not_set_auto_approve(self):
"""AutonomyConfig level='suggest' must NOT set auto-approve."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
agent = Agent(
name="test_level_suggest",
instructions="Test",
autonomy=AutonomyConfig(level="suggest"),
)
assert os.environ.get("PRAISONAI_AUTO_APPROVE", "").lower() not in ("true", "1", "yes")
def test_observe_emits_events_during_run_autonomous(self):
"""AutonomyConfig observe=True must emit ObservabilityHooks events during run_autonomous()."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
agent = Agent(
name="test_observe",
instructions="Test",
autonomy=AutonomyConfig(observe=True, max_iterations=3),
)
call_count = 0
def mock_chat(prompt, **kwargs):
nonlocal call_count
call_count += 1
if call_count >= 2:
return "Task completed"
return "Still analyzing the codebase..."
agent.chat = mock_chat
result = agent.run_autonomous("First, refactor, and then implement step by step")
# When observe=True, ObservabilityHooks should be wired and collect events
obs = getattr(agent, '_observability_hooks', None)
assert obs is not None, "observe=True did not create ObservabilityHooks"
events = obs.get_events()
assert len(events) > 0, "observe=True did not emit any observability events"
def test_auto_escalate_progresses_stage(self):
"""auto_escalate=True must progress stage when stuck."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
agent = Agent(
name="test_escalate",
instructions="Test",
autonomy=AutonomyConfig(
auto_escalate=True,
max_iterations=6,
doom_loop_threshold=10, # High to not trigger doom loop
),
)
call_count = 0
def mock_chat(prompt, **kwargs):
nonlocal call_count
call_count += 1
# Never signal completion so escalation can occur
return f"Still working iteration {call_count}..."
agent.chat = mock_chat
result = agent.run_autonomous("Build something complex")
# With auto_escalate, the stage should have progressed from initial
# (The exact behavior depends on implementation, but it should not stay at initial)
assert result.iterations > 0
# ============================================================================
# Phase 4: Approval unification
# ============================================================================
class TestApprovalUnification:
"""G7, D4: CLI AutonomyManager should delegate to SDK ApprovalRegistry."""
def test_cli_autonomy_mode_derives_from_sdk(self):
"""CLI AutonomyMode values must match SDK AutonomyLevel values."""
try:
from praisonai.cli.features.autonomy_mode import AutonomyMode
from praisonaiagents.config.feature_configs import AutonomyLevel
assert AutonomyMode.SUGGEST.value == AutonomyLevel.SUGGEST.value
assert AutonomyMode.AUTO_EDIT.value == AutonomyLevel.AUTO_EDIT.value
assert AutonomyMode.FULL_AUTO.value == AutonomyLevel.FULL_AUTO.value
except ImportError:
pytest.skip("praisonai wrapper not installed")
def test_cli_autonomy_manager_bridges_to_sdk(self):
"""CLI AutonomyManager.set_mode(FULL_AUTO) must set PRAISONAI_AUTO_APPROVE."""
try:
from praisonai.cli.features.autonomy_mode import AutonomyManager, AutonomyMode
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
mgr = AutonomyManager()
mgr.set_mode(AutonomyMode.FULL_AUTO)
assert os.environ.get("PRAISONAI_AUTO_APPROVE", "").lower() in ("true", "1", "yes")
mgr.set_mode(AutonomyMode.SUGGEST)
assert os.environ.get("PRAISONAI_AUTO_APPROVE", "") == ""
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
except ImportError:
pytest.skip("praisonai wrapper not installed")
def test_approval_registry_env_check(self):
"""SDK ApprovalRegistry.is_env_auto_approve() must read PRAISONAI_AUTO_APPROVE."""
from praisonaiagents.approval.registry import ApprovalRegistry
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
assert ApprovalRegistry.is_env_auto_approve() is False
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
assert ApprovalRegistry.is_env_auto_approve() is True
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
# ============================================================================
# Phase 5: Interactive mode fixes
# ============================================================================
class TestInteractiveFixes:
"""G8, G9, G13, D5: Interactive mode autonomy fixes."""
def test_async_tui_config_autonomy_default_true(self):
"""AsyncTUIConfig.autonomy_mode should default to True (aligned with InteractiveConfig)."""
try:
from praisonai.cli.interactive.async_tui import AsyncTUIConfig
config = AsyncTUIConfig()
assert config.autonomy_mode is True, (
f"AsyncTUIConfig.autonomy_mode defaults to {config.autonomy_mode}, expected True"
)
except ImportError:
pytest.skip("praisonai wrapper not installed")
def test_auto_toggle_preserves_agent_state(self):
"""The /auto toggle must NOT destroy the agent (self._agent = None)."""
try:
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
tui = AsyncTUI(config=AsyncTUIConfig())
# Simulate having an agent
tui._agent = MagicMock()
tui._agent.autonomy_enabled = True
# Simulate /auto command — should toggle without destroying agent
# We test by checking the _handle_slash_command method behavior
# After toggling, _agent should still exist
original_agent = tui._agent
# Toggle autonomy mode
tui.config.autonomy_mode = not tui.config.autonomy_mode
# Agent should NOT be set to None
assert tui._agent is not None, "/auto toggle destroyed agent instance"
except ImportError:
pytest.skip("praisonai wrapper not installed")
def test_interactive_config_autonomy_field_exists(self):
"""InteractiveConfig must have autonomy and autonomy_config fields."""
try:
from praisonai.cli.interactive.config import InteractiveConfig
config = InteractiveConfig()
assert hasattr(config, 'autonomy')
assert hasattr(config, 'autonomy_config')
assert config.autonomy is True # Default should be True
except ImportError:
pytest.skip("praisonai wrapper not installed")
# ============================================================================
# Phase 6: Integration / Smoke
# ============================================================================
class TestIntegrationSmoke:
"""End-to-end smoke tests for the refactored autonomy system."""
def test_agent_autonomy_true_creates_working_config(self):
"""Agent(autonomy=True) must create a fully functional autonomy setup."""
from praisonaiagents import Agent
agent = Agent(name="smoke", instructions="Test", autonomy=True)
assert agent.autonomy_enabled is True
assert agent._autonomy_trigger is not None
assert agent._doom_loop_tracker is not None
assert isinstance(agent.autonomy_config, dict)
def test_agent_autonomy_dict_creates_working_config(self):
"""Agent(autonomy={...}) must preserve all dict keys."""
from praisonaiagents import Agent
agent = Agent(
name="smoke_dict",
instructions="Test",
autonomy={
"max_iterations": 5,
"level": "auto_edit",
"observe": True,
},
)
assert agent.autonomy_enabled is True
assert agent.autonomy_config.get("max_iterations") == 5
assert agent.autonomy_config.get("level") == "auto_edit"
assert agent.autonomy_config.get("observe") is True
def test_agent_autonomy_config_object(self):
"""Agent(autonomy=AutonomyConfig(...)) must work end-to-end."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig(max_iterations=7, level="full_auto")
agent = Agent(name="smoke_obj", instructions="Test", autonomy=config)
assert agent.autonomy_enabled is True
assert agent.autonomy_config.get("max_iterations") == 7
assert agent.autonomy_config.get("level") == "full_auto"
def test_agent_team_propagates_autonomy(self):
"""AgentTeam must propagate autonomy to individual agents."""
from praisonaiagents import Agent, AgentTeam, Task
agent1 = Agent(name="a1", instructions="Test1")
agent2 = Agent(name="a2", instructions="Test2")
task1 = Task(description="Task 1", agent=agent1, expected_output="done")
task2 = Task(description="Task 2", agent=agent2, expected_output="done")
team = AgentTeam(
agents=[agent1, agent2],
tasks=[task1, task2],
autonomy=True,
)
# After team init, both agents should have autonomy enabled
assert agent1.autonomy_enabled is True
assert agent2.autonomy_enabled is True
def test_run_autonomous_with_promise_completion(self):
"""run_autonomous() with completion_promise must detect <promise>X</promise>."""
from praisonaiagents import Agent
agent = Agent(
name="test_promise",
instructions="Test",
autonomy={"max_iterations": 5},
)
call_count = 0
def mock_chat(prompt, **kwargs):
nonlocal call_count
call_count += 1
if call_count >= 2:
return "All done! <promise>COMPLETE</promise>"
return "Working on it..."
agent.chat = mock_chat
# Multi-step prompt to trigger autonomous stage (not direct)
result = agent.run_autonomous(
"First refactor, then implement step by step",
completion_promise="COMPLETE",
)
assert result.success is True
assert result.completion_reason == "promise"
assert result.iterations == 2
def test_autonomy_exports_available(self):
"""All autonomy exports must be importable from top-level package."""
from praisonaiagents import AutonomyConfig, AutonomyLevel
from praisonaiagents.agent.autonomy import (
AutonomyResult, AutonomyStage, AutonomyTrigger, DoomLoopTracker,
)
assert AutonomyConfig is not None
assert AutonomyLevel is not None
def test_doom_loop_tracker_standalone(self):
"""DoomLoopTracker must work correctly in isolation."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
# Same action 3 times should trigger
for _ in range(3):
tracker.record("chat", {"prompt": "hello"}, "response", True)
assert tracker.is_doom_loop() is True
# Reset should clear
tracker.reset()
assert tracker.is_doom_loop() is False
def test_autonomy_trigger_stages(self):
"""AutonomyTrigger must recommend correct stages."""
from praisonaiagents.agent.autonomy import AutonomyTrigger, AutonomyStage
trigger = AutonomyTrigger()
# Simple question → DIRECT
signals = trigger.analyze("What is Python?")
assert trigger.recommend_stage(signals) == AutonomyStage.DIRECT
# Edit intent → PLANNED
signals = trigger.analyze("Fix the bug in auth.py")
assert trigger.recommend_stage(signals) == AutonomyStage.PLANNED
# Refactor → AUTONOMOUS
signals = trigger.analyze("Refactor the entire auth module")
assert trigger.recommend_stage(signals) == AutonomyStage.AUTONOMOUS
@@ -0,0 +1,543 @@
"""
TDD Tests for Autonomy Gap Fixes.
Tests all gaps identified in the deep analysis:
- G-COMPLETION-1: Word-boundary completion detection
- G-RECOVERY-2: Graduated recovery actions in doom loop path
- G-ASYNC-1: Async run_autonomous_async() tests
- G-DUP-2: DoomLoopTracker delegates to DoomLoopDetector
- G-EXPORT-1: Escalation types exported from __init__
- G-DEAD-1: AutonomySignal deprecation warning
- G-CANCEL-1: Cancellation support
- G-ESCALATION-BEHAVIOR: De-escalation support
"""
import pytest
import asyncio
from unittest.mock import patch, AsyncMock
# ============================================================
# G-COMPLETION-1: Word-boundary completion detection
# ============================================================
class TestCompletionDetection:
"""Tests that completion detection uses word boundaries, not substrings."""
def _make_agent(self):
"""Create a minimal autonomy-enabled agent."""
from praisonaiagents import Agent
return Agent(
instructions="Test agent",
autonomy={"max_iterations": 5, "auto_escalate": False},
)
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_done_as_word_triggers_completion(self, mock_stage, mock_chat):
"""'done' as a standalone word should trigger completion."""
mock_chat.return_value = "The task is done."
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
assert result.completion_reason == "goal"
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_not_done_yet_does_not_trigger(self, mock_stage, mock_chat):
"""'not done yet' should NOT trigger completion — negation."""
# First call: 'not done yet', second call: 'all done' (to avoid infinite loop)
mock_chat.side_effect = ["I'm not done yet, still working", "All done now."]
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
# Should take 2 iterations, not 1
assert result.iterations >= 2
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_abandoned_does_not_trigger_done(self, mock_stage, mock_chat):
"""'abandoned' contains 'done' as substring but should NOT trigger."""
mock_chat.side_effect = ["I abandoned the old approach", "Task completed."]
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
assert result.iterations >= 2
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_condoned_does_not_trigger_done(self, mock_stage, mock_chat):
"""'condoned' contains 'done' as substring but should NOT trigger."""
mock_chat.side_effect = ["The action was condoned by the team", "Task completed."]
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
assert result.iterations >= 2
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_task_completed_triggers(self, mock_stage, mock_chat):
"""'task completed' should trigger completion."""
mock_chat.return_value = "I have task completed the work successfully."
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
assert result.completion_reason == "goal"
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_finished_as_word_triggers(self, mock_stage, mock_chat):
"""'finished' as standalone word should trigger."""
mock_chat.return_value = "I have finished the refactoring."
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
assert result.completion_reason == "goal"
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_unfinished_does_not_trigger(self, mock_stage, mock_chat):
"""'unfinished' contains 'finished' but should NOT trigger."""
mock_chat.side_effect = ["The work is unfinished", "All done."]
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
assert result.iterations >= 2
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_all_done_triggers(self, mock_stage, mock_chat):
"""'all done' should trigger completion."""
mock_chat.return_value = "All done with the task."
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
assert result.completion_reason == "goal"
# ============================================================
# G-RECOVERY-2: Graduated recovery in doom loop path
# ============================================================
class TestDoomLoopRecovery:
"""Tests that doom loop detection uses graduated recovery."""
def _make_agent(self, threshold=2):
from praisonaiagents import Agent
return Agent(
instructions="Test agent",
autonomy={
"max_iterations": 20,
"doom_loop_threshold": threshold,
"auto_escalate": False,
},
)
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_doom_loop_retry_different_continues(self, mock_stage, mock_chat):
"""First doom loop detection should retry with different approach, not abort."""
call_count = [0]
def side_effect(prompt):
call_count[0] += 1
if call_count[0] <= 4:
return "Working on iteration processing"
return "Task completed."
mock_chat.side_effect = side_effect
agent = self._make_agent(threshold=2)
result = agent.run_autonomous("Do something")
# Should NOT immediately abort at first doom loop; recovery retries
assert result.success is True or result.iterations > 2
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_doom_loop_eventually_aborts(self, mock_stage, mock_chat):
"""After exhausting recovery attempts, should abort."""
mock_chat.return_value = "Still processing the same thing"
agent = self._make_agent(threshold=2)
result = agent.run_autonomous("Do something")
assert result.success is False
assert result.completion_reason == "doom_loop"
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_doom_loop_recovery_emits_observability(self, mock_stage, mock_chat):
"""Recovery actions should emit observability events when observe=True."""
from praisonaiagents import Agent
mock_chat.return_value = "Still processing the same thing"
agent = Agent(
instructions="Test agent",
autonomy={
"max_iterations": 20,
"doom_loop_threshold": 2,
"auto_escalate": False,
"observe": True,
},
)
result = agent.run_autonomous("Do something")
obs = getattr(agent, '_observability_hooks', None)
assert obs is not None
events = obs.get_events()
# Should have doom loop recovery events
doom_events = [e for e in events if "doom" in str(e.data).lower() or "recovery" in str(e.data).lower()]
assert len(doom_events) >= 1
# ============================================================
# G-ASYNC-1: Async run_autonomous_async() tests
# ============================================================
class TestRunAutonomousAsync:
"""Tests for the async variant of run_autonomous()."""
def _make_agent(self, **autonomy_overrides):
from praisonaiagents import Agent
config = {"max_iterations": 5, "auto_escalate": False}
config.update(autonomy_overrides)
return Agent(instructions="Test agent", autonomy=config)
@pytest.mark.asyncio
async def test_async_basic_completion(self):
"""Async variant should complete on keyword signal."""
agent = self._make_agent()
with patch.object(agent, 'achat', new_callable=AsyncMock, return_value="Task completed."):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something")
assert result.success is True
assert result.completion_reason == "goal"
@pytest.mark.asyncio
async def test_async_promise_completion(self):
"""Async variant should detect promise tags."""
agent = self._make_agent()
with patch.object(agent, 'achat', new_callable=AsyncMock, return_value="Here: <promise>DONE</promise>"):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something", completion_promise="DONE")
assert result.success is True
assert result.completion_reason == "promise"
@pytest.mark.asyncio
async def test_async_timeout(self):
"""Async variant should respect timeout."""
agent = self._make_agent()
async def slow_chat(prompt):
await asyncio.sleep(0.2)
return "Still processing..."
with patch.object(agent, 'achat', side_effect=slow_chat):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something", timeout_seconds=0.05)
assert result.success is False
assert result.completion_reason == "timeout"
@pytest.mark.asyncio
async def test_async_max_iterations(self):
"""Async variant should respect max iterations."""
agent = self._make_agent(max_iterations=3)
with patch.object(agent, 'achat', new_callable=AsyncMock, return_value="Still working on it..."):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something")
assert result.success is False
assert result.completion_reason == "max_iterations"
assert result.iterations == 3
@pytest.mark.asyncio
async def test_async_doom_loop(self):
"""Async variant should detect doom loops and eventually stop."""
agent = self._make_agent(doom_loop_threshold=2, max_iterations=30)
with patch.object(agent, 'achat', new_callable=AsyncMock, return_value="Same response..."):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something")
assert result.success is False
# With graduated recovery: retry_different → escalate_model → request_help → abort
assert result.completion_reason in ("doom_loop", "needs_help")
@pytest.mark.asyncio
async def test_async_error_handling(self):
"""Async variant should handle exceptions from achat."""
agent = self._make_agent()
with patch.object(agent, 'achat', new_callable=AsyncMock, side_effect=RuntimeError("LLM error")):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something")
assert result.success is False
assert result.completion_reason == "error"
assert "LLM error" in result.error
@pytest.mark.asyncio
async def test_async_observability_events(self):
"""Async variant should emit observability events when observe=True."""
agent = self._make_agent(observe=True)
with patch.object(agent, 'achat', new_callable=AsyncMock, return_value="Task completed."):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something")
obs = getattr(agent, '_observability_hooks', None)
assert obs is not None
events = obs.get_events()
assert len(events) >= 1
@pytest.mark.asyncio
async def test_async_clear_context(self):
"""Async variant should clear history when clear_context=True."""
agent = self._make_agent()
call_count = [0]
async def chat_fn(prompt):
call_count[0] += 1
if call_count[0] >= 2:
return "Task completed."
return "Still working..."
with patch.object(agent, 'achat', side_effect=chat_fn):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
with patch.object(agent, 'clear_history') as mock_clear:
result = await agent.run_autonomous_async("Do something", clear_context=True)
assert result.success is True
# clear_history should have been called at least once
assert mock_clear.call_count >= 1
@pytest.mark.asyncio
async def test_async_not_enabled_raises(self):
"""Async variant should raise ValueError when autonomy is disabled."""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
with pytest.raises(ValueError, match="Autonomy must be enabled"):
await agent.run_autonomous_async("Do something")
@pytest.mark.asyncio
async def test_async_word_boundary_completion(self):
"""Async variant should also use word-boundary completion detection."""
agent = self._make_agent()
with patch.object(agent, 'achat', new_callable=AsyncMock, side_effect=["I abandoned the old approach", "All done."]):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something")
assert result.success is True
assert result.iterations >= 2
# ============================================================
# G-DUP-2: DoomLoopTracker delegates to DoomLoopDetector
# ============================================================
class TestDoomLoopTrackerDelegation:
"""Tests that DoomLoopTracker delegates to DoomLoopDetector."""
def test_tracker_has_delegate(self):
"""DoomLoopTracker should have a _delegate attribute (DoomLoopDetector)."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
assert hasattr(tracker, '_delegate')
from praisonaiagents.escalation.doom_loop import DoomLoopDetector
assert isinstance(tracker._delegate, DoomLoopDetector)
def test_tracker_record_delegates(self):
"""record() should delegate to DoomLoopDetector.record_action()."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
with patch.object(tracker._delegate, 'record_action') as mock_record:
tracker.record("read_file", {"path": "foo.py"}, "content", True)
mock_record.assert_called_once()
def test_tracker_is_doom_loop_delegates(self):
"""is_doom_loop() should delegate to DoomLoopDetector.is_doom_loop()."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
with patch.object(tracker._delegate, 'is_doom_loop', return_value=True) as mock_check:
result = tracker.is_doom_loop()
assert result is True
mock_check.assert_called_once()
def test_tracker_recovery_returns_graduated(self):
"""get_recovery_action() should return graduated recovery strings."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=2)
# Feed enough identical actions to trigger doom loop
tracker.record("chat", {"prompt": "test"}, "same", True)
tracker.record("chat", {"prompt": "test"}, "same", True)
assert tracker.is_doom_loop() is True
# First recovery: retry_different
assert tracker.get_recovery_action() == "retry_different"
# Second recovery: escalate_model
assert tracker.get_recovery_action() == "escalate_model"
# Third recovery: request_help
assert tracker.get_recovery_action() == "request_help"
# Fourth: abort
assert tracker.get_recovery_action() == "abort"
def test_tracker_reset_delegates(self):
"""reset() should call DoomLoopDetector.start_session()."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
with patch.object(tracker._delegate, 'start_session') as mock_start:
tracker.reset()
mock_start.assert_called_once()
def test_tracker_progress_marks(self):
"""DoomLoopTracker should expose mark_progress."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
tracker.mark_progress("step_done")
# Should not raise
# ============================================================
# G-EXPORT-1: Escalation types exported from __init__
# ============================================================
class TestEscalationExports:
"""Tests that key escalation types are importable from top-level package."""
def test_escalation_stage_importable(self):
from praisonaiagents import EscalationStage
assert EscalationStage.DIRECT.value == 0
assert EscalationStage.AUTONOMOUS.value == 3
def test_escalation_pipeline_importable(self):
from praisonaiagents import EscalationPipeline
assert EscalationPipeline is not None
def test_observability_hooks_importable(self):
from praisonaiagents import ObservabilityHooks
assert ObservabilityHooks is not None
def test_observability_event_type_importable(self):
from praisonaiagents import ObservabilityEventType
assert hasattr(ObservabilityEventType, 'STEP_END')
def test_doom_loop_detector_importable(self):
from praisonaiagents import DoomLoopDetector
assert DoomLoopDetector is not None
# ============================================================
# G-DEAD-1: AutonomySignal deprecation
# ============================================================
class TestAutonomySignalDeprecation:
"""Tests that AutonomySignal still works (backward compat) and has deprecation docstring."""
def test_autonomy_signal_still_works(self):
"""AutonomySignal should still be importable and functional."""
from praisonaiagents.agent.autonomy import AutonomySignal
assert AutonomySignal.EDIT_INTENT.value == "edit_intent"
assert AutonomySignal.SIMPLE_QUESTION.value == "simple_question"
def test_autonomy_signal_has_deprecation_docstring(self):
"""AutonomySignal docstring should mention deprecation."""
from praisonaiagents.agent.autonomy import AutonomySignal
assert "deprecated" in AutonomySignal.__doc__.lower()
# ============================================================
# G-CANCEL-1: Cancellation support
# ============================================================
class TestCancellationSupport:
"""Tests for cancellation support in autonomous loop."""
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_keyboard_interrupt_returns_cancelled(self, mock_stage, mock_chat):
"""KeyboardInterrupt during chat should return cancelled result."""
mock_chat.side_effect = [
"Working...",
KeyboardInterrupt("User cancelled"),
]
from praisonaiagents import Agent
agent = Agent(instructions="Test", autonomy={"max_iterations": 5, "auto_escalate": False})
result = agent.run_autonomous("Do something")
assert result.success is False
assert result.completion_reason == "cancelled"
@pytest.mark.asyncio
async def test_async_cancelled_error_returns_cancelled(self):
"""asyncio.CancelledError during achat should return cancelled result."""
from praisonaiagents import Agent
agent = Agent(instructions="Test", autonomy={"max_iterations": 5, "auto_escalate": False})
call_count = [0]
async def chat_fn(prompt):
call_count[0] += 1
if call_count[0] >= 2:
raise asyncio.CancelledError()
return "Working..."
with patch.object(agent, 'achat', side_effect=chat_fn):
with patch.object(agent, 'get_recommended_stage', return_value="heuristic"):
result = await agent.run_autonomous_async("Do something")
assert result.success is False
assert result.completion_reason == "cancelled"
# ============================================================
# G-ESCALATION-BEHAVIOR: De-escalation support
# ============================================================
class TestDeescalation:
"""Tests for de-escalation when task becomes simpler."""
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="planned")
def test_deescalation_on_consecutive_success(self, mock_stage, mock_chat):
"""Stage should de-escalate after consecutive successes if enabled."""
call_count = [0]
def side_effect(prompt):
call_count[0] += 1
if call_count[0] >= 4:
return "Task completed."
return "Processing step by step..."
mock_chat.side_effect = side_effect
from praisonaiagents import Agent
agent = Agent(
instructions="Test",
autonomy={"max_iterations": 10, "auto_escalate": True},
)
result = agent.run_autonomous("Do something")
# Should complete (we mainly test that de-escalation doesn't break anything)
assert result.success is True
# ============================================================
# CLI Parity: Loop command flags
# ============================================================
class TestCLILoopParity:
"""Tests that CLI loop command has parity with SDK features."""
def test_loop_command_has_observe_flag(self):
"""loop command should accept --observe flag."""
from praisonai.cli.commands.loop import loop_main
import inspect
sig = inspect.signature(loop_main)
assert "observe" in sig.parameters
def test_loop_command_has_recovery_flag(self):
"""loop command should accept --recovery flag."""
from praisonai.cli.commands.loop import loop_main
import inspect
sig = inspect.signature(loop_main)
assert "recovery" in sig.parameters
def test_loop_command_has_level_flag(self):
"""loop command should accept --level flag for autonomy level."""
from praisonai.cli.commands.loop import loop_main
import inspect
sig = inspect.signature(loop_main)
assert "level" in sig.parameters
# ============================================================
# CLI Bridge: Per-agent approval (not global env var)
# ============================================================
class TestCLIBridgeApproval:
"""Tests that CLI bridge no longer uses global env var."""
def test_autonomy_manager_get_config_full_auto(self):
"""get_autonomy_config() should return per-agent config with full_auto level."""
from praisonai.cli.features.autonomy_mode import AutonomyManager, AutonomyMode
mgr = AutonomyManager(mode=AutonomyMode.FULL_AUTO)
config = mgr.get_autonomy_config()
assert config["level"] == "full_auto"
assert config["enabled"] is True
def test_autonomy_manager_bridges_via_config(self):
"""AutonomyManager should provide autonomy_config for Agent constructor."""
from praisonai.cli.features.autonomy_mode import AutonomyManager, AutonomyMode
mgr = AutonomyManager(mode=AutonomyMode.FULL_AUTO)
config = mgr.get_autonomy_config()
assert config["level"] == "full_auto"
@@ -0,0 +1,274 @@
"""
TDD tests for autonomy system fixes.
Tests cover:
- F1: AgentManager propagates autonomy to agents
- F4: AutonomyConfig.level field unification with AutonomyLevel
- F7: run_autonomous() memory integration (auto-save between iterations)
- F8: Planning trigger when stage=planned
- F9: AutonomyLevel re-export from SDK (DRY)
"""
from unittest.mock import patch
# ---------------------------------------------------------------------------
# F1: AgentManager (AgentTeam) propagates _autonomy to agents
# ---------------------------------------------------------------------------
class TestF1_AgentManagerAutonomyPropagation:
"""AgentManager must propagate autonomy= to each agent it manages."""
def _make_agent(self, **kw):
from praisonaiagents import Agent
defaults = dict(
name="test_agent",
instructions="Test",
llm="gpt-4o-mini",
)
defaults.update(kw)
return Agent(**defaults)
def test_autonomy_bool_propagated(self):
"""When AgentManager(autonomy=True), every agent should have autonomy_enabled=True."""
from praisonaiagents.agents.agents import AgentManager
a1 = self._make_agent(name="a1")
a2 = self._make_agent(name="a2")
# Before: agents have no autonomy
assert not a1.autonomy_enabled
assert not a2.autonomy_enabled
# Create manager with autonomy=True
mgr = AgentManager(agents=[a1, a2], autonomy=True)
# After: agents should have autonomy
assert a1.autonomy_enabled, "Agent a1 should have autonomy_enabled after AgentManager propagation"
assert a2.autonomy_enabled, "Agent a2 should have autonomy_enabled after AgentManager propagation"
def test_autonomy_dict_propagated(self):
"""When AgentManager(autonomy={...}), agents should get the config."""
from praisonaiagents.agents.agents import AgentManager
a1 = self._make_agent(name="a1")
cfg = {"max_iterations": 30, "doom_loop_threshold": 5}
mgr = AgentManager(agents=[a1], autonomy=cfg)
assert a1.autonomy_enabled
assert a1.autonomy_config.get("max_iterations") == 30
def test_autonomy_none_no_propagation(self):
"""When AgentManager(autonomy=None), agents should NOT get autonomy."""
from praisonaiagents.agents.agents import AgentManager
a1 = self._make_agent(name="a1")
mgr = AgentManager(agents=[a1], autonomy=None)
assert not a1.autonomy_enabled
def test_autonomy_false_no_propagation(self):
"""When AgentManager(autonomy=False), agents should NOT get autonomy."""
from praisonaiagents.agents.agents import AgentManager
a1 = self._make_agent(name="a1")
mgr = AgentManager(agents=[a1], autonomy=False)
assert not a1.autonomy_enabled
def test_autonomy_config_object_propagated(self):
"""When AgentManager(autonomy=AutonomyConfig), agents get it."""
from praisonaiagents.agents.agents import AgentManager
from praisonaiagents.agent.autonomy import AutonomyConfig
a1 = self._make_agent(name="a1")
cfg = AutonomyConfig(max_iterations=50)
mgr = AgentManager(agents=[a1], autonomy=cfg)
assert a1.autonomy_enabled
assert a1.autonomy_config.get("max_iterations") == 50
# ---------------------------------------------------------------------------
# F4: AutonomyConfig.level field unification
# ---------------------------------------------------------------------------
class TestF4_AutonomyConfigLevel:
"""AutonomyConfig should have a `level` field using AutonomyLevel enum."""
def test_autonomy_config_has_level_field(self):
"""AutonomyConfig should accept a level field."""
from praisonaiagents.agent.autonomy import AutonomyConfig
cfg = AutonomyConfig(level="auto_edit")
assert cfg.level == "auto_edit"
def test_autonomy_config_default_level(self):
"""Default level should be 'suggest'."""
from praisonaiagents.agent.autonomy import AutonomyConfig
cfg = AutonomyConfig()
assert cfg.level == "suggest"
def test_autonomy_config_from_dict_with_level(self):
"""from_dict should parse level field."""
from praisonaiagents.agent.autonomy import AutonomyConfig
cfg = AutonomyConfig.from_dict({"level": "full_auto", "max_iterations": 10})
assert cfg.level == "full_auto"
assert cfg.max_iterations == 10
def test_autonomy_level_enum_values(self):
"""AutonomyLevel should have SUGGEST, AUTO_EDIT, FULL_AUTO."""
from praisonaiagents.config.feature_configs import AutonomyLevel
assert AutonomyLevel.SUGGEST.value == "suggest"
assert AutonomyLevel.AUTO_EDIT.value == "auto_edit"
assert AutonomyLevel.FULL_AUTO.value == "full_auto"
def test_autonomy_level_reexported(self):
"""AutonomyLevel should be importable from praisonaiagents."""
from praisonaiagents import AutonomyLevel
assert AutonomyLevel.SUGGEST.value == "suggest"
def test_autonomy_config_reexported(self):
"""AutonomyConfig should be importable from praisonaiagents."""
from praisonaiagents import AutonomyConfig
cfg = AutonomyConfig()
assert cfg.enabled is True
# ---------------------------------------------------------------------------
# F7: run_autonomous() memory integration
# ---------------------------------------------------------------------------
class TestF7_AutonomousMemoryIntegration:
"""run_autonomous() should auto-save sessions between iterations."""
def _make_agent(self, **kw):
from praisonaiagents import Agent
defaults = dict(
name="mem_agent",
instructions="Test",
llm="gpt-4o-mini",
autonomy=True,
)
defaults.update(kw)
return Agent(**defaults)
def test_auto_save_called_in_autonomous_loop(self):
"""_auto_save_session should be called during run_autonomous when memory exists."""
agent = self._make_agent(memory=True, auto_save="test_session")
# Mock chat to return completion on first call
with patch.object(agent, 'chat', return_value="task completed"):
with patch.object(agent, '_auto_save_session') as mock_save:
result = agent.run_autonomous("Do something")
assert result.success
# _auto_save_session should have been called at least once
assert mock_save.call_count >= 1, \
"_auto_save_session should be called during autonomous iterations"
def test_no_save_without_memory(self):
"""Without memory, _auto_save_session should not be called extra times."""
agent = self._make_agent()
with patch.object(agent, 'chat', return_value="task completed"):
with patch.object(agent, '_auto_save_session') as mock_save:
result = agent.run_autonomous("Do something")
assert result.success
# ---------------------------------------------------------------------------
# F8: Planning trigger when stage=planned
# ---------------------------------------------------------------------------
class TestF8_PlanningFromAutonomyStage:
"""When autonomy detects stage=planned, it should use planning if available."""
def test_planned_stage_detection(self):
"""Prompts with edit intent should recommend 'planned' stage."""
from praisonaiagents.agent.autonomy import AutonomyTrigger, AutonomyStage
trigger = AutonomyTrigger()
signals = trigger.analyze("edit the auth module to add validation")
stage = trigger.recommend_stage(signals)
assert stage == AutonomyStage.PLANNED
def test_run_autonomous_uses_planning_for_planned_stage(self):
"""run_autonomous should use planning when stage=planned and planning is available."""
from praisonaiagents import Agent
agent = Agent(
name="planner",
instructions="Test",
llm="gpt-4o-mini",
autonomy=True,
planning=True,
)
# Mock both chat and _start_with_planning
with patch.object(agent, 'get_recommended_stage', return_value="planned"):
with patch.object(agent, 'chat', return_value="<promise>DONE</promise>"):
with patch.object(agent, '_start_with_planning', return_value="planned result") as mock_plan:
result = agent.run_autonomous(
"edit the auth module",
completion_promise="DONE",
)
# Planning should be called for the first iteration of planned stage
# (but only if the agent has planning enabled)
# ---------------------------------------------------------------------------
# DRY: AutonomyLevel, AutonomyConfig exports
# ---------------------------------------------------------------------------
class TestDRY_Exports:
"""Verify DRY exports — single source of truth."""
def test_autonomy_level_in_all(self):
"""AutonomyLevel should be in praisonaiagents.__all__."""
import praisonaiagents
assert "AutonomyLevel" in praisonaiagents.__all__
def test_autonomy_config_in_all(self):
"""AutonomyConfig should be in praisonaiagents.__all__."""
import praisonaiagents
assert "AutonomyConfig" in praisonaiagents.__all__
def test_autonomy_result_importable(self):
"""AutonomyResult should be importable."""
from praisonaiagents.agent.autonomy import AutonomyResult
assert AutonomyResult is not None
def test_autonomy_stage_importable(self):
"""AutonomyStage should be importable and is now an alias for EscalationStage (IntEnum)."""
from praisonaiagents.agent.autonomy import AutonomyStage
from praisonaiagents.escalation.types import EscalationStage
assert AutonomyStage is EscalationStage
assert AutonomyStage.DIRECT.name == "DIRECT"
# ---------------------------------------------------------------------------
# Integration: End-to-end autonomy wiring
# ---------------------------------------------------------------------------
class TestIntegration_AutonomyWiring:
"""Integration tests for autonomy wiring across components."""
def test_agent_autonomy_true_enables_all(self):
"""Agent(autonomy=True) should enable autonomy features."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="Test", autonomy=True)
assert agent.autonomy_enabled
assert agent._autonomy_trigger is not None
assert agent._doom_loop_tracker is not None
def test_agent_autonomy_dict_configures(self):
"""Agent(autonomy={...}) should configure autonomy."""
from praisonaiagents import Agent
agent = Agent(
name="test",
instructions="Test",
autonomy={"max_iterations": 50, "doom_loop_threshold": 10}
)
assert agent.autonomy_enabled
assert agent.autonomy_config.get("max_iterations") == 50
def test_agent_autonomy_config_object(self):
"""Agent(autonomy=AutonomyConfig(...)) should work."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
agent = Agent(
name="test",
instructions="Test",
autonomy=AutonomyConfig(max_iterations=25, level="auto_edit")
)
assert agent.autonomy_enabled
assert agent.autonomy_config.get("max_iterations") == 25
def test_autoagents_propagates_autonomy(self):
"""AutoAgents should propagate autonomy to generated agents."""
# This is an existing working feature — regression test
from praisonaiagents.agents.autoagents import AutoAgents
# We can't easily test this without LLM, so just verify the param is stored
# The actual propagation happens in _create_agents_and_tasks
pass # Covered by existing tests
@@ -0,0 +1,559 @@
"""
TDD tests for unified autonomy system.
Tests cover:
- Phase 1: Unified enums + triggers (G-DUP-1, G-DUP-3)
- Phase 2: Unified doom loop (G-DUP-2, G-RECOVERY-1)
- Phase 3: ObservabilityHooks wiring (G-UNUSED-2)
- Phase 4: Per-agent approval bridge (G-BRIDGE-1, G-BRIDGE-2)
- Phase 5: Interactive routing (G-INTERACTIVE-1)
- Phase 6: CLI parity
- Phase 7: Exports
"""
import pytest
from unittest.mock import patch
# ============================================================================
# Phase 1: Unified Enums + Triggers
# ============================================================================
class TestUnifiedStageEnum:
"""AutonomyStage must be an alias for EscalationStage."""
def test_autonomy_stage_is_escalation_stage(self):
from praisonaiagents.agent.autonomy import AutonomyStage
from praisonaiagents.escalation.types import EscalationStage
# AutonomyStage values must match EscalationStage values
assert AutonomyStage.DIRECT == EscalationStage.DIRECT
assert AutonomyStage.HEURISTIC == EscalationStage.HEURISTIC
assert AutonomyStage.PLANNED == EscalationStage.PLANNED
assert AutonomyStage.AUTONOMOUS == EscalationStage.AUTONOMOUS
def test_stage_ordering(self):
from praisonaiagents.agent.autonomy import AutonomyStage
assert AutonomyStage.DIRECT < AutonomyStage.HEURISTIC
assert AutonomyStage.HEURISTIC < AutonomyStage.PLANNED
assert AutonomyStage.PLANNED < AutonomyStage.AUTONOMOUS
def test_stage_int_values(self):
from praisonaiagents.agent.autonomy import AutonomyStage
assert int(AutonomyStage.DIRECT) == 0
assert int(AutonomyStage.HEURISTIC) == 1
assert int(AutonomyStage.PLANNED) == 2
assert int(AutonomyStage.AUTONOMOUS) == 3
class TestUnifiedTrigger:
"""AutonomyTrigger must delegate to EscalationTrigger."""
def test_trigger_analyzes_simple_question(self):
from praisonaiagents.agent.autonomy import AutonomyTrigger
trigger = AutonomyTrigger()
signals = trigger.analyze("What is Python?")
assert "simple_question" in signals
def test_trigger_analyzes_edit_intent(self):
from praisonaiagents.agent.autonomy import AutonomyTrigger
trigger = AutonomyTrigger()
signals = trigger.analyze("Edit the file auth.py and fix the bug")
assert "edit_intent" in signals
def test_trigger_analyzes_multi_step(self):
from praisonaiagents.agent.autonomy import AutonomyTrigger
trigger = AutonomyTrigger()
# EscalationTrigger requires comma-delimited sequential patterns or multiple action verbs
signals = trigger.analyze("First, read the file, and then refactor it, finally, run tests")
assert "multi_step" in signals
def test_trigger_recommends_direct_for_simple(self):
from praisonaiagents.agent.autonomy import AutonomyTrigger, AutonomyStage
trigger = AutonomyTrigger()
signals = trigger.analyze("What is Python?")
stage = trigger.recommend_stage(signals)
assert stage == AutonomyStage.DIRECT
def test_trigger_recommends_autonomous_for_multi_step(self):
from praisonaiagents.agent.autonomy import AutonomyTrigger, AutonomyStage
trigger = AutonomyTrigger()
signals = trigger.analyze("First refactor auth.py, then write tests, finally run them")
stage = trigger.recommend_stage(signals)
assert stage == AutonomyStage.AUTONOMOUS
def test_trigger_recommends_planned_for_edit(self):
from praisonaiagents.agent.autonomy import AutonomyTrigger, AutonomyStage
trigger = AutonomyTrigger()
signals = trigger.analyze("Edit the README to add installation steps")
stage = trigger.recommend_stage(signals)
assert stage == AutonomyStage.PLANNED
def test_trigger_detects_file_references(self):
from praisonaiagents.agent.autonomy import AutonomyTrigger
trigger = AutonomyTrigger()
signals = trigger.analyze("Read the file src/main.py and explain it")
assert "file_references" in signals
def test_trigger_detects_code_blocks(self):
from praisonaiagents.agent.autonomy import AutonomyTrigger
trigger = AutonomyTrigger()
signals = trigger.analyze("Fix this code:\n```python\nprint('hello')\n```")
assert "code_blocks" in signals
# ============================================================================
# Phase 2: Unified Doom Loop
# ============================================================================
class TestUnifiedDoomLoop:
"""DoomLoopTracker must use advanced detection from DoomLoopDetector."""
def test_basic_doom_loop_detection(self):
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
# Same action 3 times
for _ in range(3):
tracker.record("read_file", {"path": "foo.py"}, "content", True)
assert tracker.is_doom_loop() is True
def test_no_doom_loop_with_varied_actions(self):
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
tracker.record("read_file", {"path": "a.py"}, "content_a", True)
tracker.record("write_file", {"path": "b.py"}, "ok", True)
tracker.record("read_file", {"path": "c.py"}, "content_c", True)
assert tracker.is_doom_loop() is False
def test_doom_loop_reset(self):
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=2)
tracker.record("read_file", {"path": "foo.py"}, "content", True)
tracker.record("read_file", {"path": "foo.py"}, "content", True)
assert tracker.is_doom_loop() is True
tracker.reset()
assert tracker.is_doom_loop() is False
def test_doom_loop_has_recovery_action(self):
"""DoomLoopTracker should expose recovery_action when loop detected."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=2)
tracker.record("read_file", {"path": "foo.py"}, "content", True)
tracker.record("read_file", {"path": "foo.py"}, "content", True)
assert tracker.is_doom_loop() is True
recovery = tracker.get_recovery_action()
assert recovery in ("retry_different", "escalate_model", "request_help", "abort", "continue")
def test_doom_loop_consecutive_failures(self):
"""DoomLoopTracker should detect consecutive failures."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
tracker.record("tool_call", {"name": "search"}, "error", False)
tracker.record("tool_call", {"name": "search"}, "error", False)
tracker.record("tool_call", {"name": "search"}, "error", False)
assert tracker.is_doom_loop() is True
class TestRunAutonomousRecovery:
"""run_autonomous() should attempt recovery on doom loop instead of immediate abort."""
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_recovery_on_doom_loop(self, mock_chat):
from praisonaiagents import Agent
# Return identical responses to trigger doom loop
mock_chat.return_value = "I'll keep trying the same thing"
agent = Agent(
name="test",
instructions="test",
autonomy={"max_iterations": 10, "doom_loop_threshold": 2},
llm="gpt-4o-mini",
)
result = agent.run_autonomous("Do something")
# Should eventually stop with doom_loop or recovery exhaustion
assert result.completion_reason in ("doom_loop", "max_iterations", "goal")
# ============================================================================
# Phase 3: ObservabilityHooks wiring
# ============================================================================
class TestObservabilityWiring:
"""observe=True should emit ObservabilityHooks events."""
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_observe_emits_events(self, mock_chat):
from praisonaiagents import Agent
mock_chat.return_value = "Task completed"
agent = Agent(
name="test",
instructions="test",
autonomy={"observe": True, "max_iterations": 3},
llm="gpt-4o-mini",
)
result = agent.run_autonomous("Do something simple")
assert result.success is True
# The observability hooks should have been created
hooks = getattr(agent, '_observability_hooks', None)
if hooks is not None:
metrics = hooks.get_metrics()
assert metrics.total_steps >= 1
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_observe_false_no_hooks(self, mock_chat):
from praisonaiagents import Agent
mock_chat.return_value = "Task completed"
agent = Agent(
name="test",
instructions="test",
autonomy={"observe": False, "max_iterations": 3},
llm="gpt-4o-mini",
)
result = agent.run_autonomous("Do something simple")
assert result.success is True
# No hooks should be created
hooks = getattr(agent, '_observability_hooks', None)
assert hooks is None
# ============================================================================
# Phase 4: Per-agent approval bridge
# ============================================================================
class TestApprovalBridge:
"""Level→approval bridge should be per-agent, not global env var."""
def test_full_auto_sets_auto_approve_backend(self):
from praisonaiagents import Agent
agent = Agent(
name="test",
instructions="test",
autonomy={"level": "full_auto"},
llm="gpt-4o-mini",
)
# Should NOT set env var (per-agent instead)
backend = getattr(agent, '_approval_backend', None)
# full_auto should result in an auto-approve backend
assert backend is not None or agent.autonomy_config.get("level") == "full_auto"
def test_suggest_level_no_auto_approve(self):
from praisonaiagents import Agent
agent = Agent(
name="test",
instructions="test",
autonomy={"level": "suggest"},
llm="gpt-4o-mini",
)
# suggest level should NOT auto-approve
backend = getattr(agent, '_approval_backend', None)
if backend is not None:
from praisonaiagents.approval.backends import AutoApproveBackend
assert not isinstance(backend, AutoApproveBackend)
def test_invalid_level_raises_error(self):
from praisonaiagents import Agent
with pytest.raises(ValueError, match="[Ii]nvalid autonomy level"):
Agent(
name="test",
instructions="test",
autonomy={"level": "invalid_level_xyz"},
llm="gpt-4o-mini",
)
def test_multi_agent_isolation(self):
"""Two agents with different levels should not interfere."""
from praisonaiagents import Agent
agent_auto = Agent(
name="auto",
instructions="test",
autonomy={"level": "full_auto"},
llm="gpt-4o-mini",
)
agent_suggest = Agent(
name="suggest",
instructions="test",
autonomy={"level": "suggest"},
llm="gpt-4o-mini",
)
# agent_suggest should NOT be auto-approved just because agent_auto exists
auto_backend = getattr(agent_auto, '_approval_backend', None)
suggest_backend = getattr(agent_suggest, '_approval_backend', None)
# They should have different approval behavior
if auto_backend is not None and suggest_backend is not None:
assert not isinstance(auto_backend, type(suggest_backend))
# ============================================================================
# Phase 5: Config validation
# ============================================================================
class TestAutonomyConfigValidation:
"""AutonomyConfig should validate level field."""
def test_valid_levels(self):
from praisonaiagents.agent.autonomy import AutonomyConfig
for level in ("suggest", "auto_edit", "full_auto"):
config = AutonomyConfig(level=level)
assert config.level == level
def test_config_from_dict_preserves_all_fields(self):
from praisonaiagents.agent.autonomy import AutonomyConfig
data = {
"enabled": True,
"level": "full_auto",
"max_iterations": 50,
"doom_loop_threshold": 5,
"auto_escalate": False,
"observe": True,
"completion_promise": "DONE",
"clear_context": True,
}
config = AutonomyConfig.from_dict(data)
assert config.level == "full_auto"
assert config.max_iterations == 50
assert config.doom_loop_threshold == 5
assert config.auto_escalate is False
assert config.observe is True
assert config.completion_promise == "DONE"
assert config.clear_context is True
# ============================================================================
# Phase 6: CLI parity
# ============================================================================
class TestCLIAutonomyMode:
"""CLI autonomy mode should bridge to SDK properly."""
def test_autonomy_mode_enum_values(self):
"""AutonomyMode should mirror AutonomyLevel values."""
# Skip if wrapper not available
try:
from praisonai.cli.features.autonomy_mode import AutonomyMode
except ImportError:
pytest.skip("praisonai wrapper not installed")
assert AutonomyMode.SUGGEST.value == "suggest"
assert AutonomyMode.AUTO_EDIT.value == "auto_edit"
assert AutonomyMode.FULL_AUTO.value == "full_auto"
def test_autonomy_mode_from_string(self):
try:
from praisonai.cli.features.autonomy_mode import AutonomyMode
except ImportError:
pytest.skip("praisonai wrapper not installed")
assert AutonomyMode.from_string("suggest") == AutonomyMode.SUGGEST
assert AutonomyMode.from_string("full_auto") == AutonomyMode.FULL_AUTO
assert AutonomyMode.from_string("auto_edit") == AutonomyMode.AUTO_EDIT
# ============================================================================
# Phase 7: Exports
# ============================================================================
class TestExports:
"""Key classes should be importable from top-level."""
def test_autonomy_imports(self):
from praisonaiagents.agent.autonomy import (
AutonomyConfig,
AutonomyStage,
AutonomyTrigger,
AutonomyResult,
DoomLoopTracker,
AutonomyMixin,
)
assert AutonomyConfig is not None
assert AutonomyStage is not None
assert AutonomyTrigger is not None
assert AutonomyResult is not None
assert DoomLoopTracker is not None
assert AutonomyMixin is not None
def test_escalation_imports(self):
from praisonaiagents.escalation import (
EscalationPipeline,
EscalationStage,
EscalationConfig,
EscalationTrigger,
DoomLoopDetector,
ObservabilityHooks,
)
assert EscalationPipeline is not None
assert EscalationStage is not None
assert EscalationConfig is not None
assert EscalationTrigger is not None
assert DoomLoopDetector is not None
assert ObservabilityHooks is not None
def test_approval_imports(self):
from praisonaiagents.approval.protocols import (
ApprovalRequest,
ApprovalDecision,
ApprovalConfig,
ApprovalProtocol,
)
assert ApprovalRequest is not None
assert ApprovalDecision is not None
assert ApprovalConfig is not None
assert ApprovalProtocol is not None
# ============================================================================
# Smoke Tests (mock-based, no real LLM)
# ============================================================================
class TestAutonomySmokeTests:
"""End-to-end smoke tests with mocked LLM responses."""
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_full_auto_completes_on_keyword(self, mock_chat):
from praisonaiagents import Agent
mock_chat.return_value = "I have finished the task. Task completed."
agent = Agent(
name="smoker",
instructions="Do tasks",
autonomy={"level": "full_auto", "max_iterations": 5},
llm="gpt-4o-mini",
)
result = agent.run_autonomous("Do a simple task")
assert result.success is True
assert result.completion_reason == "goal"
assert result.iterations >= 1
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_promise_completion(self, mock_chat):
from praisonaiagents import Agent
mock_chat.side_effect = [
"Working on it...",
"Still going...",
"Done! <promise>FINISHED</promise>",
]
agent = Agent(
name="promise_agent",
instructions="Do tasks",
autonomy={
"level": "full_auto",
"max_iterations": 10,
"completion_promise": "FINISHED",
},
llm="gpt-4o-mini",
)
result = agent.run_autonomous(
"First analyze the code, then refactor it, then test it"
)
assert result.success is True
assert result.completion_reason == "promise"
assert result.iterations == 3
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_timeout_stops_execution(self, mock_chat):
import time
from praisonaiagents import Agent
def slow_response(prompt):
time.sleep(0.15)
return "Still working on the multi-step task, not completed yet..."
mock_chat.side_effect = slow_response
agent = Agent(
name="slow_agent",
instructions="Do tasks",
autonomy={"max_iterations": 100},
llm="gpt-4o-mini",
)
result = agent.run_autonomous(
"First, analyze the code, and then refactor it, finally, run tests",
timeout_seconds=0.25,
)
assert result.completion_reason == "timeout"
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_max_iterations_stops(self, mock_chat):
from praisonaiagents import Agent
# Response must NOT contain any completion keywords: done, finished, completed, task complete
mock_chat.return_value = "Analyzing the codebase structure and dependencies..."
agent = Agent(
name="iter_agent",
instructions="Do tasks",
autonomy={"max_iterations": 3},
llm="gpt-4o-mini",
)
result = agent.run_autonomous(
"First, refactor auth, and then add tests, finally, write docs"
)
assert result.completion_reason == "max_iterations"
assert result.iterations == 3
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_clear_context_between_iterations(self, mock_chat):
from praisonaiagents import Agent
call_count = 0
def track_calls(prompt):
nonlocal call_count
call_count += 1
if call_count >= 2:
return "Task completed"
return "Working..."
mock_chat.side_effect = track_calls
agent = Agent(
name="ctx_agent",
instructions="Do tasks",
autonomy={"max_iterations": 5, "clear_context": True},
llm="gpt-4o-mini",
)
result = agent.run_autonomous(
"First analyze, then refactor the code"
)
assert result.success is True
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_error_handling(self, mock_chat):
from praisonaiagents import Agent
mock_chat.side_effect = RuntimeError("LLM connection failed")
agent = Agent(
name="err_agent",
instructions="Do tasks",
autonomy={"max_iterations": 5},
llm="gpt-4o-mini",
)
result = agent.run_autonomous("Do something")
assert result.success is False
assert result.completion_reason == "error"
assert "connection failed" in result.error
def test_autonomy_disabled_raises(self):
from praisonaiagents import Agent
agent = Agent(
name="no_auto",
instructions="Do tasks",
llm="gpt-4o-mini",
)
with pytest.raises(ValueError, match="[Aa]utonomy"):
agent.run_autonomous("Do something")
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_auto_escalate_progresses_stage(self, mock_chat):
from praisonaiagents import Agent
responses = ["Working..."] * 5 + ["Task completed"]
mock_chat.side_effect = responses
agent = Agent(
name="esc_agent",
instructions="Do tasks",
autonomy={
"auto_escalate": True,
"max_iterations": 10,
},
llm="gpt-4o-mini",
)
result = agent.run_autonomous("What is Python?") # starts as DIRECT
assert result.success is True
+1 -1
View File
@@ -2549,7 +2549,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.2"
version = "1.5.7"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+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.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.7.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.7.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -34,6 +34,95 @@ def classify_keyword(text: str) -> str | None:
return None
async def classify_with_llm(
text: str,
tool_name: str,
arguments: dict,
risk_level: str = "medium",
) -> dict:
"""Classify a free-text approval response using an LLM.
When a user replies with something richer than a simple yes/no (e.g.
*"yes, but path is ~/Downloads"*), we ask a lightweight LLM to:
1. Determine intent: ``approved`` (bool).
2. Extract any **modified arguments** the user specified.
Args:
text: The raw user reply text.
tool_name: Name of the tool awaiting approval.
arguments: Original arguments dict the tool would be called with.
risk_level: Risk classification string.
Returns:
``{"approved": bool, "reason": str, "modified_args": dict}``
where *modified_args* may be empty if no modifications were requested.
"""
try:
from openai import OpenAI
except ImportError:
logger.warning("openai package not installed — cannot classify with LLM")
return {"approved": False, "reason": "LLM classification unavailable", "modified_args": {}}
import json as _json
import os as _os
args_str = _json.dumps(arguments, indent=2, default=str)
system_prompt = (
"You are a tool-approval classifier. A human was asked to approve or deny "
"a tool execution and replied with a free-text message. Your job:\n"
"1. Determine if the human APPROVED or DENIED the execution.\n"
"2. Extract any MODIFIED ARGUMENTS from their reply.\n\n"
"Respond with ONLY valid JSON (no markdown fences):\n"
'{"approved": true/false, "reason": "short explanation", '
'"modified_args": {"arg_name": "new_value", ...}}\n\n'
"Rules:\n"
'- If the reply clearly means yes/approve/go ahead (even with extra context), set approved=true.\n'
'- If the reply clearly means no/deny/reject, set approved=false.\n'
"- If the reply contains corrections or new values for any of the original arguments, "
"include them in modified_args using the EXACT original argument key names.\n"
"- modified_args should be empty {} if no modifications were mentioned.\n"
"- Only include arguments that were actually changed, not unchanged ones.\n"
)
user_prompt = (
f"Tool: {tool_name}\n"
f"Risk: {risk_level}\n"
f"Original arguments:\n{args_str}\n\n"
f"Human reply: \"{text}\"\n\n"
"Classify this reply as JSON:"
)
try:
client = OpenAI(
api_key=_os.environ.get("OPENAI_API_KEY", ""),
base_url=_os.environ.get("OPENAI_BASE_URL"),
)
response = client.chat.completions.create(
model=_os.environ.get("APPROVAL_LLM_MODEL", "gpt-4o-mini"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0,
max_tokens=300,
)
raw = response.choices[0].message.content.strip()
# Strip markdown code fences if present
if raw.startswith("```"):
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = _json.loads(raw)
return {
"approved": bool(result.get("approved", False)),
"reason": str(result.get("reason", text)),
"modified_args": result.get("modified_args", {}),
}
except Exception as e:
logger.warning(f"LLM approval classification failed: {e}")
return {"approved": False, "reason": f"Could not classify response: {text}", "modified_args": {}}
def sync_wrapper(async_fn, timeout: float):
"""Run *async_fn* (a coroutine) synchronously, handling nested loops."""
try:
@@ -27,7 +27,7 @@ import os
import time
from typing import Any, Dict, Optional
from ._approval_base import classify_keyword, sync_wrapper
from ._approval_base import classify_keyword, classify_with_llm, sync_wrapper
logger = logging.getLogger(__name__)
@@ -147,7 +147,7 @@ class DiscordApproval:
# 2. Poll for text reply
decision = await self._poll_for_response(
channel_id, msg_id, session=session,
channel_id, msg_id, request=request, session=session,
)
# 3. Update original message with result
@@ -199,7 +199,7 @@ class DiscordApproval:
"title": "🔒 Tool Approval Required",
"color": risk_colors.get(request.risk_level, 0x808080),
"fields": fields,
"footer": {"text": f"Reply yes to approve or no to deny (timeout: {int(self._timeout)}s)"},
"footer": {"text": f"Reply yes/no, or with modifications (e.g. 'yes, but use ~/Downloads') (timeout: {int(self._timeout)}s)"},
}
def _build_fallback_text(self, request) -> str:
@@ -215,6 +215,7 @@ class DiscordApproval:
self,
channel_id: str,
message_id: str,
request: Optional[Any] = None,
session: Optional[Any] = None,
) -> Any:
"""Poll channel messages for a reply after the approval message."""
@@ -264,6 +265,30 @@ class DiscordApproval:
metadata={"platform": "discord", "message_id": msg.get("id")},
)
# Not a simple keyword — use LLM to classify
if kw is None and text and request is not None:
try:
llm_result = await classify_with_llm(
text=text,
tool_name=request.tool_name,
arguments=request.arguments,
risk_level=request.risk_level,
)
return ApprovalDecision(
approved=llm_result["approved"],
reason=llm_result["reason"],
approver=user_id,
modified_args=llm_result.get("modified_args", {}),
metadata={
"platform": "discord",
"message_id": msg.get("id"),
"llm_classified": True,
"original_text": text,
},
)
except Exception as llm_err:
logger.warning(f"LLM classification failed: {llm_err}")
except Exception as e:
logger.warning(f"Discord poll exception: {e}")
@@ -24,7 +24,7 @@ import os
import time
from typing import Any, Dict, List, Optional
from ._approval_base import classify_keyword, sync_wrapper
from ._approval_base import classify_keyword, classify_with_llm, sync_wrapper
logger = logging.getLogger(__name__)
@@ -62,7 +62,7 @@ class SlackApproval:
raise ValueError(
"Slack bot token is required. Pass token= or set SLACK_BOT_TOKEN env var."
)
self._channel = channel or ""
self._channel = channel or os.environ.get("SLACK_CHANNEL", "")
self._timeout = timeout
self._poll_interval = poll_interval
@@ -158,7 +158,7 @@ class SlackApproval:
# 2. Poll for response (thread-scoped for multi-agent isolation)
decision = await self._poll_for_response(
msg_channel, msg_ts, session=session,
msg_channel, msg_ts, request=request, session=session,
)
# 3. Update original message with result
@@ -222,8 +222,10 @@ class SlackApproval:
{
"type": "mrkdwn",
"text": (
f"Reply *yes* to approve or *no* to deny "
f"(timeout: {int(self._timeout)}s)"
f"Reply *yes* to approve or *no* to deny. "
f"You can also reply with modifications "
f"(e.g. _yes, but use ~/Downloads_) "
f"(timeout: {int(self._timeout) if self._timeout is not None else '∞'}s)"
),
}
],
@@ -245,6 +247,7 @@ class SlackApproval:
self,
channel: str,
message_ts: str,
request: Optional[Any] = None,
session: Optional[Any] = None,
) -> Any:
"""Poll for replies scoped to the approval message thread.
@@ -252,12 +255,16 @@ class SlackApproval:
Uses ``conversations.replies`` when available so concurrent approval
requests on the same channel don't cross-talk. Falls back to
``conversations.history`` if replies returns an error (e.g. DMs).
When a reply is not a simple yes/no keyword, the text is sent to an
LLM to classify intent and extract any modified arguments (e.g.
*"yes, but path is ~/Downloads"*).
"""
from praisonaiagents.approval.protocols import ApprovalDecision
deadline = time.monotonic() + self._timeout
deadline = None if self._timeout is None else time.monotonic() + self._timeout
while time.monotonic() < deadline:
while deadline is None or time.monotonic() < deadline:
await asyncio.sleep(self._poll_interval)
try:
@@ -307,13 +314,38 @@ class SlackApproval:
metadata={"platform": "slack", "message_ts": msg.get("ts")},
)
# Not a simple keyword — use LLM to classify free-text
if kw is None and text and request is not None:
try:
llm_result = await classify_with_llm(
text=text,
tool_name=request.tool_name,
arguments=request.arguments,
risk_level=request.risk_level,
)
return ApprovalDecision(
approved=llm_result["approved"],
reason=llm_result["reason"],
approver=user,
modified_args=llm_result.get("modified_args", {}),
metadata={
"platform": "slack",
"message_ts": msg.get("ts"),
"llm_classified": True,
"original_text": text,
},
)
except Exception as llm_err:
logger.warning(f"LLM classification failed: {llm_err}")
except Exception as e:
logger.warning(f"Slack poll exception: {e}")
# Timeout
timeout_str = "indefinite" if self._timeout is None else f"{int(self._timeout)}s"
return ApprovalDecision(
approved=False,
reason=f"Timed out waiting for Slack approval ({int(self._timeout)}s)",
reason=f"Timed out waiting for Slack approval ({timeout_str})",
metadata={"platform": "slack", "timeout": True},
)
@@ -27,7 +27,7 @@ import os
import time
from typing import Any, Dict, Optional
from ._approval_base import classify_keyword, sync_wrapper
from ._approval_base import classify_keyword, classify_with_llm, sync_wrapper
logger = logging.getLogger(__name__)
@@ -135,7 +135,7 @@ class TelegramApproval:
# 2. Poll for callback_query response
decision = await self._poll_for_callback(
chat_id, message_id, session=session,
chat_id, message_id, request=request, session=session,
)
# 3. Update original message with result
@@ -203,6 +203,7 @@ class TelegramApproval:
self,
chat_id: str,
message_id: int,
request: Optional[Any] = None,
session: Optional[Any] = None,
) -> Any:
"""Poll getUpdates for callback_query matching our message."""
@@ -289,6 +290,30 @@ class TelegramApproval:
metadata={"platform": "telegram", "message_id": message_id},
)
# Not a simple keyword — use LLM to classify
if kw is None and text and request is not None:
try:
llm_result = await classify_with_llm(
text=text,
tool_name=request.tool_name,
arguments=request.arguments,
risk_level=request.risk_level,
)
return ApprovalDecision(
approved=llm_result["approved"],
reason=llm_result["reason"],
approver=user_id,
modified_args=llm_result.get("modified_args", {}),
metadata={
"platform": "telegram",
"message_id": message_id,
"llm_classified": True,
"original_text": text,
},
)
except Exception as llm_err:
logger.warning(f"LLM classification failed: {llm_err}")
except Exception as e:
logger.warning(f"Telegram poll exception: {e}")
@@ -112,13 +112,19 @@ class WhatsAppWebAdapter:
# ── Suppress noisy Go-backend warnings ─────────────────────
# whatsmeow emits non-actionable warnings (websocket EOF on
# close, LTHash mismatches during state sync, duplicate
# contacts, missing MAC values). These are protocol-level
# quirks, not bugs in our code — suppress to reduce noise.
# contacts, missing MAC values, decryption failures for old
# keys). These are protocol-level quirks, not bugs in our
# code — suppress to reduce noise.
_known_noise = (
"failed to close WebSocket",
"Error sending close to websocket",
"duplicate contacts found",
"mismatching LTHash",
"missing value MAC",
"failed to decrypt prekey message",
"failed to decrypt group message",
"received message with old counter",
"failed to read frame header",
)
class _WhatsmeowNoiseFilter(logging.Filter):
@@ -126,9 +132,13 @@ class WhatsAppWebAdapter:
msg = record.getMessage()
return not any(pat in msg for pat in _known_noise)
for _logger_name in ("whatsmeow.Client", "whatsmeow.Database", "whatsmeow"):
# Apply to the root "whatsmeow" logger so ALL child loggers
# (whatsmeow.Client, whatsmeow.Database, etc.) inherit it.
# Also apply to neonize's own log module logger.
_noise_filter = _WhatsmeowNoiseFilter()
for _logger_name in ("whatsmeow", "neonize.utils.log"):
_wm_logger = logging.getLogger(_logger_name)
_wm_logger.addFilter(_WhatsmeowNoiseFilter())
_wm_logger.addFilter(_noise_filter)
# ── Critical: bridge neonize's event loop to ours ──────────
# neonize creates event_global_loop = asyncio.new_event_loop()
+8
View File
@@ -147,6 +147,14 @@ class Bot:
agent._approval_backend = AutoApproveBackend()
logger.debug(f"Bot: auto_approve_tools enabled for agent '{getattr(agent, 'name', '?')}'")
# Wire BotConfig.autonomy → Agent autonomy (if not already enabled)
autonomy_val = None
if self._config:
autonomy_val = getattr(self._config, 'autonomy', None)
if autonomy_val and not getattr(agent, 'autonomy_enabled', False):
agent._init_autonomy(autonomy_val)
logger.debug(f"Bot: autonomy enabled for agent '{getattr(agent, 'name', '?')}'")
# Inject session history if agent has no memory configured (zero-dep).
# NOTE: No session_id here — BotSessionManager handles per-user
# isolation by swapping chat_history before/after each agent.chat().
+21 -2
View File
@@ -324,15 +324,25 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
if not self._respond_to_all:
if is_self_chat:
pass # true self-chat — always allow
elif is_from_me:
# Outgoing message to someone else's chat — NEVER process.
# This prevents the bot from responding when the user
# sends messages to friends/groups. Only self-chat
# (user → own number) should trigger the bot.
logger.debug(
"Filtered: outgoing msg %s to chat %s (not self-chat)",
msg_id, chat_jid,
)
return
elif is_group and self._allowed_groups:
# Check if group JID is in the allowlist
# Inbound group message — check if group is in allowlist
chat_jid_str = chat_jid.split("@")[0] if "@" in chat_jid else chat_jid
if not (chat_jid in self._allowed_groups
or chat_jid_str in self._allowed_groups):
logger.debug("Filtered: group %s not in allowlist", chat_jid)
return
elif not is_group and self._allowed_numbers:
# Check if sender number is in the allowlist
# Inbound DM — check if sender is in allowlist
sender_num = _jid_user(sender_jid)
if sender_num not in self._allowed_numbers:
logger.debug("Filtered: sender %s not in allowlist", sender_num)
@@ -457,10 +467,19 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
await asyncio.sleep(1)
except asyncio.CancelledError:
pass
except KeyboardInterrupt:
pass
except Exception as e:
logger.error(f"WhatsApp Web mode error: {e}")
raise
finally:
# Graceful shutdown: stop the neonize log worker thread to
# prevent "threading._shutdown" errors on Ctrl+C.
try:
from neonize.utils.log import shutdown_log_worker
shutdown_log_worker()
except Exception:
pass
await self.stop()
async def _web_send(self, to: Any, text: str) -> None:
+2
View File
@@ -219,6 +219,7 @@ def register_commands():
from .commands.batch import app as batch_app
from .commands.replay import app as replay_app
from .commands.loop import app as loop_app
from .commands.tracker import app as tracker_app
# Import new moltbot-inspired commands
from .commands.bot import app as bot_app
@@ -287,6 +288,7 @@ def register_commands():
app.add_typer(batch_app, name="batch", help="Run all PraisonAI scripts in current folder")
app.add_typer(replay_app, name="replay", help="Context replay for debugging agent execution")
app.add_typer(loop_app, name="loop", help="Autonomous agent execution loops")
app.add_typer(tracker_app, name="tracker", help="Autonomous agent tracking with step-by-step analysis")
# Helper function for loading agents from config
def _load_agents_from_config_file(config_path: str, console) -> list:
@@ -286,6 +286,9 @@ def bot_whatsapp(
auto_tts: bool = typer.Option(False, "--auto-tts", help="Auto-convert all responses to speech"),
stt: bool = typer.Option(False, "--stt", help="Enable STT tool for speech-to-text"),
stt_model: Optional[str] = typer.Option(None, "--stt-model", help="STT model (default: openai/whisper-1)"),
respond_to: Optional[List[str]] = typer.Option(None, "--respond-to", help="Phone numbers the bot responds to (besides self). Repeat or comma-separate."),
respond_to_groups: Optional[List[str]] = typer.Option(None, "--respond-to-groups", help="Group JIDs the bot responds in. Repeat or comma-separate."),
respond_to_all: bool = typer.Option(False, "--respond-to-all", help="Respond to ALL messages (default: self-only)"),
):
"""Start a WhatsApp bot with full agent capabilities.
@@ -328,6 +331,14 @@ def bot_whatsapp(
user_id=user_id,
)
# Parse filtering values — support both repeated flags and comma-separated
allowed_numbers = None
if respond_to:
allowed_numbers = [n.strip() for raw in respond_to for n in raw.split(",") if n.strip()]
allowed_groups = None
if respond_to_groups:
allowed_groups = [g.strip() for raw in respond_to_groups for g in raw.split(",") if g.strip()]
handler = BotHandler()
handler.start_whatsapp(
token=token,
@@ -338,6 +349,9 @@ def bot_whatsapp(
capabilities=capabilities,
mode=mode,
creds_dir=creds_dir,
allowed_numbers=allowed_numbers,
allowed_groups=allowed_groups,
respond_to_all=respond_to_all,
)
@@ -125,6 +125,7 @@ def chat_main(
is_flag=False,
flag_value="true",
),
approval: Optional[str] = typer.Option(None, "--approval", help="Approval backend: console, slack, telegram, discord, webhook, http, agent, auto, none"),
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
debug: bool = typer.Option(False, "--debug", help="Enable debug logging to ~/.praisonai/async_tui_debug.log"),
@@ -183,6 +184,19 @@ def chat_main(
else:
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
# Wire --approval backend via global registry (TUI creates agents internally)
if approval:
from praisonai.cli.features.approval import resolve_approval_backend
try:
backend = resolve_approval_backend(approval)
if backend is not None:
from praisonaiagents.approval import get_approval_registry
get_approval_registry().set_backend(backend)
typer.echo(f"Approval backend: {approval}")
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
# Use the async TUI (non-blocking, scrollable output)
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
@@ -193,6 +207,7 @@ def chat_main(
session_id=session_id,
workspace=workspace,
debug=debug,
autonomy_mode=autonomy,
)
tui = AsyncTUI(config=tui_config)
@@ -28,6 +28,9 @@ def loop_main(
timeout: Optional[float] = typer.Option(None, "--timeout", "-t", help="Timeout in seconds"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show verbose output"),
observe: bool = typer.Option(False, "--observe", "-o", help="Enable observability event logging"),
recovery: bool = typer.Option(True, "--recovery/--no-recovery", help="Enable graduated doom loop recovery (default: on)"),
level: Optional[str] = typer.Option(None, "--level", "-l", help="Autonomy level: suggest, auto_edit, full_auto"),
):
"""Run an agent in an autonomous loop.
@@ -60,6 +63,9 @@ def loop_main(
timeout=timeout,
model=model,
verbose=verbose,
observe=observe,
recovery=recovery,
level=level,
)
@@ -71,6 +77,9 @@ def _run_loop(
timeout: Optional[float],
model: Optional[str],
verbose: bool,
observe: bool = False,
recovery: bool = True,
level: Optional[str] = None,
):
"""Execute the autonomous loop."""
try:
@@ -84,7 +93,10 @@ def _run_loop(
"max_iterations": max_iterations,
"completion_promise": completion_promise,
"clear_context": clear_context,
"observe": observe,
}
if level:
autonomy_config["level"] = level
# Create agent
agent_kwargs = {
@@ -30,6 +30,9 @@ def run_main(
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
output_mode: Optional[str] = typer.Option(None, "--output", "-o", help="Output mode: silent (default), actions, verbose, json, stream"),
approval: Optional[str] = typer.Option(None, "--approval", help="Approval backend: console, slack, telegram, discord, webhook, http, agent, auto, none"),
approve_all_tools: bool = typer.Option(False, "--approve-all-tools", help="Require approval for ALL tool calls, not just dangerous tools"),
approval_timeout: Optional[str] = typer.Option(None, "--approval-timeout", help="Seconds to wait for approval. Use 'none' for indefinite wait"),
):
"""
Run agents from a file or prompt.
@@ -123,6 +126,9 @@ def run_main(
tools=tools,
max_tokens=max_tokens,
output_mode=output_mode,
approval=approval,
approve_all_tools=approve_all_tools,
approval_timeout=approval_timeout,
)
@@ -183,6 +189,9 @@ def _run_prompt(
tools: Optional[str] = None,
max_tokens: int = 16000,
output_mode: Optional[str] = None,
approval: Optional[str] = None,
approve_all_tools: bool = False,
approval_timeout: Optional[str] = None,
):
"""Run a direct prompt."""
output = get_output_controller()
@@ -201,6 +210,13 @@ def _run_prompt(
if model:
agent_config["llm"] = model
# Resolve approval backend if specified
if approval:
from praisonai.cli.features.approval import resolve_approval_config
agent_config["approval"] = resolve_approval_config(
approval, all_tools=approve_all_tools, timeout=approval_timeout,
)
agent = Agent(**agent_config)
result = agent.start(prompt)
@@ -263,6 +279,7 @@ def _run_prompt(
args.expand_prompt = False
args.expand_tools = None
args.no_tools = False
args.approval = approval
praison.args = args
result = praison.handle_direct_prompt(prompt)
@@ -0,0 +1,552 @@
"""
Tracker command for autonomous agent execution with step-by-step tracking.
Provides CLI interface for running agents in autonomous mode with:
- Real-time step tracking and status output
- Default tools (search, file, shell, code)
- Summary table at completion
- Gap analysis for debugging
"""
from typing import Optional, List
from dataclasses import dataclass, field
from datetime import datetime
import time
import typer
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
console = Console()
app = typer.Typer(
name="tracker",
help="Run agents in autonomous mode with step-by-step tracking",
no_args_is_help=False,
)
@dataclass
class TrackedStep:
"""A single tracked step in the autonomous execution."""
step_number: int
timestamp: str
action_type: str # chat, tool_call, completion, error
action_name: str # tool name or "thinking"
input_summary: str
output_summary: str
duration_seconds: float
success: bool
error: Optional[str] = None
@dataclass
class TrackerResult:
"""Result of a tracked autonomous execution."""
task: str
success: bool
completion_reason: str
total_steps: int
total_duration: float
steps: List[TrackedStep] = field(default_factory=list)
tools_used: List[str] = field(default_factory=list)
gaps_identified: List[str] = field(default_factory=list)
# Default tools for tracker (commonly used, no API keys required)
DEFAULT_TOOLS = [
"search_web", # Web search (DuckDuckGo fallback)
"web_crawl", # Web crawling
"read_file", # File reading
"write_file", # File writing
"list_files", # Directory listing
"execute_command", # Shell commands
"execute_code", # Python execution
"analyze_code", # Code analysis
]
# Extended tools (may require API keys)
EXTENDED_TOOLS = [
"tavily_search", # Tavily search (requires TAVILY_API_KEY)
"exa_search", # Exa search (requires EXA_API_KEY)
"crawl4ai", # Advanced crawling
"format_code", # Code formatting
"lint_code", # Code linting
]
def _get_tools(tool_names: List[str]) -> List:
"""Resolve tool names to actual tool functions."""
tools = []
try:
from praisonaiagents import tools as tool_module
for name in tool_names:
try:
tool_func = getattr(tool_module, name)
# Check if it's a module (e.g., web_crawl returns module)
# In that case, get the function with the same name from the module
if hasattr(tool_func, '__file__') and hasattr(tool_func, name):
tool_func = getattr(tool_func, name)
tools.append(tool_func)
except AttributeError:
console.print(f"[yellow]Warning: Tool '{name}' not found, skipping[/yellow]")
except ImportError:
console.print("[red]Error: praisonaiagents not installed[/red]")
return tools
def _summarize_text(text: str, max_len: int = 50) -> str:
"""Summarize text to max length."""
if not text:
return ""
text = str(text).replace("\n", " ").strip()
if len(text) > max_len:
return text[:max_len-3] + "..."
return text
def _run_tracked_task(
task: str,
tools: List,
max_iterations: int,
model: Optional[str],
verbose: bool,
step_callback: Optional[callable] = None,
trust: bool = True,
) -> TrackerResult:
"""Run a task with step tracking."""
from praisonaiagents import Agent
steps: List[TrackedStep] = []
tools_used: set = set()
start_time = time.time()
step_number = 0
# Create agent with tools - auto-approve for autonomous tracking
agent_kwargs = {
"name": "tracker_agent",
"instructions": """You are an autonomous agent with access to various tools.
You MUST use tools to complete tasks - do NOT rely on your training knowledge alone.
For research tasks: use search_web to find information.
For file tasks: use read_file, write_file, list_files.
For code tasks: use execute_code, analyze_code.
When you have fully completed the task, say 'Task completed' or 'Done'.""",
"tools": tools,
"autonomy": {
"max_iterations": max_iterations,
"doom_loop_threshold": 5,
"level": "full_auto" if trust else "suggest",
},
}
# Auto-approve tools for tracker (autonomous mode)
if trust:
import os
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
if model:
agent_kwargs["llm"] = model
if not verbose:
agent_kwargs["output"] = "silent"
agent = Agent(**agent_kwargs)
# Wrap execute_tool to track tool calls
original_execute_tool = agent.execute_tool
def tracked_execute_tool(tool_name, *args, **kwargs):
nonlocal step_number
step_number += 1
step_start = time.time()
tools_used.add(tool_name)
try:
result = original_execute_tool(tool_name, *args, **kwargs)
duration = time.time() - step_start
step = TrackedStep(
step_number=step_number,
timestamp=datetime.now().isoformat(),
action_type="tool_call",
action_name=tool_name,
input_summary=_summarize_text(str(args) + str(kwargs)),
output_summary=_summarize_text(str(result)),
duration_seconds=duration,
success=True,
)
steps.append(step)
if step_callback:
step_callback(step)
return result
except Exception as e:
duration = time.time() - step_start
step = TrackedStep(
step_number=step_number,
timestamp=datetime.now().isoformat(),
action_type="tool_error",
action_name=tool_name,
input_summary=_summarize_text(str(args) + str(kwargs)),
output_summary="",
duration_seconds=duration,
success=False,
error=str(e),
)
steps.append(step)
if step_callback:
step_callback(step)
raise
agent.execute_tool = tracked_execute_tool
# Track chat calls
original_chat = agent.chat
def tracked_chat(prompt, *args, **kwargs):
nonlocal step_number
step_number += 1
step_start = time.time()
try:
result = original_chat(prompt, *args, **kwargs)
duration = time.time() - step_start
# Record step
step = TrackedStep(
step_number=step_number,
timestamp=datetime.now().isoformat(),
action_type="chat",
action_name="thinking",
input_summary=_summarize_text(prompt),
output_summary=_summarize_text(str(result)),
duration_seconds=duration,
success=True,
)
steps.append(step)
if step_callback:
step_callback(step)
return result
except Exception as e:
duration = time.time() - step_start
step = TrackedStep(
step_number=step_number,
timestamp=datetime.now().isoformat(),
action_type="error",
action_name="chat_error",
input_summary=_summarize_text(prompt),
output_summary="",
duration_seconds=duration,
success=False,
error=str(e),
)
steps.append(step)
if step_callback:
step_callback(step)
raise
agent.chat = tracked_chat
# Run autonomous loop
try:
result = agent.run_autonomous(
prompt=task,
max_iterations=max_iterations,
)
total_duration = time.time() - start_time
# Extract tool usage from AutonomyResult.actions
if hasattr(result, 'actions') and result.actions:
for action in result.actions:
if isinstance(action, dict):
action_type = action.get('type', 'unknown')
action_name = action.get('name', action.get('tool', 'unknown'))
if action_type == 'tool_call' or 'tool' in action:
tools_used.add(action_name)
# Analyze gaps
gaps = []
if result.iterations >= max_iterations:
gaps.append("Hit max iterations - task may be incomplete")
if result.completion_reason == "doom_loop":
gaps.append("Doom loop detected - agent repeated same actions")
if result.completion_reason == "needs_help":
gaps.append("Agent requested human help")
if not tools_used and len(tools) > 0:
gaps.append("No tools were used despite being available")
return TrackerResult(
task=task,
success=result.success,
completion_reason=result.completion_reason,
total_steps=len(steps) if steps else result.iterations,
total_duration=total_duration,
steps=steps,
tools_used=list(tools_used),
gaps_identified=gaps,
)
except Exception as e:
total_duration = time.time() - start_time
return TrackerResult(
task=task,
success=False,
completion_reason="error",
total_steps=len(steps),
total_duration=total_duration,
steps=steps,
tools_used=list(tools_used),
gaps_identified=[f"Error: {str(e)}"],
)
def _print_step_table(steps: List[TrackedStep]) -> None:
"""Print a table of all steps."""
table = Table(title="📊 Execution Steps", show_header=True, header_style="bold cyan")
table.add_column("#", style="dim", width=4)
table.add_column("Type", width=10)
table.add_column("Action", width=15)
table.add_column("Input", width=30)
table.add_column("Output", width=30)
table.add_column("Time", width=8)
table.add_column("Status", width=8)
for step in steps:
status = "✅" if step.success else "❌"
table.add_row(
str(step.step_number),
step.action_type,
step.action_name,
step.input_summary,
step.output_summary,
f"{step.duration_seconds:.2f}s",
status,
)
console.print(table)
def _print_summary(result: TrackerResult) -> None:
"""Print execution summary."""
status_icon = "✅" if result.success else "❌"
console.print(Panel(
f"""
[bold]{status_icon} Task: {_summarize_text(result.task, 60)}[/bold]
[cyan]Completion Reason:[/cyan] {result.completion_reason}
[cyan]Total Steps:[/cyan] {result.total_steps}
[cyan]Total Duration:[/cyan] {result.total_duration:.2f}s
[cyan]Tools Used:[/cyan] {', '.join(result.tools_used) if result.tools_used else 'None'}
""",
title="📋 Execution Summary",
border_style="green" if result.success else "red",
))
if result.gaps_identified:
console.print("\n[bold yellow]⚠️ Gaps Identified:[/bold yellow]")
for gap in result.gaps_identified:
console.print(f" • {gap}")
@app.callback(invoke_without_command=True)
def tracker_main(
ctx: typer.Context,
task: Optional[str] = typer.Argument(None, help="Task for the agent to complete"),
max_iterations: int = typer.Option(20, "--max-iterations", "-n", help="Maximum iterations (default: 20)"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool names to use"),
extended: bool = typer.Option(False, "--extended", "-e", help="Include extended tools (may require API keys)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show verbose output"),
live: bool = typer.Option(True, "--live/--no-live", help="Show live step updates (default: on)"),
):
"""Run an agent with step-by-step tracking.
The tracker runs an agent in autonomous mode and records every step,
tool call, and decision. At the end, it displays a summary table
showing all steps taken and any gaps identified.
Examples:
praisonai tracker "Search for Python best practices and summarize"
praisonai tracker "Read config.yaml and explain its structure" -v
praisonai tracker "Find trending AI news" --tools search_web,web_crawl
"""
if ctx.invoked_subcommand is not None:
return
if not task:
typer.echo(ctx.get_help())
return
# Resolve tools
tool_names = DEFAULT_TOOLS.copy()
if extended:
tool_names.extend(EXTENDED_TOOLS)
if tools:
# Override with user-specified tools
tool_names = [t.strip() for t in tools.split(",")]
resolved_tools = _get_tools(tool_names)
console.print(f"\n[bold cyan]🔍 Agent Tracker[/bold cyan]")
console.print(f"[dim]Task: {_summarize_text(task, 70)}[/dim]")
console.print(f"[dim]Tools: {len(resolved_tools)} loaded ({', '.join(tool_names[:5])}{'...' if len(tool_names) > 5 else ''})[/dim]")
console.print(f"[dim]Max iterations: {max_iterations}[/dim]\n")
# Step callback for live updates
def step_callback(step: TrackedStep):
if live:
status = "✅" if step.success else "❌"
console.print(f" [{step.step_number}] {status} {step.action_type}: {step.action_name} ({step.duration_seconds:.2f}s)")
# Run tracked task
with console.status("[bold green]Running autonomous agent...[/bold green]") if not live else console:
result = _run_tracked_task(
task=task,
tools=resolved_tools,
max_iterations=max_iterations,
model=model,
verbose=verbose,
step_callback=step_callback if live else None,
)
# Print results
console.print("\n")
_print_step_table(result.steps)
console.print("\n")
_print_summary(result)
@app.command(name="batch")
def tracker_batch(
tasks_file: str = typer.Argument(..., help="JSON file with list of tasks"),
max_iterations: int = typer.Option(20, "--max-iterations", "-n", help="Maximum iterations per task"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output JSON file for results"),
):
"""Run multiple tasks and generate a comparison table.
The tasks file should be a JSON array of task strings:
["Task 1", "Task 2", "Task 3"]
Example:
praisonai tracker batch tasks.json -o results.json
"""
import json
try:
with open(tasks_file) as f:
tasks = json.load(f)
except Exception as e:
console.print(f"[red]Error reading tasks file: {e}[/red]")
raise typer.Exit(1)
if not isinstance(tasks, list):
console.print("[red]Tasks file must contain a JSON array of strings[/red]")
raise typer.Exit(1)
console.print(f"\n[bold cyan]🔍 Batch Agent Tracker[/bold cyan]")
console.print(f"[dim]Running {len(tasks)} tasks...[/dim]\n")
resolved_tools = _get_tools(DEFAULT_TOOLS)
results: List[TrackerResult] = []
for i, task in enumerate(tasks, 1):
console.print(f"\n[bold]Task {i}/{len(tasks)}:[/bold] {_summarize_text(task, 50)}")
result = _run_tracked_task(
task=task,
tools=resolved_tools,
max_iterations=max_iterations,
model=model,
verbose=False,
step_callback=None,
)
results.append(result)
status = "✅" if result.success else "❌"
console.print(f" {status} {result.completion_reason} ({result.total_steps} steps, {result.total_duration:.2f}s)")
# Print summary table
console.print("\n")
table = Table(title="📊 Batch Results Summary", show_header=True, header_style="bold cyan")
table.add_column("#", style="dim", width=4)
table.add_column("Task", width=40)
table.add_column("Status", width=8)
table.add_column("Reason", width=15)
table.add_column("Steps", width=6)
table.add_column("Duration", width=10)
table.add_column("Gaps", width=20)
for i, result in enumerate(results, 1):
status = "✅" if result.success else "❌"
gaps = ", ".join(result.gaps_identified[:2]) if result.gaps_identified else "-"
table.add_row(
str(i),
_summarize_text(result.task, 40),
status,
result.completion_reason,
str(result.total_steps),
f"{result.total_duration:.2f}s",
_summarize_text(gaps, 20),
)
console.print(table)
# Calculate stats
success_count = sum(1 for r in results if r.success)
total_steps = sum(r.total_steps for r in results)
total_duration = sum(r.total_duration for r in results)
console.print(f"\n[bold]Summary:[/bold]")
console.print(f" Success rate: {success_count}/{len(results)} ({100*success_count/len(results):.1f}%)")
console.print(f" Total steps: {total_steps}")
console.print(f" Total duration: {total_duration:.2f}s")
# Save results if output specified
if output:
output_data = {
"tasks": len(results),
"success_rate": success_count / len(results),
"total_steps": total_steps,
"total_duration": total_duration,
"results": [
{
"task": r.task,
"success": r.success,
"completion_reason": r.completion_reason,
"steps": r.total_steps,
"duration": r.total_duration,
"gaps": r.gaps_identified,
}
for r in results
],
}
with open(output, "w") as f:
json.dump(output_data, f, indent=2)
console.print(f"\n[green]Results saved to {output}[/green]")
@app.command(name="tools")
def tracker_tools():
"""List all available tools for the tracker."""
console.print("\n[bold cyan]🛠️ Available Tools[/bold cyan]\n")
console.print("[bold]Default Tools (no API keys required):[/bold]")
for tool in DEFAULT_TOOLS:
console.print(f" • {tool}")
console.print("\n[bold]Extended Tools (may require API keys):[/bold]")
for tool in EXTENDED_TOOLS:
console.print(f" • {tool}")
console.print("\n[dim]Use --tools to specify custom tools, e.g.:[/dim]")
console.print("[dim] praisonai tracker 'task' --tools search_web,read_file[/dim]")
@@ -0,0 +1,139 @@
"""
Approval backend resolver for PraisonAI CLI.
Maps CLI --approval flag values to approval backend instances.
Used by run, chat, and main CLI commands for DRY approval wiring.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Valid backend names for CLI help text
VALID_BACKENDS = ["console", "slack", "telegram", "discord", "webhook", "http", "agent", "auto", "none"]
def resolve_approval_backend(value: Optional[str]) -> Optional[Any]:
"""Resolve a CLI --approval flag value to an approval backend instance.
Args:
value: One of the backend names (slack, telegram, discord, webhook,
http, console, auto, none) or None. For webhook and http,
additional config is read from environment variables.
Returns:
An approval backend instance, or None if disabled.
Raises:
ValueError: If the value is not a recognised backend name.
"""
if value is None or value.lower() == "none":
return None
name = value.lower().strip()
if name in ("true", "console"):
from praisonaiagents.approval.backends import ConsoleBackend
return ConsoleBackend()
if name == "auto":
from praisonaiagents.approval.backends import AutoApproveBackend
return AutoApproveBackend()
if name == "slack":
from praisonai.bots import SlackApproval
return SlackApproval()
if name == "telegram":
from praisonai.bots import TelegramApproval
import os
chat_id = os.environ.get("TELEGRAM_CHAT_ID", "")
if not chat_id:
raise ValueError(
"TELEGRAM_CHAT_ID env var required for --approval telegram"
)
return TelegramApproval(chat_id=chat_id)
if name == "discord":
from praisonai.bots import DiscordApproval
import os
channel_id = os.environ.get("DISCORD_CHANNEL_ID", "")
if not channel_id:
raise ValueError(
"DISCORD_CHANNEL_ID env var required for --approval discord"
)
return DiscordApproval(channel_id=channel_id)
if name == "webhook":
from praisonai.bots import WebhookApproval
return WebhookApproval()
if name == "http":
from praisonai.bots import HTTPApproval
return HTTPApproval()
if name == "agent":
from praisonaiagents.approval import AgentApproval
from praisonaiagents import Agent
reviewer = Agent(
name="approval-reviewer",
instructions=(
"You are a security reviewer. Only approve low-risk read "
"operations. Deny anything destructive. Respond with exactly "
"one word: APPROVE or DENY"
),
)
return AgentApproval(approver_agent=reviewer)
raise ValueError(
f"Unknown approval backend: {value!r}. "
f"Valid options: {', '.join(VALID_BACKENDS)}"
)
def resolve_approval_config(
backend_name: Optional[str] = None,
all_tools: bool = False,
timeout: Optional[str] = None,
) -> Optional[Any]:
"""Build an approval value for ``Agent(approval=...)``.
If only a backend name is given (no extra flags), returns the plain
backend object for backward compatibility. When ``all_tools`` or
``timeout`` are specified, returns an :class:`ApprovalConfig`.
Args:
backend_name: CLI ``--approval`` value (slack, console, …).
all_tools: ``--approve-all-tools`` flag.
timeout: ``--approval-timeout`` value. ``"none"`` means
indefinite; a numeric string is parsed as seconds.
"""
backend = resolve_approval_backend(backend_name)
if backend is None:
return None
# Parse timeout
parsed_timeout: Optional[float] = 0 # 0 = use backend default
if timeout is not None:
if timeout.lower() == "none":
parsed_timeout = None # indefinite
else:
try:
parsed_timeout = float(timeout)
except ValueError:
logger.warning(f"Invalid --approval-timeout value: {timeout!r}, using backend default")
parsed_timeout = 0
# If no extra config needed, return plain backend (backward compatible)
if not all_tools and parsed_timeout == 0:
return backend
from praisonaiagents.approval.protocols import ApprovalConfig
return ApprovalConfig(
backend=backend,
all_tools=all_tools,
timeout=parsed_timeout,
)
@@ -18,18 +18,31 @@ import logging
logger = logging.getLogger(__name__)
# DRY: Reuse AutonomyLevel from core SDK as single source of truth
try:
from praisonaiagents.config.feature_configs import AutonomyLevel as _AutonomyLevel
except ImportError:
# Fallback if SDK not installed — define locally
class _AutonomyLevel(Enum):
SUGGEST = "suggest"
AUTO_EDIT = "auto_edit"
FULL_AUTO = "full_auto"
class AutonomyMode(Enum):
"""
Autonomy levels for agent execution.
Inspired by Codex CLI's approval modes:
Values are identical to praisonaiagents.config.feature_configs.AutonomyLevel.
This wrapper adds from_string() for CLI convenience.
- SUGGEST: Read-only, requires approval for all changes
- AUTO_EDIT: Auto-approve file edits, require approval for commands
- FULL_AUTO: Auto-approve everything (YOLO mode)
"""
SUGGEST = "suggest" # Default: ask for approval on everything
AUTO_EDIT = "auto_edit" # Auto-approve file edits only
FULL_AUTO = "full_auto" # Auto-approve everything (dangerous)
SUGGEST = _AutonomyLevel.SUGGEST.value
AUTO_EDIT = _AutonomyLevel.AUTO_EDIT.value
FULL_AUTO = _AutonomyLevel.FULL_AUTO.value
@classmethod
def from_string(cls, value: str) -> "AutonomyMode":
@@ -39,6 +52,10 @@ class AutonomyMode(Enum):
if mode.value == value:
return mode
raise ValueError(f"Unknown autonomy mode: {value}")
def to_sdk_level(self) -> "_AutonomyLevel":
"""Convert to SDK AutonomyLevel for bridging."""
return _AutonomyLevel(self.value)
class ActionType(Enum):
@@ -204,14 +221,53 @@ class AutonomyManager:
self._remembered_approvals: Dict[str, bool] = {}
def set_mode(self, mode: AutonomyMode) -> None:
"""Change the autonomy mode."""
"""Change the autonomy mode and bridge to SDK approval system."""
old_mode = self.mode
self.mode = mode
self.policy = AutonomyPolicy.for_mode(mode)
# Bridge: sync CLI autonomy mode to SDK approval env var
self._bridge_to_sdk_approval(mode)
if self.verbose:
logger.info(f"Autonomy mode changed: {old_mode.value} -> {mode.value}")
@staticmethod
def _bridge_to_sdk_approval(mode: AutonomyMode) -> None:
"""Bridge CLI autonomy mode to SDK ApprovalRegistry via env var.
When FULL_AUTO, set PRAISONAI_AUTO_APPROVE=true so the SDK's
ApprovalRegistry.is_env_auto_approve() returns True.
Otherwise, remove the env var to restore normal approval flow.
Note: For multi-agent isolation, prefer get_autonomy_config() which
returns a per-agent config dict instead of global env vars.
"""
import os
if mode == AutonomyMode.FULL_AUTO:
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
else:
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
def get_autonomy_config(self) -> Dict[str, Any]:
"""Get SDK-compatible autonomy config dict for Agent constructor.
Maps CLI AutonomyMode to SDK AutonomyConfig level, enabling
per-agent approval without global env vars (G-BRIDGE-CLI fix).
Returns:
Dict suitable for Agent(autonomy={...})
"""
mode_to_level = {
AutonomyMode.SUGGEST: "suggest",
AutonomyMode.AUTO_EDIT: "auto_edit",
AutonomyMode.FULL_AUTO: "full_auto",
}
return {
"enabled": True,
"level": mode_to_level.get(self.mode, "suggest"),
}
def request_approval(self, action: ActionRequest) -> ApprovalResult:
"""
Request approval for an action.
@@ -76,7 +76,7 @@ class AsyncTUIConfig:
history_file: Optional[str] = None
compact_mode: bool = False
multiline_mode: bool = False
autonomy_mode: bool = False # Enable autonomous task delegation
autonomy_mode: bool = True # Enable autonomous task delegation (aligned with InteractiveConfig)
debug: bool = False # Enable debug logging to file (~/.praisonai/async_tui_debug.log)
+26 -23
View File
@@ -300,30 +300,33 @@ class InteractiveCore:
session = self.session_store.load(session_id)
history = session.get_chat_history(max_messages=50) if session else []
# Build agent config
agent_config = {
"name": "InteractiveAgent",
"role": "Assistant",
"goal": "Help the user with their request",
"backstory": "You are a helpful AI assistant",
"tools": self.get_tools(),
}
# Reuse agent instance across prompts to preserve autonomy state
# (doom loop tracker, action history) and avoid per-prompt overhead
if not hasattr(self, '_agent') or self._agent is None:
agent_config = {
"name": "InteractiveAgent",
"role": "Assistant",
"goal": "Help the user with their request",
"backstory": "You are a helpful AI assistant",
"tools": self.get_tools(),
}
if self.config.model:
agent_config["llm"] = {"model": self.config.model}
if self.config.memory:
agent_config["memory"] = True
# Enable autonomy for intelligent task handling (agent-centric)
if self.config.autonomy:
if self.config.autonomy_config:
agent_config["autonomy"] = self.config.autonomy_config
else:
agent_config["autonomy"] = True
self._agent = Agent(**agent_config)
if self.config.model:
agent_config["llm"] = {"model": self.config.model}
if self.config.memory:
agent_config["memory"] = True
# Enable autonomy for intelligent task handling (agent-centric)
if self.config.autonomy:
if self.config.autonomy_config:
agent_config["autonomy"] = self.config.autonomy_config
else:
agent_config["autonomy"] = True
# Create agent and execute
agent = Agent(**agent_config)
agent = self._agent
# Add history context if available
context = ""
+23
View File
@@ -945,6 +945,12 @@ class PraisonAI:
parser.add_argument("--trust", action="store_true", help="Auto-approve all tool executions (skip approval prompts)")
parser.add_argument("--approve-level", type=str, choices=["low", "medium", "high", "critical"],
help="Auto-approve tools up to this risk level (e.g., --approve-level high approves low/medium/high but prompts for critical)")
parser.add_argument("--approval", type=str,
help="Approval backend: console, slack, telegram, discord, webhook, http, agent, auto, none")
parser.add_argument("--approve-all-tools", action="store_true",
help="Require approval for ALL tool calls, not just dangerous tools (default: dangerous only)")
parser.add_argument("--approval-timeout", type=str, default=None,
help="Seconds to wait for approval response. Use 'none' for indefinite wait (default: backend default)")
# Sandbox Execution - secure command execution
parser.add_argument("--sandbox", type=str, choices=["off", "basic", "strict"], help="Enable sandboxed command execution")
@@ -3987,6 +3993,23 @@ Provide ONLY the commit message, no explanations."""
set_approval_callback(level_based_approve)
print(f"[bold cyan]Auto-approve enabled for tools up to '{max_level}' risk level[/bold cyan]")
# Approval Backend - Set agent-level approval via --approval flag
approval_flag = getattr(self.args, 'approval', None)
if approval_flag:
from .features.approval import resolve_approval_config
try:
approval_val = resolve_approval_config(
approval_flag,
all_tools=getattr(self.args, 'approve_all_tools', False),
timeout=getattr(self.args, 'approval_timeout', None),
)
if approval_val is not None:
agent_config["approval"] = approval_val
print(f"[bold cyan]Approval backend: {approval_flag}[/bold cyan]")
except ValueError as e:
print(f"[red]ERROR: {e}[/red]")
sys.exit(1)
# Router - Smart model selection (must be before agent creation)
if getattr(self.args, 'router', False):
from .features.router import RouterHandler
+1 -1
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.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.7 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.5.2"
__version__ = "4.5.7"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.5.2",
"praisonaiagents>=1.5.7",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
@@ -0,0 +1,346 @@
"""
Integration tests for the tracker CLI with 10 complex multi-step tasks.
These tests run real agents with real tools to verify:
1. Autonomous mode execution
2. Step tracking and status output
3. Tool usage patterns
4. Gap identification
"""
import pytest
import json
import os
from typing import List, Dict, Any
from dataclasses import dataclass
from datetime import datetime
# Skip if no API key
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY required for integration tests"
)
@dataclass
class TaskResult:
"""Result from running a complex task."""
task_id: int
task_description: str
success: bool
completion_reason: str
steps_taken: int
tools_used: List[str]
duration_seconds: float
gaps_identified: List[str]
step_details: List[Dict[str, Any]]
# 10 Complex Multi-Step Tasks
COMPLEX_TASKS = [
{
"id": 1,
"description": "Research the top 3 Python web frameworks, compare their features, and write a summary to a file",
"expected_tools": ["search_web", "write_file"],
"min_steps": 3,
},
{
"id": 2,
"description": "Find the current weather API documentation, extract the endpoint structure, and create a Python code snippet to call it",
"expected_tools": ["search_web", "web_crawl", "execute_code"],
"min_steps": 4,
},
{
"id": 3,
"description": "Search for best practices in REST API design, summarize the key points, and save them to api_best_practices.txt",
"expected_tools": ["search_web", "write_file"],
"min_steps": 3,
},
{
"id": 4,
"description": "Find information about Docker containerization, create a simple Dockerfile example, and explain each line",
"expected_tools": ["search_web", "write_file"],
"min_steps": 3,
},
{
"id": 5,
"description": "Research machine learning model deployment strategies, compare cloud vs edge deployment, and create a decision matrix",
"expected_tools": ["search_web", "write_file"],
"min_steps": 4,
},
{
"id": 6,
"description": "Search for the latest AI agent frameworks, compare PraisonAI with alternatives, and write a comparison report",
"expected_tools": ["search_web", "write_file"],
"min_steps": 4,
},
{
"id": 7,
"description": "Find Python testing best practices, create a sample pytest test file, and explain the testing patterns used",
"expected_tools": ["search_web", "write_file", "execute_code"],
"min_steps": 4,
},
{
"id": 8,
"description": "Research async programming in Python, find examples of asyncio patterns, and create a working async code example",
"expected_tools": ["search_web", "execute_code"],
"min_steps": 3,
},
{
"id": 9,
"description": "Search for database optimization techniques, summarize indexing strategies, and create a SQL example demonstrating indexes",
"expected_tools": ["search_web", "write_file"],
"min_steps": 3,
},
{
"id": 10,
"description": "Find information about CI/CD pipelines, compare GitHub Actions vs GitLab CI, and create a sample workflow file",
"expected_tools": ["search_web", "write_file"],
"min_steps": 4,
},
]
def run_single_task(task: Dict[str, Any], max_iterations: int = 15) -> TaskResult:
"""Run a single complex task and return the result."""
from praisonai.cli.commands.tracker import (
_run_tracked_task,
_get_tools,
DEFAULT_TOOLS,
)
tools = _get_tools(DEFAULT_TOOLS)
result = _run_tracked_task(
task=task["description"],
tools=tools,
max_iterations=max_iterations,
model=None, # Use default
verbose=False,
step_callback=None,
)
return TaskResult(
task_id=task["id"],
task_description=task["description"],
success=result.success,
completion_reason=result.completion_reason,
steps_taken=result.total_steps,
tools_used=result.tools_used,
duration_seconds=result.total_duration,
gaps_identified=result.gaps_identified,
step_details=[
{
"step": s.step_number,
"type": s.action_type,
"action": s.action_name,
"success": s.success,
"duration": s.duration_seconds,
}
for s in result.steps
],
)
class TestTrackerComplexTasks:
"""Integration tests for complex multi-step tasks."""
@pytest.fixture(autouse=True)
def setup(self, tmp_path):
"""Setup test environment."""
self.original_cwd = os.getcwd()
os.chdir(tmp_path)
yield
os.chdir(self.original_cwd)
@pytest.mark.timeout(120)
def test_task_1_web_framework_research(self):
"""Task 1: Research Python web frameworks."""
task = COMPLEX_TASKS[0]
result = run_single_task(task)
assert result.steps_taken >= 1, "Should take at least 1 step"
# Record result for analysis
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
print(f" Tools: {result.tools_used}")
print(f" Gaps: {result.gaps_identified}")
@pytest.mark.timeout(120)
def test_task_2_weather_api(self):
"""Task 2: Weather API documentation."""
task = COMPLEX_TASKS[1]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
@pytest.mark.timeout(120)
def test_task_3_rest_api_best_practices(self):
"""Task 3: REST API best practices."""
task = COMPLEX_TASKS[2]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
@pytest.mark.timeout(120)
def test_task_4_docker_containerization(self):
"""Task 4: Docker containerization."""
task = COMPLEX_TASKS[3]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
@pytest.mark.timeout(120)
def test_task_5_ml_deployment(self):
"""Task 5: ML deployment strategies."""
task = COMPLEX_TASKS[4]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
@pytest.mark.timeout(120)
def test_task_6_ai_agent_comparison(self):
"""Task 6: AI agent framework comparison."""
task = COMPLEX_TASKS[5]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
@pytest.mark.timeout(120)
def test_task_7_python_testing(self):
"""Task 7: Python testing best practices."""
task = COMPLEX_TASKS[6]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
@pytest.mark.timeout(120)
def test_task_8_async_programming(self):
"""Task 8: Async programming in Python."""
task = COMPLEX_TASKS[7]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
@pytest.mark.timeout(120)
def test_task_9_database_optimization(self):
"""Task 9: Database optimization."""
task = COMPLEX_TASKS[8]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
@pytest.mark.timeout(120)
def test_task_10_cicd_pipelines(self):
"""Task 10: CI/CD pipelines."""
task = COMPLEX_TASKS[9]
result = run_single_task(task)
assert result.steps_taken >= 1
print(f"\nTask {task['id']}: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
def run_all_tasks_and_generate_report():
"""Run all 10 tasks and generate a summary report."""
import tempfile
import os
results: List[TaskResult] = []
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
for task in COMPLEX_TASKS:
print(f"\n{'='*60}")
print(f"Running Task {task['id']}: {task['description'][:50]}...")
print('='*60)
try:
result = run_single_task(task, max_iterations=15)
results.append(result)
status = "✅" if result.success else "❌"
print(f"{status} Completed: {result.completion_reason}")
print(f" Steps: {result.steps_taken}, Duration: {result.duration_seconds:.2f}s")
print(f" Tools: {result.tools_used}")
if result.gaps_identified:
print(f" Gaps: {result.gaps_identified}")
except Exception as e:
print(f"❌ Error: {e}")
results.append(TaskResult(
task_id=task["id"],
task_description=task["description"],
success=False,
completion_reason="error",
steps_taken=0,
tools_used=[],
duration_seconds=0,
gaps_identified=[str(e)],
step_details=[],
))
# Generate summary table
print("\n" + "="*80)
print("SUMMARY TABLE: 10 Complex Task Results")
print("="*80)
print(f"{'#':<3} {'Task':<40} {'Status':<8} {'Steps':<6} {'Duration':<10} {'Gaps'}")
print("-"*80)
for r in results:
status = "✅" if r.success else "❌"
task_short = r.task_description[:38] + ".." if len(r.task_description) > 40 else r.task_description
gaps = ", ".join(r.gaps_identified[:2]) if r.gaps_identified else "-"
print(f"{r.task_id:<3} {task_short:<40} {status:<8} {r.steps_taken:<6} {r.duration_seconds:<10.2f} {gaps[:20]}")
print("-"*80)
# Statistics
success_count = sum(1 for r in results if r.success)
total_steps = sum(r.steps_taken for r in results)
total_duration = sum(r.duration_seconds for r in results)
print(f"\nSuccess Rate: {success_count}/{len(results)} ({100*success_count/len(results):.1f}%)")
print(f"Total Steps: {total_steps}")
print(f"Total Duration: {total_duration:.2f}s")
print(f"Avg Steps per Task: {total_steps/len(results):.1f}")
print(f"Avg Duration per Task: {total_duration/len(results):.2f}s")
# Gap analysis
all_gaps = []
for r in results:
all_gaps.extend(r.gaps_identified)
if all_gaps:
print("\n" + "="*80)
print("GAP ANALYSIS")
print("="*80)
gap_counts = {}
for gap in all_gaps:
gap_counts[gap] = gap_counts.get(gap, 0) + 1
for gap, count in sorted(gap_counts.items(), key=lambda x: -x[1]):
print(f" [{count}x] {gap}")
return results
if __name__ == "__main__":
run_all_tasks_and_generate_report()
+4 -4
View File
@@ -4016,7 +4016,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.2" },
{ name = "praisonaiagents", specifier = ">=1.5.7" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pyjwt", marker = "extra == 'os'", specifier = ">=2.8.0" },
{ name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" },
@@ -4074,7 +4074,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.2"
version = "1.5.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4083,9 +4083,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/1a2f31d403d122eed660d0eb672f6867560f4922cb08320a56e9fa9ebfa8/praisonaiagents-1.5.2.tar.gz", hash = "sha256:47023e4a47099972c8e52d6effc1731add07a50dc6fdf33f7d13584a7580c04e", size = 1202584, upload-time = "2026-02-14T00:05:51.824Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/e1/bf4a5ee72c1cf7d0bc30c4984f0a23319bba685c49ca96be7d75b214c517/praisonaiagents-1.5.7.tar.gz", hash = "sha256:f9599f8e530610e6545aa784c79e2cb0c5f282fe8585e7130fddc14350b35a1b", size = 1204864, upload-time = "2026-02-16T06:08:51.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/fa/dac2e479897a41f9706f84efc1df7687f8ef9dee6582a5298346cd0dbaf3/praisonaiagents-1.5.2-py3-none-any.whl", hash = "sha256:4e4ee5dc268727850f85fece9749e300d3af421dbc07900a8310f95f5d4ee245", size = 1201020, upload-time = "2026-02-14T00:05:49.544Z" },
{ url = "https://files.pythonhosted.org/packages/6e/9a/a4b2f30d0f46f0136d9a862fc3a07c1402cd486add3c98e6ed36bd6b4c39/praisonaiagents-1.5.7-py3-none-any.whl", hash = "sha256:c81483bef9d573f8e5f84b67441f07615a7973e2880e97518954f06ac6d31c57", size = 1203482, upload-time = "2026-02-16T06:08:49.221Z" },
]
[[package]]