55This module integrates escalation, doom loop detection, and observability
66directly into the Agent class as first-class capabilities.
77
8+ Unified Architecture:
9+ - AutonomyStage is an alias for EscalationStage (single source of truth)
10+ - AutonomyTrigger delegates to EscalationTrigger (DRY)
11+ - DoomLoopTracker adds recovery actions on top of basic detection
12+
813Usage:
914 from praisonaiagents import Agent
1015
2732"""
2833
2934from dataclasses import dataclass , field
30- from typing import Optional , Dict , Any , List , Set , Callable , Protocol , runtime_checkable
35+ from typing import Optional , Dict , Any , List , Set
3136from enum import Enum
3237import logging
3338
3439logger = logging .getLogger (__name__ )
3540
41+ # ============================================================================
42+ # Unified Stage Enum (G-DUP-1 fix: single source of truth)
43+ # ============================================================================
44+ # AutonomyStage is an alias for EscalationStage so both systems share
45+ # the same IntEnum. Backward-compatible: AutonomyStage.DIRECT == EscalationStage.DIRECT.
46+ from ..escalation .types import EscalationStage as AutonomyStage # noqa: F401
47+
48+ # Valid autonomy levels for AutonomyConfig.level
49+ VALID_AUTONOMY_LEVELS = {"suggest" , "auto_edit" , "full_auto" }
3650
37- class AutonomyStage (str , Enum ):
38- """Autonomy execution stages."""
39- DIRECT = "direct"
40- HEURISTIC = "heuristic"
41- PLANNED = "planned"
42- AUTONOMOUS = "autonomous"
51+ # Signal name mapping: EscalationSignal.value → AutonomyTrigger string
52+ # Keeps backward compat for existing code that checks string signal names.
53+ _ESCALATION_TO_AUTONOMY_SIGNAL = {
54+ "simple_question" : "simple_question" ,
55+ "file_references" : "file_references" ,
56+ "code_blocks" : "code_blocks" ,
57+ "edit_intent" : "edit_intent" ,
58+ "test_intent" : "test_intent" ,
59+ "refactor_intent" : "refactor_intent" ,
60+ "multi_step_intent" : "multi_step" ,
61+ "complex_keywords" : "complex_keywords" ,
62+ "long_prompt" : "long_prompt" ,
63+ "repo_context" : "repo_context" ,
64+ "build_intent" : "build_intent" ,
65+ "tool_failure" : "tool_failure" ,
66+ "ambiguous_result" : "ambiguous_result" ,
67+ "incomplete_task" : "incomplete_task" ,
68+ "clarification" : "clarification" ,
69+ "acknowledgment" : "acknowledgment" ,
70+ }
71+
72+ # Reverse map for converting autonomy signal strings back to EscalationSignal values
73+ _AUTONOMY_TO_ESCALATION_SIGNAL = {v : k for k , v in _ESCALATION_TO_AUTONOMY_SIGNAL .items ()}
4374
4475
4576@dataclass
@@ -48,6 +79,7 @@ class AutonomyConfig:
4879
4980 Attributes:
5081 enabled: Whether autonomy is enabled
82+ level: Autonomy level (suggest, auto_edit, full_auto)
5183 max_iterations: Maximum iterations before stopping
5284 doom_loop_threshold: Number of repeated actions to trigger doom loop
5385 auto_escalate: Whether to automatically escalate complexity
@@ -66,12 +98,25 @@ class AutonomyConfig:
6698 clear_context : bool = False
6799 verification_hooks : Optional [List [Any ]] = None
68100
101+ def __post_init__ (self ):
102+ if self .level not in VALID_AUTONOMY_LEVELS :
103+ raise ValueError (
104+ f"Invalid autonomy level: { self .level !r} . "
105+ f"Must be one of { sorted (VALID_AUTONOMY_LEVELS )} "
106+ )
107+
69108 @classmethod
70109 def from_dict (cls , data : Dict [str , Any ]) -> "AutonomyConfig" :
71110 """Create config from dictionary."""
111+ level = data .get ("level" , "suggest" )
112+ if level not in VALID_AUTONOMY_LEVELS :
113+ raise ValueError (
114+ f"Invalid autonomy level: { level !r} . "
115+ f"Must be one of { sorted (VALID_AUTONOMY_LEVELS )} "
116+ )
72117 return cls (
73118 enabled = data .get ("enabled" , True ),
74- level = data . get ( " level" , "suggest" ) ,
119+ level = level ,
75120 max_iterations = data .get ("max_iterations" , 20 ),
76121 doom_loop_threshold = data .get ("doom_loop_threshold" , 3 ),
77122 auto_escalate = data .get ("auto_escalate" , True ),
@@ -97,45 +142,13 @@ class AutonomySignal(str, Enum):
97142class AutonomyTrigger :
98143 """Detects signals from prompts for autonomy decisions.
99144
100- Uses fast heuristics (no LLM calls) to analyze prompts.
145+ Delegates to EscalationTrigger (DRY, G-DUP-3 fix) and maps
146+ signal names for backward compatibility.
101147 """
102148
103- # Keywords that indicate simple questions
104- SIMPLE_KEYWORDS = {
105- "what is" , "what's" , "define" , "explain" , "describe" ,
106- "how does" , "why is" , "when was" , "who is" , "where is"
107- }
108-
109- # Keywords that indicate complex tasks
110- COMPLEX_KEYWORDS = {
111- "analyze" , "refactor" , "optimize" , "implement" , "design" ,
112- "architect" , "debug" , "fix" , "modify" , "update" , "change" ,
113- "create" , "build" , "develop" , "integrate" , "migrate"
114- }
115-
116- # Keywords that indicate edit intent
117- EDIT_KEYWORDS = {
118- "edit" , "modify" , "change" , "update" , "fix" , "add" , "remove" ,
119- "delete" , "replace" , "insert" , "write" , "rewrite"
120- }
121-
122- # Keywords that indicate test intent
123- TEST_KEYWORDS = {
124- "test" , "verify" , "validate" , "check" , "assert" , "ensure" ,
125- "unit test" , "integration test" , "e2e" , "coverage"
126- }
127-
128- # Keywords that indicate refactor intent
129- REFACTOR_KEYWORDS = {
130- "refactor" , "restructure" , "reorganize" , "clean up" ,
131- "simplify" , "extract" , "inline" , "rename"
132- }
133-
134- # Multi-step indicators
135- MULTI_STEP_INDICATORS = {
136- "first" , "then" , "next" , "after" , "finally" , "step" ,
137- "1." , "2." , "3." , "and then" , "followed by"
138- }
149+ def __init__ (self ):
150+ from ..escalation .triggers import EscalationTrigger
151+ self ._delegate = EscalationTrigger ()
139152
140153 def analyze (self , prompt : str ) -> Set [str ]:
141154 """Analyze prompt and return detected signals.
@@ -146,46 +159,11 @@ def analyze(self, prompt: str) -> Set[str]:
146159 Returns:
147160 Set of signal names (lowercase strings)
148161 """
149- signals : Set [str ] = set ()
150- prompt_lower = prompt .lower ()
151-
152- # Check for simple questions
153- if any (kw in prompt_lower for kw in self .SIMPLE_KEYWORDS ):
154- word_count = len (prompt .split ())
155- if word_count < 30 :
156- signals .add ("simple_question" )
157-
158- # Check for file references
159- import re
160- file_pattern = r'[\w\-./]+\.(py|js|ts|tsx|jsx|java|go|rs|cpp|c|h|md|txt|json|yaml|yml|toml)'
161- if re .search (file_pattern , prompt ):
162- signals .add ("file_references" )
163-
164- # Check for code blocks
165- if "```" in prompt :
166- signals .add ("code_blocks" )
167-
168- # Check for edit intent
169- if any (kw in prompt_lower for kw in self .EDIT_KEYWORDS ):
170- signals .add ("edit_intent" )
171-
172- # Check for test intent
173- if any (kw in prompt_lower for kw in self .TEST_KEYWORDS ):
174- signals .add ("test_intent" )
175-
176- # Check for refactor intent
177- if any (kw in prompt_lower for kw in self .REFACTOR_KEYWORDS ):
178- signals .add ("refactor_intent" )
179-
180- # Check for multi-step
181- if any (ind in prompt_lower for ind in self .MULTI_STEP_INDICATORS ):
182- signals .add ("multi_step" )
183-
184- # Check for complex keywords
185- if any (kw in prompt_lower for kw in self .COMPLEX_KEYWORDS ):
186- signals .add ("complex_keywords" )
187-
188- return signals
162+ escalation_signals = self ._delegate .analyze (prompt )
163+ return {
164+ _ESCALATION_TO_AUTONOMY_SIGNAL .get (s .value , s .value )
165+ for s in escalation_signals
166+ }
189167
190168 def recommend_stage (self , signals : Set [str ]) -> AutonomyStage :
191169 """Recommend execution stage based on signals.
@@ -194,22 +172,17 @@ def recommend_stage(self, signals: Set[str]) -> AutonomyStage:
194172 signals: Set of detected signal names
195173
196174 Returns:
197- Recommended AutonomyStage
175+ Recommended AutonomyStage (alias for EscalationStage)
198176 """
199- # AUTONOMOUS: multi-step or refactor
200- if "multi_step" in signals or "refactor_intent" in signals :
201- return AutonomyStage .AUTONOMOUS
202-
203- # PLANNED: edit or test intent
204- if "edit_intent" in signals or "test_intent" in signals :
205- return AutonomyStage .PLANNED
206-
207- # HEURISTIC: file references or code blocks
208- if "file_references" in signals or "code_blocks" in signals :
209- return AutonomyStage .HEURISTIC
210-
211- # DIRECT: simple questions or no signals
212- return AutonomyStage .DIRECT
177+ from ..escalation .types import EscalationSignal
178+ esc_signals = set ()
179+ for s in signals :
180+ signal_value = _AUTONOMY_TO_ESCALATION_SIGNAL .get (s , s )
181+ try :
182+ esc_signals .add (EscalationSignal (signal_value ))
183+ except ValueError :
184+ pass
185+ return self ._delegate .recommend_stage (esc_signals )
213186
214187
215188@dataclass
@@ -239,10 +212,13 @@ class AutonomyResult:
239212
240213
241214class DoomLoopTracker :
242- """Tracks actions to detect doom loops.
215+ """Tracks actions to detect doom loops with recovery actions .
243216
244217 A doom loop occurs when the agent repeats the same action
245218 multiple times without making progress.
219+
220+ Enhanced (G-DUP-2 fix): adds get_recovery_action() for
221+ graduated recovery instead of immediate abort.
246222 """
247223
248224 def __init__ (self , threshold : int = 3 ):
@@ -254,6 +230,8 @@ def __init__(self, threshold: int = 3):
254230 self .threshold = threshold
255231 self .actions : List [str ] = []
256232 self .action_counts : Dict [str , int ] = {}
233+ self ._consecutive_failures : int = 0
234+ self ._recovery_attempts : int = 0
257235
258236 def record (self , action_type : str , args : Dict [str , Any ], result : Any , success : bool ) -> None :
259237 """Record an action.
@@ -268,6 +246,12 @@ def record(self, action_type: str, args: Dict[str, Any], result: Any, success: b
268246 sig = f"{ action_type } :{ hash (str (sorted (args .items ())))} "
269247 self .actions .append (sig )
270248 self .action_counts [sig ] = self .action_counts .get (sig , 0 ) + 1
249+
250+ # Track consecutive failures
251+ if not success :
252+ self ._consecutive_failures += 1
253+ else :
254+ self ._consecutive_failures = 0
271255
272256 def is_doom_loop (self ) -> bool :
273257 """Check if we're in a doom loop.
@@ -283,12 +267,42 @@ def is_doom_loop(self) -> bool:
283267 if count >= self .threshold :
284268 return True
285269
270+ # Check consecutive failures
271+ if self ._consecutive_failures >= self .threshold :
272+ return True
273+
286274 return False
287275
276+ def get_recovery_action (self ) -> str :
277+ """Get recommended recovery action when doom loop detected.
278+
279+ Returns graduated recovery actions:
280+ - "retry_different": Try a different approach (1st detection)
281+ - "escalate_model": Escalate to stronger model (2nd detection)
282+ - "request_help": Request human intervention (3rd detection)
283+ - "abort": Stop execution (4th+ detection)
284+ - "continue": No doom loop, continue normally
285+ """
286+ if not self .is_doom_loop ():
287+ return "continue"
288+
289+ self ._recovery_attempts += 1
290+
291+ if self ._recovery_attempts <= 1 :
292+ return "retry_different"
293+ elif self ._recovery_attempts <= 2 :
294+ return "escalate_model"
295+ elif self ._recovery_attempts <= 3 :
296+ return "request_help"
297+ else :
298+ return "abort"
299+
288300 def reset (self ) -> None :
289301 """Reset the tracker."""
290302 self .actions .clear ()
291303 self .action_counts .clear ()
304+ self ._consecutive_failures = 0
305+ self ._recovery_attempts = 0
292306
293307
294308class AutonomyMixin :
0 commit comments