@@ -2033,6 +2033,64 @@ def _reset_doom_loop(self) -> None:
20332033 if self ._doom_loop_tracker is not None :
20342034 self ._doom_loop_tracker .reset ()
20352035
2036+ @staticmethod
2037+ def _is_completion_signal (response_text : str ) -> bool :
2038+ """Check if response contains a completion signal using word boundaries.
2039+
2040+ Uses regex word boundaries to avoid false positives like
2041+ 'abandoned' matching 'done' or 'unfinished' matching 'finished'.
2042+
2043+ Args:
2044+ response_text: The agent response to check
2045+
2046+ Returns:
2047+ True if a completion signal is detected
2048+ """
2049+ import re
2050+ response_lower = response_text .lower ()
2051+ # Negation patterns that should NOT be treated as completion
2052+ _NEGATION_RE = re .compile (
2053+ 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'
2054+ r'.{0,20}' # up to 20 chars between negation and keyword
2055+ )
2056+ # Word-boundary patterns to avoid substring false positives
2057+ _COMPLETION_PATTERNS = [
2058+ (re .compile (r'\btask\s+completed?\b' ), False ), # no negation check needed
2059+ (re .compile (r'\bcompleted\s+successfully\b' ), False ),
2060+ (re .compile (r'\ball\s+done\b' ), False ),
2061+ (re .compile (r'\bdone\b' ), True ), # 'done' needs negation check
2062+ (re .compile (r'\bfinished\b' ), True ), # 'finished' needs negation check
2063+ ]
2064+ for pattern , needs_negation_check in _COMPLETION_PATTERNS :
2065+ match = pattern .search (response_lower )
2066+ if match :
2067+ if needs_negation_check :
2068+ # Check if a negation word precedes the match within 30 chars
2069+ start = max (0 , match .start () - 30 )
2070+ prefix = response_lower [start :match .start ()]
2071+ if _NEGATION_RE .search (prefix ):
2072+ continue # Skip — negated completion
2073+ # Also check "not X yet" pattern
2074+ end = min (len (response_lower ), match .end () + 10 )
2075+ suffix = response_lower [match .end ():end ]
2076+ if 'yet' in suffix :
2077+ neg_prefix = response_lower [max (0 , match .start () - 15 ):match .start ()]
2078+ if 'not' in neg_prefix :
2079+ continue
2080+ return True
2081+ return False
2082+
2083+ def _get_doom_recovery (self ) -> str :
2084+ """Get doom loop recovery action from tracker.
2085+
2086+ Returns:
2087+ Recovery action string: continue, retry_different, escalate_model,
2088+ request_help, or abort
2089+ """
2090+ if self ._doom_loop_tracker is None :
2091+ return "continue"
2092+ return self ._doom_loop_tracker .get_recovery_action ()
2093+
20362094 def _bridge_autonomy_level (self , level : str ) -> None :
20372095 """Bridge autonomy level to per-agent approval backend (G3/G-BRIDGE-1 fix).
20382096
@@ -2144,18 +2202,44 @@ def run_autonomous(
21442202 started_at = started_at ,
21452203 )
21462204
2147- # Check doom loop
2205+ # Check doom loop with graduated recovery (G-RECOVERY-2 fix)
21482206 if self ._is_doom_loop ():
2149- return AutonomyResult (
2150- success = False ,
2151- output = "Task stopped due to repeated actions (doom loop)" ,
2152- completion_reason = "doom_loop" ,
2153- iterations = iterations ,
2154- stage = stage ,
2155- actions = actions_taken ,
2156- duration_seconds = time_module .time () - start_time ,
2157- started_at = started_at ,
2158- )
2207+ recovery = self ._get_doom_recovery ()
2208+ obs = getattr (self , '_observability_hooks' , None )
2209+ if obs is not None :
2210+ from ..escalation .observability import EventType as _EvtType
2211+ obs .emit (_EvtType .STEP_END , {
2212+ "doom_loop" : True ,
2213+ "recovery_action" : recovery ,
2214+ "iteration" : iterations ,
2215+ })
2216+ if recovery == "retry_different" :
2217+ prompt = prompt + "\n \n [System: Previous approach repeated. Try a completely different strategy.]"
2218+ if self ._doom_loop_tracker is not None :
2219+ self ._doom_loop_tracker .clear_actions ()
2220+ continue
2221+ elif recovery == "request_help" :
2222+ return AutonomyResult (
2223+ success = False ,
2224+ output = "Task needs human guidance (doom loop recovery exhausted)" ,
2225+ completion_reason = "needs_help" ,
2226+ iterations = iterations ,
2227+ stage = stage ,
2228+ actions = actions_taken ,
2229+ duration_seconds = time_module .time () - start_time ,
2230+ started_at = started_at ,
2231+ )
2232+ else :
2233+ return AutonomyResult (
2234+ success = False ,
2235+ output = "Task stopped due to repeated actions (doom loop)" ,
2236+ completion_reason = "doom_loop" ,
2237+ iterations = iterations ,
2238+ stage = stage ,
2239+ actions = actions_taken ,
2240+ duration_seconds = time_module .time () - start_time ,
2241+ started_at = started_at ,
2242+ )
21592243
21602244 # Execute one turn using the agent's chat method
21612245 # Always use the original prompt (prompt re-injection)
@@ -2233,14 +2317,8 @@ def run_autonomous(
22332317 started_at = started_at ,
22342318 )
22352319
2236- # Check for keyword-based completion signals (fallback)
2237- response_lower = response_str .lower ()
2238- completion_signals = [
2239- "task completed" , "task complete" , "done" ,
2240- "finished" , "completed successfully" ,
2241- ]
2242-
2243- if any (signal in response_lower for signal in completion_signals ):
2320+ # Check for keyword-based completion signals (word-boundary, G-COMPLETION-1 fix)
2321+ if self ._is_completion_signal (response_str ):
22442322 return AutonomyResult (
22452323 success = True ,
22462324 output = response_str ,
@@ -2280,7 +2358,18 @@ def run_autonomous(
22802358 duration_seconds = time_module .time () - start_time ,
22812359 started_at = started_at ,
22822360 )
2283-
2361+
2362+ except KeyboardInterrupt :
2363+ return AutonomyResult (
2364+ success = False ,
2365+ output = "Task cancelled by user" ,
2366+ completion_reason = "cancelled" ,
2367+ iterations = iterations ,
2368+ stage = stage ,
2369+ actions = actions_taken ,
2370+ duration_seconds = time_module .time () - start_time ,
2371+ started_at = started_at ,
2372+ )
22842373 except Exception as e :
22852374 return AutonomyResult (
22862375 success = False ,
@@ -2396,18 +2485,44 @@ async def main():
23962485 started_at = started_at ,
23972486 )
23982487
2399- # Check doom loop
2488+ # Check doom loop with graduated recovery (G-RECOVERY-2 fix)
24002489 if self ._is_doom_loop ():
2401- return AutonomyResult (
2402- success = False ,
2403- output = "Task stopped due to repeated actions (doom loop)" ,
2404- completion_reason = "doom_loop" ,
2405- iterations = iterations ,
2406- stage = stage ,
2407- actions = actions_taken ,
2408- duration_seconds = time_module .time () - start_time ,
2409- started_at = started_at ,
2410- )
2490+ recovery = self ._get_doom_recovery ()
2491+ obs = getattr (self , '_observability_hooks' , None )
2492+ if obs is not None :
2493+ from ..escalation .observability import EventType as _EvtType
2494+ obs .emit (_EvtType .STEP_END , {
2495+ "doom_loop" : True ,
2496+ "recovery_action" : recovery ,
2497+ "iteration" : iterations ,
2498+ })
2499+ if recovery == "retry_different" :
2500+ prompt = prompt + "\n \n [System: Previous approach repeated. Try a completely different strategy.]"
2501+ if self ._doom_loop_tracker is not None :
2502+ self ._doom_loop_tracker .clear_actions ()
2503+ continue
2504+ elif recovery == "request_help" :
2505+ return AutonomyResult (
2506+ success = False ,
2507+ output = "Task needs human guidance (doom loop recovery exhausted)" ,
2508+ completion_reason = "needs_help" ,
2509+ iterations = iterations ,
2510+ stage = stage ,
2511+ actions = actions_taken ,
2512+ duration_seconds = time_module .time () - start_time ,
2513+ started_at = started_at ,
2514+ )
2515+ else :
2516+ return AutonomyResult (
2517+ success = False ,
2518+ output = "Task stopped due to repeated actions (doom loop)" ,
2519+ completion_reason = "doom_loop" ,
2520+ iterations = iterations ,
2521+ stage = stage ,
2522+ actions = actions_taken ,
2523+ duration_seconds = time_module .time () - start_time ,
2524+ started_at = started_at ,
2525+ )
24112526
24122527 # Execute one turn using the agent's async chat method
24132528 # Always use the original prompt (prompt re-injection)
@@ -2485,14 +2600,8 @@ async def main():
24852600 started_at = started_at ,
24862601 )
24872602
2488- # Check for keyword-based completion signals (fallback)
2489- response_lower = response_str .lower ()
2490- completion_signals = [
2491- "task completed" , "task complete" , "done" ,
2492- "finished" , "completed successfully" ,
2493- ]
2494-
2495- if any (signal in response_lower for signal in completion_signals ):
2603+ # Check for keyword-based completion signals (word-boundary, G-COMPLETION-1 fix)
2604+ if self ._is_completion_signal (response_str ):
24962605 return AutonomyResult (
24972606 success = True ,
24982607 output = response_str ,
@@ -2538,7 +2647,18 @@ async def main():
25382647 duration_seconds = time_module .time () - start_time ,
25392648 started_at = started_at ,
25402649 )
2541-
2650+
2651+ except (KeyboardInterrupt , asyncio .CancelledError ):
2652+ return AutonomyResult (
2653+ success = False ,
2654+ output = "Task cancelled" ,
2655+ completion_reason = "cancelled" ,
2656+ iterations = iterations ,
2657+ stage = stage ,
2658+ actions = actions_taken ,
2659+ duration_seconds = time_module .time () - start_time ,
2660+ started_at = started_at ,
2661+ )
25422662 except Exception as e :
25432663 return AutonomyResult (
25442664 success = False ,
0 commit comments