Coverage for agentos/swarm/human_loop.py: 48%

119 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-10 01:20 +0800

1""" 

2v1.9.5: Human-in-the-Loop (HITL) breakpoint system. 

3 

4Enables task execution to pause at configurable checkpoints for human 

5review, approval, or intervention before continuing. 

6""" 

7 

8from __future__ import annotations 

9 

10import asyncio 

11import time 

12import uuid 

13from collections.abc import Callable 

14from dataclasses import dataclass, field 

15from enum import StrEnum 

16from typing import Any 

17 

18 

19class BreakpointType(StrEnum): 

20 """Types of human-in-the-loop breakpoints.""" 

21 

22 BEFORE_TASK = "before_task" # Before a sub-task starts 

23 AFTER_RESULT = "after_result" # After a sub-task produces output 

24 ON_FAILURE = "on_failure" # When a sub-task fails 

25 ON_LOW_CONFIDENCE = "on_low_confidence" # When fusion confidence is low 

26 MANUAL = "manual" # Explicitly placed by developer 

27 

28 

29class HumanDecision(StrEnum): 

30 """Human responses at a breakpoint.""" 

31 

32 APPROVE = "approve" # Approve and continue 

33 REJECT = "reject" # Reject and skip/retry 

34 RETRY = "retry" # Reject and retry with feedback 

35 MODIFY = "modify" # Accept with modifications 

36 ABORT = "abort" # Abort entire task 

37 

38 

39@dataclass 

40class Breakpoint: 

41 """A checkpoint where execution pauses for human input.""" 

42 

43 id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) 

44 type: BreakpointType = BreakpointType.MANUAL 

45 task_id: str = "" 

46 context: dict[str, Any] = field(default_factory=dict) 

47 message: str = "" 

48 options: list[str] = field(default_factory=lambda: ["approve", "reject", "retry", "abort"]) 

49 timeout: float = 0.0 # 0 = no timeout 

50 created_at: float = field(default_factory=time.time) 

51 resolved_at: float = 0.0 

52 decision: HumanDecision | None = None 

53 feedback: str = "" 

54 resolved: bool = False 

55 

56 def to_dict(self) -> dict: 

57 return { 

58 "id": self.id, 

59 "type": self.type.value, 

60 "task_id": self.task_id, 

61 "message": self.message, 

62 "options": self.options, 

63 "resolved": self.resolved, 

64 "decision": self.decision.value if self.decision else None, 

65 } 

66 

67 

68@dataclass 

69class HITLConfig: 

70 """Configuration for human-in-the-loop behavior.""" 

71 

72 enabled: bool = True 

73 break_on_failure: bool = True 

74 break_on_low_confidence: float = 0.3 # confidence below this triggers break 

75 break_on_first_task: bool = False # break before first sub-task 

76 break_on_every_task: bool = False 

77 break_on_final_result: bool = False # break before returning final result 

78 max_pending_breakpoints: int = 5 # queue limit 

79 default_timeout: float = 300.0 # 5 min default 

80 

81 

82class HITLManager: 

83 """Manages human-in-the-loop breakpoints during task execution. 

84 

85 Usage: 

86 hitl = HITLManager(config=HITLConfig(break_on_failure=True)) 

87 

88 # Register a callback for human input 

89 hitl.register_handler(my_human_input_function) 

90 

91 # During execution: 

92 decision = await hitl.request_decision( 

93 bp_type=BreakpointType.ON_FAILURE, 

94 task_id="task_1", 

95 message="Task failed. Retry?", 

96 context={"error": "...", "attempts": 2} 

97 ) 

98 if decision == HumanDecision.RETRY: 

99 ... 

100 """ 

101 

102 def __init__( 

103 self, 

104 config: HITLConfig | None = None, 

105 handler: Callable | None = None, 

106 ): 

107 self.config = config or HITLConfig() 

108 self._handler = handler 

109 self._breakpoints: dict[str, Breakpoint] = {} 

110 self._pending: list[Breakpoint] = [] 

111 self._decision_queue: asyncio.Queue = asyncio.Queue() 

112 

113 def register_handler(self, handler: Callable[[Breakpoint], HumanDecision]) -> None: 

114 """ 

115 Register a human input handler. 

116 

117 Args: 

118 handler: Callable that receives a Breakpoint and returns a HumanDecision. 

119 Can be sync or async. 

120 """ 

121 self._handler = handler 

122 

123 async def request_decision( 

124 self, 

125 bp_type: BreakpointType, 

126 task_id: str, 

127 message: str, 

128 context: dict | None = None, 

129 timeout: float | None = None, 

130 options: list[str] | None = None, 

131 ) -> tuple[HumanDecision, str]: 

132 """ 

133 Pause execution and request human decision. 

134 

135 Args: 

136 bp_type: Type of breakpoint 

137 task_id: Current task identifier 

138 message: Human-readable message explaining what's needed 

139 context: Additional context for the decision 

140 timeout: Max wait time (None = use config default) 

141 options: Available decision options 

142 

143 Returns: 

144 Tuple of (decision, feedback text) 

145 """ 

146 if not self.config.enabled: 

