mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 14:38:40 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91aaa9a310 | ||
|
|
2b54eb571e | ||
|
|
be167f885e | ||
|
|
8c4e339aa6 | ||
|
|
841c51df84 | ||
|
|
6709ae17e9 |
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Generated
+1
-1
@@ -2549,7 +2549,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.2"
|
||||
version = "1.5.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "4.5.2"
|
||||
__version__ = "4.5.7"
|
||||
@@ -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()
|
||||
Generated
+4
-4
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user