Skip to content

Commit 50c0034

Browse files
authored
Merge pull request #124 from QWED-AI/feat/progress-aware-doom-loop-guard
feat(agent): add progress-aware doom loop guard (LOOP-004)
2 parents 6e1f029 + 62a56e9 commit 50c0034

3 files changed

Lines changed: 985 additions & 18 deletions

File tree

src/qwed_new/core/agent_service.py

Lines changed: 138 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from typing import Optional, Dict, Any, List, Set
1717
from enum import Enum
1818

19+
from qwed_new.guards.doom_loop_guard import ProgressAwareDoomLoopGuard
20+
1921

2022
class 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

Comments
 (0)