147 return HumanDecision.APPROVE, "" 

148 

149 bp = Breakpoint( 

150 type=bp_type, 

151 task_id=task_id, 

152 context=context or {}, 

153 message=message, 

154 options=options or ["approve", "reject", "retry", "abort"], 

155 timeout=timeout or self.config.default_timeout, 

156 ) 

157 

158 self._breakpoints[bp.id] = bp 

159 self._pending.append(bp) 

160 

161 # If pending exceeds limit, auto-approve oldest 

162 if len(self._pending) > self.config.max_pending_breakpoints: 

163 oldest = self._pending.pop(0) 

164 oldest.decision = HumanDecision.APPROVE 

165 oldest.resolved = True 

166 oldest.resolved_at = time.time() 

167 

168 # Call handler 

169 if self._handler: 

170 try: 

171 result = self._handler(bp) 

172 if asyncio.iscoroutine(result): 

173 result = await result 

174 if isinstance(result, HumanDecision): 

175 bp.decision = result 

176 elif isinstance(result, tuple) and len(result) == 2: 

177 bp.decision, bp.feedback = result 

178 else: 

179 bp.decision = HumanDecision.APPROVE 

180 except Exception: 

181 bp.decision = HumanDecision.APPROVE 

182 else: 

183 # No handler — wait on queue 

184 try: 

185 decision, feedback = await asyncio.wait_for( 

186 self._decision_queue.get(), 

187 timeout=bp.timeout, 

188 ) 

189 bp.decision = decision 

190 bp.feedback = feedback 

191 except TimeoutError: 

192 bp.decision = HumanDecision.APPROVE 

193 

194 bp.resolved = True 

195 bp.resolved_at = time.time() 

196 

197 # Remove from pending 

198 if bp in self._pending: 

199 self._pending.remove(bp) 

200 

201 return bp.decision, bp.feedback 

202 

203 def provide_decision( 

204 self, 

205 breakpoint_id: str, 

206 decision: HumanDecision, 

207 feedback: str = "", 

208 ) -> None: 

209 """Provide a decision for a pending breakpoint (alternative to handler).""" 

210 if breakpoint_id in self._breakpoints: 

211 self._breakpoints[breakpoint_id] 

212 self._decision_queue.put_nowait((decision, feedback)) 

213 

214 async def should_break_before_task(self, task_id: str, task_name: str) -> bool: 

215 """Check if we should break before a sub-task.""" 

216 if not self.config.enabled: 

217 return False 

218 if self.config.break_on_first_task or self.config.break_on_every_task: 

219 decision, _ = await self.request_decision( 

220 bp_type=BreakpointType.BEFORE_TASK, 

221 task_id=task_id, 

222 message=f"About to execute: {task_name}\nProceed?", 

223 options=["approve", "abort", "modify"], 

224 ) 

225 if decision == HumanDecision.ABORT: 

226 return False 

227 return True 

228 

229 async def should_break_on_result( 

230 self, task_id: str, output: Any, confidence: float 

231 ) -> tuple[HumanDecision, str]: 

232 """Check if we should break after a result.""" 

233 if not self.config.enabled: 

234 return HumanDecision.APPROVE, "" 

235 

236 # Low confidence trigger 

237 if confidence < self.config.break_on_low_confidence: 

238 return await self.request_decision( 

239 bp_type=BreakpointType.ON_LOW_CONFIDENCE, 

240 task_id=task_id, 

241 message=( 

242 f"Low confidence result (confidence: {confidence:.2f})\n" 

243 f"Output: {str(output)[:300]}\n" 

244 f"What would you like to do?" 

245 ), 

246 context={"confidence": confidence, "output": str(output)[:500]}, 

247 options=["approve", "retry", "modify", "abort"], 

248 ) 

249 

250 # Final result break 

251 if self.config.break_on_final_result: 

252 return await self.request_decision( 

253 bp_type=BreakpointType.AFTER_RESULT, 

254 task_id=task_id, 

255 message=f"Result: {str(output)[:300]}\nApprove?", 

256 context={"output": str(output)[:500]}, 

257 options=["approve", "retry", "modify"], 

258 ) 

259 

260 return HumanDecision.APPROVE, "" 

261 

262 async def should_break_on_failure( 

263 self, task_id: str, error: str, attempt: int 

264 ) -> tuple[HumanDecision, str]: 

265 """Check if we should break on failure.""" 

266 if not self.config.enabled or not self.config.break_on_failure: 

267 return HumanDecision.RETRY, "" 

268 

269 return await self.request_decision( 

270 bp_type=BreakpointType.ON_FAILURE, 

271 task_id=task_id, 

272 message=( 

273 f"Task failed (attempt {attempt})\n" 

274 f"Error: {error[:300]}\n" 

275 f"Retry, skip, or abort?" 

276 ), 

277 context={"error": error, "attempt": attempt}, 

278 options=["retry", "abort", "modify"], 

279 ) 

280 

281 @property 

282 def pending_count(self) -> int: 

283 return len(self._pending) 

284 

285 @property 

286 def total_breakpoints(self) -> int: 

287 return len(self._breakpoints)