gohumanloop 0.0.1__py3-none-any.whl → 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,658 @@
1
+ from abc import ABC
2
+ import re
3
+ from typing import Dict, Any, Optional, List
4
+ import asyncio
5
+ import json
6
+ import uuid
7
+ from datetime import datetime
8
+ from collections import defaultdict
9
+ from gohumanloop.utils.threadsafedict import ThreadSafeDict
10
+
11
+ from gohumanloop.core.interface import (
12
+ HumanLoopProvider, HumanLoopResult, HumanLoopStatus, HumanLoopType
13
+ )
14
+
15
+ class BaseProvider(HumanLoopProvider, ABC):
16
+ """Base implementation of human-in-the-loop provider"""
17
+
18
+
19
+ def __init__(self, name: str, config: Optional[Dict[str, Any]] = None):
20
+ self.config = config or {}
21
+ # Custom name, will use UUID if not provided
22
+ self.name = name
23
+ # Store request information using (conversation_id, request_id) as key
24
+ self._requests = ThreadSafeDict() # Using thread-safe dictionary to store request information
25
+ # Store conversation information, including request list and latest request ID
26
+ self._conversations = {}
27
+ # For quick lookup of requests in conversations
28
+ self._conversation_requests = defaultdict(list)
29
+ # Store timeout tasks
30
+ self._timeout_tasks = {}
31
+
32
+ self.prompt_template = self.config.get("prompt_template", "{context}")
33
+
34
+ def __str__(self) -> str:
35
+ """Returns a string description of this instance"""
36
+ total_conversations = len(self._conversations)
37
+ total_requests = len(self._requests)
38
+ active_requests = sum(1 for req in self._requests.values()
39
+ if req["status"] in [HumanLoopStatus.PENDING, HumanLoopStatus.INPROGRESS])
40
+
41
+ return (f"conversations={total_conversations}, "
42
+ f"total_requests={total_requests}, "
43
+ f"active_requests={active_requests})")
44
+
45
+ def __repr__(self) -> str:
46
+ """Returns a detailed string representation of this instance"""
47
+ return self.__str__()
48
+
49
+ def _generate_request_id(self) -> str:
50
+ """Generates a unique request ID"""
51
+ return str(uuid.uuid4())
52
+
53
+ def _update_request_status_error(
54
+ self,
55
+ conversation_id: str,
56
+ request_id: str,
57
+ error: Optional[str] = None,
58
+ ):
59
+ """Update request status"""
60
+ request_key = (conversation_id, request_id)
61
+ if request_key in self._requests:
62
+ self._requests[request_key]["status"] = HumanLoopStatus.ERROR
63
+ self._requests[request_key]["error"] = error
64
+
65
+ def _store_request(
66
+ self,
67
+ conversation_id: str,
68
+ request_id: str,
69
+ task_id: str,
70
+ loop_type: HumanLoopType,
71
+ context: Dict[str, Any],
72
+ metadata: Dict[str, Any],
73
+ timeout: Optional[int],
74
+ ) -> None:
75
+ """Store request information"""
76
+ # Store request information using tuple (conversation_id, request_id) as key
77
+ self._requests[(conversation_id, request_id)] = {
78
+ "task_id": task_id,
79
+ "loop_type": loop_type,
80
+ "context": context,
81
+ "metadata": metadata,
82
+ "created_at": datetime.now().isoformat(),
83
+ "status": HumanLoopStatus.PENDING,
84
+ "timeout": timeout,
85
+ }
86
+
87
+ # Update conversation information
88
+ if conversation_id not in self._conversations:
89
+ self._conversations[conversation_id] = {
90
+ "task_id": task_id,
91
+ "latest_request_id": None,
92
+ "created_at": datetime.now().isoformat(),
93
+ }
94
+
95
+ # Add request to conversation request list
96
+ self._conversation_requests[conversation_id].append(request_id)
97
+ # Update latest request ID
98
+ self._conversations[conversation_id]["latest_request_id"] = request_id
99
+
100
+ def _get_request(self, conversation_id: str, request_id: str) -> Optional[Dict[str, Any]]:
101
+ """Get request information"""
102
+ return self._requests.get((conversation_id, request_id))
103
+
104
+ def _get_conversation(self, conversation_id: str) -> Optional[Dict[str, Any]]:
105
+ """Get conversation information"""
106
+ return self._conversations.get(conversation_id)
107
+
108
+ def _get_conversation_requests(self, conversation_id: str) -> List[str]:
109
+ """Get all request IDs in the conversation"""
110
+ return self._conversation_requests.get(conversation_id, [])
111
+
112
+ async def async_request_humanloop(
113
+ self,
114
+ task_id: str,
115
+ conversation_id: str,
116
+ loop_type: HumanLoopType,
117
+ context: Dict[str, Any],
118
+ metadata: Optional[Dict[str, Any]] = None,
119
+ timeout: Optional[int] = None
120
+ ) -> HumanLoopResult:
121
+ """Request human-in-the-loop interaction
122
+
123
+ Args:
124
+ task_id: Task identifier
125
+ conversation_id: Conversation ID for multi-turn dialogues
126
+ loop_type: Type of human loop interaction
127
+ context: Context information provided to human
128
+ metadata: Additional metadata
129
+ timeout: Request timeout in seconds
130
+
131
+ Returns:
132
+ HumanLoopResult: Result object containing request ID and initial status
133
+ """
134
+ # Subclasses must implement this method
135
+ raise NotImplementedError("Subclasses must implement request_humanloop")
136
+
137
+ def request_humanloop(
138
+ self,
139
+ task_id: str,
140
+ conversation_id: str,
141
+ loop_type: HumanLoopType,
142
+ context: Dict[str, Any],
143
+ metadata: Optional[Dict[str, Any]] = None,
144
+ timeout: Optional[int] = None
145
+ ) -> HumanLoopResult:
146
+ """Request human-in-the-loop interaction (synchronous version)
147
+
148
+ Args:
149
+ task_id: Task identifier
150
+ conversation_id: Conversation ID for multi-turn dialogues
151
+ loop_type: Type of human loop interaction
152
+ context: Context information provided to human
153
+ metadata: Additional metadata
154
+ timeout: Request timeout in seconds
155
+
156
+ Returns:
157
+ HumanLoopResult: Result object containing request ID and initial status
158
+ """
159
+
160
+ loop = asyncio.get_event_loop()
161
+ if loop.is_running():
162
+ # 如果事件循环已经在运行,创建一个新的事件循环
163
+ new_loop = asyncio.new_event_loop()
164
+ asyncio.set_event_loop(new_loop)
165
+ loop = new_loop
166
+
167
+ try:
168
+ return loop.run_until_complete(
169
+ self.async_request_humanloop(
170
+ task_id=task_id,
171
+ conversation_id=conversation_id,
172
+ loop_type=loop_type,
173
+ context=context,
174
+ metadata=metadata,
175
+ timeout=timeout
176
+ ))
177
+ finally:
178
+ if loop != asyncio.get_event_loop():
179
+ loop.close()
180
+
181
+
182
+ async def async_check_request_status(
183
+ self,
184
+ conversation_id: str,
185
+ request_id: str
186
+ ) -> HumanLoopResult:
187
+ """Check request status
188
+
189
+ Args:
190
+ conversation_id: Conversation identifier for multi-turn dialogues
191
+ request_id: Request identifier for specific interaction request
192
+
193
+ Returns:
194
+ HumanLoopResult: Result object containing current request status, including status, response data, etc.
195
+ """
196
+ request_info = self._get_request(conversation_id, request_id)
197
+ if not request_info:
198
+ return HumanLoopResult(
199
+ conversation_id=conversation_id,
200
+ request_id=request_id,
201
+ loop_type=HumanLoopType.CONVERSATION,
202
+ status=HumanLoopStatus.ERROR,
203
+ error=f"Request '{request_id}' not found in conversation '{conversation_id}'"
204
+ )
205
+
206
+ # Subclasses need to implement specific status check logic
207
+ raise NotImplementedError("Subclasses must implement check_request_status")
208
+
209
+
210
+ def check_request_status(
211
+ self,
212
+ conversation_id: str,
213
+ request_id: str
214
+ ) -> HumanLoopResult:
215
+ """Check conversation status (synchronous version)
216
+
217
+ Args:
218
+ conversation_id: Conversation identifier
219
+
220
+ Returns:
221
+ HumanLoopResult: Result containing the status of the latest request in the conversation
222
+ """
223
+
224
+ loop = asyncio.get_event_loop()
225
+ if loop.is_running():
226
+ # 如果事件循环已经在运行,创建一个新的事件循环
227
+ new_loop = asyncio.new_event_loop()
228
+ asyncio.set_event_loop(new_loop)
229
+ loop = new_loop
230
+
231
+ try:
232
+ return loop.run_until_complete(
233
+ self.async_check_request_status(
234
+ conversation_id=conversation_id,
235
+ request_id=request_id
236
+ ))
237
+ finally:
238
+ if loop != asyncio.get_event_loop():
239
+ loop.close()
240
+
241
+
242
+ async def async_check_conversation_status(
243
+ self,
244
+ conversation_id: str
245
+ ) -> HumanLoopResult:
246
+ """Check conversation status
247
+
248
+ Args:
249
+ conversation_id: Conversation identifier
250
+
251
+ Returns:
252
+ HumanLoopResult: Result containing the status of the latest request in the conversation
253
+ """
254
+ conversation_info = self._get_conversation(conversation_id)
255
+ if not conversation_info:
256
+ return HumanLoopResult(
257
+ conversation_id=conversation_id,
258
+ request_id="",
259
+ loop_type=HumanLoopType.CONVERSATION,
260
+ status=HumanLoopStatus.ERROR,
261
+ error=f"Conversation '{conversation_id}' not found"
262
+ )
263
+
264
+ latest_request_id = conversation_info.get("latest_request_id")
265
+ if not latest_request_id:
266
+ return HumanLoopResult(
267
+ conversation_id=conversation_id,
268
+ request_id="",
269
+ loop_type=HumanLoopType.CONVERSATION,
270
+ status=HumanLoopStatus.ERROR,
271
+ error=f"No requests found in conversation '{conversation_id}'"
272
+ )
273
+
274
+ return await self.async_check_request_status(conversation_id, latest_request_id)
275
+
276
+
277
+ def check_conversation_status(
278
+ self,
279
+ conversation_id: str
280
+ ) -> HumanLoopResult:
281
+ """Check conversation status (synchronous version)
282
+
283
+ Args:
284
+ conversation_id: Conversation identifier
285
+
286
+ Returns:
287
+ HumanLoopResult: Result containing the status of the latest request in the conversation
288
+ """
289
+
290
+ loop = asyncio.get_event_loop()
291
+ if loop.is_running():
292
+ # 如果事件循环已经在运行,创建一个新的事件循环
293
+ new_loop = asyncio.new_event_loop()
294
+ asyncio.set_event_loop(new_loop)
295
+ loop = new_loop
296
+
297
+ try:
298
+ return loop.run_until_complete(
299
+ self.async_check_conversation_status(
300
+ conversation_id=conversation_id
301
+ ))
302
+ finally:
303
+ if loop != asyncio.get_event_loop():
304
+ loop.close()
305
+
306
+
307
+ async def async_cancel_request(
308
+ self,
309
+ conversation_id: str,
310
+ request_id: str
311
+ ) -> bool:
312
+ """Cancel human-in-the-loop request
313
+
314
+ Args:
315
+ conversation_id: Conversation identifier for multi-turn dialogues
316
+ request_id: Request identifier for specific interaction request
317
+
318
+ Returns:
319
+ bool: Whether cancellation was successful, True indicates success, False indicates failure
320
+ """
321
+
322
+ # Cancel timeout task
323
+ if (conversation_id, request_id) in self._timeout_tasks:
324
+ self._timeout_tasks[(conversation_id, request_id)].cancel()
325
+ del self._timeout_tasks[(conversation_id, request_id)]
326
+
327
+ request_key = (conversation_id, request_id)
328
+ if request_key in self._requests:
329
+ # Update request status to cancelled
330
+ self._requests[request_key]["status"] = HumanLoopStatus.CANCELLED
331
+ return True
332
+ return False
333
+
334
+ def cancel_request(
335
+ self,
336
+ conversation_id: str,
337
+ request_id: str
338
+ ) -> bool:
339
+ """Cancel human-in-the-loop request (synchronous version)
340
+
341
+ Args:
342
+ conversation_id: Conversation identifier for multi-turn dialogues
343
+ request_id: Request identifier for specific interaction request
344
+
345
+ Returns:
346
+ bool: Whether cancellation was successful, True indicates success, False indicates failure
347
+ """
348
+ loop = asyncio.get_event_loop()
349
+ if loop.is_running():
350
+ # 如果事件循环已经在运行,创建一个新的事件循环
351
+ new_loop = asyncio.new_event_loop()
352
+ asyncio.set_event_loop(new_loop)
353
+ loop = new_loop
354
+
355
+ try:
356
+ return loop.run_until_complete(
357
+ self.async_cancel_request(
358
+ conversation_id=conversation_id,
359
+ request_id=request_id
360
+ ))
361
+ finally:
362
+ if loop != asyncio.get_event_loop():
363
+ loop.close()
364
+
365
+ async def async_cancel_conversation(
366
+ self,
367
+ conversation_id: str
368
+ ) -> bool:
369
+ """Cancel the entire conversation
370
+
371
+ Args:
372
+ conversation_id: Conversation identifier
373
+
374
+ Returns:
375
+ bool: Whether the cancellation was successful
376
+ """
377
+ if conversation_id not in self._conversations:
378
+ return False
379
+
380
+ # Cancel all requests in the conversation
381
+ success = True
382
+ for request_id in self._get_conversation_requests(conversation_id):
383
+ request_key = (conversation_id, request_id)
384
+ if request_key in self._requests:
385
+ # Update request status to cancelled
386
+ # Only requests in intermediate states (PENDING/IN_PROGRESS) can be cancelled
387
+ if self._requests[request_key]["status"] in [HumanLoopStatus.PENDING, HumanLoopStatus.INPROGRESS]:
388
+ self._requests[request_key]["status"] = HumanLoopStatus.CANCELLED
389
+
390
+ # Cancel the timeout task for this request
391
+ if request_key in self._timeout_tasks:
392
+ self._timeout_tasks[request_key].cancel()
393
+ del self._timeout_tasks[request_key]
394
+ else:
395
+ success = False
396
+
397
+ return success
398
+
399
+
400
+ def cancel_conversation(
401
+ self,
402
+ conversation_id: str
403
+ ) -> bool:
404
+ """Cancel the entire conversation (synchronous version)
405
+
406
+ Args:
407
+ conversation_id: Conversation identifier
408
+
409
+ Returns:
410
+ bool: Whether the cancellation was successful
411
+ """
412
+
413
+ loop = asyncio.get_event_loop()
414
+ if loop.is_running():
415
+ # 如果事件循环已经在运行,创建一个新的事件循环
416
+ new_loop = asyncio.new_event_loop()
417
+ asyncio.set_event_loop(new_loop)
418
+ loop = new_loop
419
+
420
+ try:
421
+ return loop.run_until_complete(
422
+ self.async_cancel_conversation(
423
+ conversation_id=conversation_id
424
+ ))
425
+ finally:
426
+ if loop != asyncio.get_event_loop():
427
+ loop.close()
428
+
429
+
430
+ async def async_continue_humanloop(
431
+ self,
432
+ conversation_id: str,
433
+ context: Dict[str, Any],
434
+ metadata: Optional[Dict[str, Any]] = None,
435
+ timeout: Optional[int] = None,
436
+ ) -> HumanLoopResult:
437
+ """Continue human-in-the-loop interaction
438
+
439
+ Args:
440
+ conversation_id: Conversation ID for multi-turn dialogues
441
+ context: Context information provided to human
442
+ metadata: Additional metadata
443
+ timeout: Request timeout in seconds
444
+
445
+ Returns:
446
+ HumanLoopResult: Result object containing request ID and status
447
+ """
448
+ # Check if conversation exists
449
+ conversation_info = self._get_conversation(conversation_id)
450
+ if not conversation_info:
451
+ return HumanLoopResult(
452
+ conversation_id=conversation_id,
453
+ request_id="",
454
+ loop_type=HumanLoopType.CONVERSATION,
455
+ status=HumanLoopStatus.ERROR,
456
+ error=f"Conversation '{conversation_id}' not found"
457
+ )
458
+
459
+ # Subclasses need to implement specific continuation logic
460
+ raise NotImplementedError("Subclasses must implement continue_humanloop")
461
+
462
+
463
+ def continue_humanloop(
464
+ self,
465
+ conversation_id: str,
466
+ context: Dict[str, Any],
467
+ metadata: Optional[Dict[str, Any]] = None,
468
+ timeout: Optional[int] = None,
469
+ ) -> HumanLoopResult:
470
+ """Continue human-in-the-loop interaction (synchronous version)
471
+
472
+ Args:
473
+ conversation_id: Conversation ID for multi-turn dialogues
474
+ context: Context information provided to human
475
+ metadata: Additional metadata
476
+ timeout: Request timeout in seconds
477
+
478
+ Returns:
479
+ HumanLoopResult: Result object containing request ID and status
480
+ """
481
+
482
+ loop = asyncio.get_event_loop()
483
+ if loop.is_running():
484
+ # 如果事件循环已经在运行,创建一个新的事件循环
485
+ new_loop = asyncio.new_event_loop()
486
+ asyncio.set_event_loop(new_loop)
487
+ loop = new_loop
488
+
489
+ try:
490
+ return loop.run_until_complete(
491
+ self.async_continue_humanloop(
492
+ conversation_id=conversation_id,
493
+ context=context,
494
+ metadata=metadata,
495
+ timeout=timeout
496
+ ))
497
+ finally:
498
+ if loop != asyncio.get_event_loop():
499
+ loop.close()
500
+
501
+ def async_get_conversation_history(self, conversation_id: str) -> List[Dict[str, Any]]:
502
+ """Get complete history for the specified conversation
503
+
504
+ Args:
505
+ conversation_id: Conversation identifier
506
+
507
+ Returns:
508
+ List[Dict[str, Any]]: List of conversation history records, each containing request ID,
509
+ status, context, response and other information
510
+ """
511
+ conversation_history = []
512
+ for request_id in self._get_conversation_requests(conversation_id):
513
+ request_key = (conversation_id, request_id)
514
+ if request_key in self._requests:
515
+ request_info = self._requests[request_key]
516
+ conversation_history.append({
517
+ "request_id": request_id,
518
+ "status": request_info.get("status").value if request_info.get("status") else None,
519
+ "context": request_info.get("context"),
520
+ "response": request_info.get("response"),
521
+ "responded_by": request_info.get("responded_by"),
522
+ "responded_at": request_info.get("responded_at")
523
+ })
524
+ return conversation_history
525
+
526
+ def get_conversation_history(self, conversation_id: str) -> List[Dict[str, Any]]:
527
+ """Get complete history for the specified conversation (synchronous version)
528
+
529
+ Args:
530
+ conversation_id: Conversation identifier
531
+
532
+ Returns:
533
+ List[Dict[str, Any]]: List of conversation history records, each containing request ID,
534
+ status, context, response and other information
535
+ """
536
+ loop = asyncio.get_event_loop()
537
+ if loop.is_running():
538
+ new_loop = asyncio.new_event_loop()
539
+ asyncio.set_event_loop(new_loop)
540
+ loop = new_loop
541
+
542
+ try:
543
+ return loop.run_until_complete(self.async_get_conversation_history(conversation_id))
544
+ finally:
545
+ if loop != asyncio.get_event_loop():
546
+ loop.close()
547
+
548
+
549
+ async def _async_create_timeout_task(
550
+ self,
551
+ conversation_id: str,
552
+ request_id: str,
553
+ timeout: int
554
+ ):
555
+ """Create timeout task
556
+
557
+ Args:
558
+ conversation_id: Conversation ID
559
+ request_id: Request ID
560
+ timeout: Timeout duration in seconds
561
+ """
562
+ async def timeout_task():
563
+ await asyncio.sleep(timeout)
564
+
565
+ # Check current status
566
+ request_info = self._get_request(conversation_id, request_id)
567
+ if not request_info:
568
+ return
569
+
570
+ current_status = request_info.get("status", HumanLoopStatus.PENDING)
571
+
572
+ # Only trigger timeout when status is PENDING
573
+ # INPROGRESS status means conversation is ongoing, should not be considered as timeout
574
+ if current_status == HumanLoopStatus.PENDING:
575
+ # Update request status to expired
576
+ request_info["status"] = HumanLoopStatus.EXPIRED
577
+ request_info["error"] = "Request timed out"
578
+ # If status is INPROGRESS, reset timeout task
579
+ elif current_status == HumanLoopStatus.INPROGRESS:
580
+ # For ongoing conversations, we can choose to extend the timeout
581
+ # Here we simply create a new timeout task with the same timeout duration
582
+ if (conversation_id, request_id) in self._timeout_tasks:
583
+ self._timeout_tasks[(conversation_id, request_id)].cancel()
584
+ new_task = asyncio.create_task(timeout_task())
585
+ self._timeout_tasks[(conversation_id, request_id)] = new_task
586
+
587
+ task = asyncio.create_task(timeout_task())
588
+ self._timeout_tasks[(conversation_id, request_id)] = task
589
+
590
+
591
+ def build_prompt(
592
+ self,
593
+ task_id: str,
594
+ conversation_id: str,
595
+ request_id: str,
596
+ loop_type: Any,
597
+ created_at: str,
598
+ context: Dict[str, Any],
599
+ metadata: Optional[Dict[str, Any]] = None,
600
+ color: Optional[bool] = None
601
+ ) -> str:
602
+ """
603
+ Dynamically generate prompt based on content, only showing sections with content,
604
+ and adapt to different terminal color display.
605
+ color: None=auto detect, True=force color, False=no color
606
+ """
607
+ # Auto detect if terminal supports ANSI colors
608
+ def _supports_color():
609
+ try:
610
+ import sys
611
+ if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
612
+ return False
613
+ import os
614
+ if os.name == "nt":
615
+ # Windows 10+ supports ANSI, older versions don't
616
+ return "ANSICON" in os.environ or "WT_SESSION" in os.environ
617
+ return True
618
+ except Exception:
619
+ return False
620
+
621
+ if color is None:
622
+ color = _supports_color()
623
+
624
+ # Define colors
625
+ if color:
626
+ COLOR_TITLE = "\033[94m" # bright blue
627
+ COLOR_RESET = "\033[0m"
628
+ else:
629
+ COLOR_TITLE = ""
630
+ COLOR_RESET = ""
631
+
632
+ lines = []
633
+ lines.append(f"{COLOR_TITLE}=== Task Information ==={COLOR_RESET}")
634
+ lines.append(f"Task ID: {task_id}")
635
+ lines.append(f"Conversation ID: {conversation_id}")
636
+ lines.append(f"Request ID: {request_id}")
637
+ lines.append(f"HumanLoop Type: {getattr(loop_type, 'value', loop_type)}")
638
+ lines.append(f"Created At: {created_at}")
639
+
640
+ if context.get("message"):
641
+ lines.append(f"\n{COLOR_TITLE}=== Main Context ==={COLOR_RESET}")
642
+ lines.append(json.dumps(context["message"], indent=2, ensure_ascii=False))
643
+
644
+ if context.get("additional"):
645
+ lines.append(f"\n{COLOR_TITLE}=== Additional Context ==={COLOR_RESET}")
646
+ lines.append(json.dumps(context["additional"], indent=2, ensure_ascii=False))
647
+
648
+ if metadata:
649
+ lines.append(f"\n{COLOR_TITLE}=== Metadata ==={COLOR_RESET}")
650
+ lines.append(json.dumps(metadata, indent=2, ensure_ascii=False))
651
+
652
+ if context.get("question"):
653
+ lines.append(f"\n{COLOR_TITLE}=== Question ==={COLOR_RESET}")
654
+ lines.append(str(context["question"]))
655
+
656
+ lines.append(f"\n{COLOR_TITLE}=== END ==={COLOR_RESET}")
657
+
658
+ return "\n".join(lines)