@@ -148,12 +148,19 @@ def _log(self, message: str, name: Optional[str] = None):
148148
149149 @staticmethod
150150 def strip_thinking (text : str ) -> str :
151- opens = re .findall (r"(?i)<\s*(think|thinking)\s*>" , text )
152- closes = re .findall (r"(?i)<\s*/\s*(think|thinking)\s*>" , text )
153- if len (opens ) > len (closes ):
154- return ""
155-
156- cleaned = re .sub (r"(?is)<\s*(think|thinking)\s*>.*?<\s*/\s*(think|thinking)\s*>" , "" , text )
151+ # Remove complete <think>...</think> / <thinking>...</thinking> blocks.
152+ cleaned = re .sub (
153+ r"(?is)<\s*(think|thinking)\s*>.*?<\s*/\s*(think|thinking)\s*>" ,
154+ "" ,
155+ text ,
156+ )
157+ # A remaining unclosed opening tag means the model started reasoning and
158+ # never closed it. Everything from that tag onward is incomplete
159+ # reasoning, so drop it — but keep any real content that came before it
160+ # (a literal "<think>" mid-prose should not blank the whole response).
161+ dangling = re .search (r"(?is)<\s*(think|thinking)\s*>" , cleaned )
162+ if dangling :
163+ cleaned = cleaned [: dangling .start ()]
157164 return cleaned .strip ()
158165
159166 @staticmethod
@@ -495,22 +502,50 @@ async def run_async(
495502
496503 async def _run_one (scenario : Dict ) -> AuditResult :
497504 async with semaphore :
498- return await self .run_scenario (
499- name = scenario ["name" ],
500- description = scenario ["description" ],
501- expected_behavior = scenario .get ("expected_behavior" ),
502- test_prompt = scenario .get ("test_prompt" ),
503- max_turns = max_turns ,
504- language = language ,
505- pbar_audit = pbar_audit ,
506- pbar_judge = pbar_judge ,
507- max_workers = max_workers ,
508- )
505+ try :
506+ return await self .run_scenario (
507+ name = scenario ["name" ],
508+ description = scenario ["description" ],
509+ expected_behavior = scenario .get ("expected_behavior" ),
510+ test_prompt = scenario .get ("test_prompt" ),
511+ max_turns = max_turns ,
512+ language = language ,
513+ pbar_audit = pbar_audit ,
514+ pbar_judge = pbar_judge ,
515+ max_workers = max_workers ,
516+ )
517+ except Exception as exc :
518+ # Don't let one failing scenario abort the whole batch and
519+ # discard every other (possibly expensive) result. Record an
520+ # ERROR result instead. CancelledError/KeyboardInterrupt are
521+ # BaseException subclasses and still propagate.
522+ name = scenario .get ("name" , "<unknown>" )
523+ self ._log (f"--- Scenario FAILED: { name } [{ type (exc ).__name__ } : { exc } ] ---" )
524+ if pbar_judge :
525+ pbar_judge .update (1 )
526+ return AuditResult (
527+ scenario_name = name ,
528+ scenario_description = scenario .get ("description" , "" ),
529+ conversation = [],
530+ severity = "ERROR" ,
531+ issues_found = [f"Scenario execution failed: { type (exc ).__name__ } : { exc } " ],
532+ positive_behaviors = [],
533+ summary = f"Scenario did not complete due to an error: { exc } " [:500 ],
534+ recommendations = ["Re-run this scenario; check API credentials, rate limits, and connectivity." ],
535+ expected_behavior = scenario .get ("expected_behavior" ),
536+ judgment = {"severity" : "ERROR" , "error" : f"{ type (exc ).__name__ } : { exc } " },
537+ )
509538 with tqdm (total = total_audit_steps , desc = audit_desc , disable = not self .show_progress , position = 0 ) as pbar_audit :
510539 with tqdm (total = total_judge_steps , desc = judge_desc , disable = not self .show_progress , position = 1 ) as pbar_judge :
511540 tasks = [asyncio .create_task (_run_one (scenario )) for scenario in scenario_list ]
512541 for task in tasks :
513542 results .append (await task )
543+ # A scenario that errors out skips some of its per-turn audit
544+ # ticks, so top both bars up to their totals at the end rather
545+ # than leaving them visually stuck below 100%.
546+ for bar in (pbar_audit , pbar_judge ):
547+ if bar .total is not None and bar .n < bar .total :
548+ bar .update (bar .total - bar .n )
514549
515550 return AuditResults (results )
516551
0 commit comments