1616from typing import Optional , Dict , Any , List , Set
1717from enum import Enum
1818
19+ from qwed_new .guards .doom_loop_guard import ProgressAwareDoomLoopGuard
20+
1921
2022class AgentType (Enum ):
2123 """Agent autonomy levels"""
@@ -108,6 +110,8 @@ class ActionContext:
108110 conversation_id : Optional [str ] = None
109111 step_number : Optional [int ] = None
110112 user_intent : Optional [str ] = None
113+ pre_action_state_hash : Optional [str ] = None
114+ state_source : Optional [str ] = None
111115
112116
113117@dataclass
@@ -165,6 +169,9 @@ class AgentService:
165169 }
166170 MAX_CONVERSATION_STEPS = 50
167171 MAX_CONSECUTIVE_IDENTICAL_ACTIONS = 2
172+ # Server-side flag: when True, callers MUST provide state hash for LOOP-004.
173+ # Set to False during gradual rollout; set to True once all callers are updated.
174+ DOOM_LOOP_GUARD_REQUIRED = False
168175 RISK_RANKS = {
169176 RiskLevel .LOW : 0 ,
170177 RiskLevel .MEDIUM : 1 ,
@@ -179,6 +186,7 @@ def __init__(self):
179186 self ._conversation_state : Dict [tuple [str , str ], Dict [str , Any ]] = {}
180187 self ._conversation_reservations : Dict [tuple [str , str ], Dict [str , Any ]] = {}
181188 self ._conversation_state_lock = threading .Lock ()
189+ self ._doom_loop_guard = ProgressAwareDoomLoopGuard ()
182190
183191 def _generate_agent_id (self ) -> str :
184192 return f"agent_{ uuid .uuid4 ().hex [:12 ]} "
@@ -329,21 +337,12 @@ def verify_action(
329337 failed = [c .name for c in checks if not c .passed ]
330338
331339 # Determine decision
332- if failed :
333- decision = AgentDecision .DENIED
334- elif self ._risk_exceeds_threshold (risk_level , RiskLevel [risk_threshold .upper ()]):
335- if agent .trust_level .value < TrustLevel .AUTONOMOUS .value :
336- decision = AgentDecision .PENDING
337- else :
338- decision = AgentDecision .APPROVED
339- else :
340- decision = AgentDecision .APPROVED
340+ decision = self ._determine_decision (
341+ failed , risk_level , risk_threshold , agent .trust_level ,
342+ )
341343
342- # Commit approved or pending steps so loop/replay tracking cannot be bypassed.
343- if decision in {AgentDecision .APPROVED , AgentDecision .PENDING }:
344- self ._commit_action_context (context , context_state )
345- else :
346- self ._release_action_context (context_state )
344+ # Commit or release action context based on decision.
345+ self ._finalize_action_context (decision , context , context_state )
347346
348347 # Update budget tracking
349348 if decision == AgentDecision .APPROVED :
@@ -366,7 +365,7 @@ def verify_action(
366365 )
367366 self ._activity_logs .append (log )
368367
369- response = {
368+ return {
370369 "decision" : decision .value ,
371370 "verification" : {
372371 "status" : "VERIFIED" if decision == AgentDecision .APPROVED else "FAILED" ,
@@ -380,8 +379,41 @@ def verify_action(
380379 "hourly_requests" : agent .budget .max_requests_per_hour - agent .budget .current_hour_requests ,
381380 },
382381 }
383-
384- return response
382+
383+ def _determine_decision (
384+ self ,
385+ failed : list ,
386+ risk_level : RiskLevel ,
387+ risk_threshold : str ,
388+ trust_level : TrustLevel ,
389+ ) -> AgentDecision :
390+ """Map verification results + risk to a deterministic decision."""
391+ if failed :
392+ return AgentDecision .DENIED
393+ if self ._risk_exceeds_threshold (risk_level , RiskLevel [risk_threshold .upper ()]):
394+ if trust_level .value < TrustLevel .AUTONOMOUS .value :
395+ return AgentDecision .PENDING
396+ return AgentDecision .APPROVED
397+ return AgentDecision .APPROVED
398+
399+ def _finalize_action_context (
400+ self ,
401+ decision : AgentDecision ,
402+ context : ActionContext ,
403+ context_state : Optional [Dict [str , Any ]],
404+ ) -> None :
405+ """Commit or release action context based on decision.
406+
407+ LOOP-004 fingerprint is only committed on APPROVED.
408+ PENDING actions still commit step/replay tracking (LOOP-001/002/003)
409+ but clear the fingerprint since PENDING may be rejected later.
410+ """
411+ if decision in {AgentDecision .APPROVED , AgentDecision .PENDING }:
412+ if decision == AgentDecision .PENDING and context_state is not None :
413+ context_state ["loop_004_fingerprint" ] = None
414+ self ._commit_action_context (context , context_state )
415+ else :
416+ self ._release_action_context (context_state )
385417
386418 def _enforce_action_context (
387419 self ,
@@ -409,7 +441,13 @@ def _enforce_action_context(
409441 }, None )
410442
411443 state_key = (agent_id , context .conversation_id )
412- fingerprint = self ._action_fingerprint (action )
444+ try :
445+ fingerprint = self ._action_fingerprint (action )
446+ except TypeError as exc :
447+ return ({
448+ "code" : "QWED-AGENT-STATE-004" ,
449+ "message" : f"Action parameters must be deterministic JSON-compatible values: { exc } " ,
450+ }, None )
413451
414452 with self ._conversation_state_lock :
415453 reservation = self ._conversation_reservations .get (state_key )
@@ -453,12 +491,84 @@ def _enforce_action_context(
453491 "reservation_id" : reservation_id ,
454492 }
455493
494+ # --- LOOP-004: Progress-aware no-progress detection ---
495+ loop_004_result = self ._check_progress_doom_loop (
496+ agent_id , action , context , state_key , reservation_id ,
497+ )
498+ if isinstance (loop_004_result , dict ) and "code" in loop_004_result :
499+ return (loop_004_result , None )
500+
456501 return None , {
457502 "state_key" : state_key ,
458503 "next_state" : next_state ,
459504 "reservation_id" : reservation_id ,
505+ "loop_004_fingerprint" : loop_004_result , # str or None
506+ "agent_id" : agent_id ,
507+ "conversation_id" : context .conversation_id ,
460508 }
461509
510+ def _check_progress_doom_loop (
511+ self ,
512+ agent_id : str ,
513+ action : AgentAction ,
514+ context : ActionContext ,
515+ state_key : tuple ,
516+ reservation_id : str ,
517+ ) -> Optional [Dict [str , str ]]:
518+ """Run LOOP-004 progress check; returns error dict or None."""
519+ has_hash = context .pre_action_state_hash is not None
520+ has_source = context .state_source is not None
521+
522+ # Neither supplied: check server-side enforcement flag.
523+ if not has_hash and not has_source :
524+ if self .DOOM_LOOP_GUARD_REQUIRED :
525+ self ._release_reservation (state_key , reservation_id )
526+ return {
527+ "code" : "QWED-AGENT-STATE-001" ,
528+ "message" : (
529+ "pre_action_state_hash and state_source are required "
530+ "when DOOM_LOOP_GUARD_REQUIRED is enabled."
531+ ),
532+ }
533+ return None
534+
535+ # Partial supply → fail closed.
536+ if has_hash != has_source :
537+ self ._release_reservation (state_key , reservation_id )
538+ return {
539+ "code" : "QWED-AGENT-STATE-001" ,
540+ "message" : "pre_action_state_hash and state_source must be provided together." ,
541+ }
542+
543+ progress = self ._doom_loop_guard .verify_progress (
544+ agent_id = agent_id ,
545+ conversation_id = context .conversation_id ,
546+ tool_name = action .action_type ,
547+ arguments = {
548+ "query" : action .query ,
549+ "code" : action .code ,
550+ "target" : action .target ,
551+ "parameters" : action .parameters ,
552+ },
553+ pre_action_state_hash = context .pre_action_state_hash ,
554+ state_source = context .state_source ,
555+ )
556+ if not progress ["verified" ]:
557+ self ._release_reservation (state_key , reservation_id )
558+ return {
559+ "code" : progress ["error_code" ],
560+ "message" : progress ["message" ],
561+ }
562+ # Return the fingerprint so it can be committed after approval.
563+ return progress .get ("fingerprint" )
564+
565+ def _release_reservation (self , state_key : tuple , reservation_id : str ) -> None :
566+ """Release a pending reservation under the conversation state lock."""
567+ with self ._conversation_state_lock :
568+ reservation = self ._conversation_reservations .get (state_key )
569+ if reservation and reservation ["reservation_id" ] == reservation_id :
570+ self ._conversation_reservations .pop (state_key , None )
571+
462572 def _commit_action_context (
463573 self ,
464574 context : ActionContext ,
@@ -483,6 +593,16 @@ def _commit_action_context(
483593 self ._conversation_state [state_key ] = next_state
484594 self ._conversation_reservations .pop (state_key , None )
485595
596+ # Phase 2: commit LOOP-004 fingerprint now that action is approved.
597+ # Executed within the lock to prevent TOCTOU concurrent bypasses.
598+ fp = context_state .get ("loop_004_fingerprint" )
599+ if fp is not None :
600+ self ._doom_loop_guard .commit_progress (
601+ agent_id = context_state ["agent_id" ],
602+ conversation_id = context_state ["conversation_id" ],
603+ fingerprint = fp ,
604+ )
605+
486606 def _release_action_context (self , context_state : Optional [Dict [str , Any ]]) -> None :
487607 """Release an in-flight context reservation when execution does not proceed."""
488608 if context_state is None :
0 commit comments