process-gpt-agent-sdk 0.3.10__tar.gz → 0.3.11__tar.gz

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.

Potentially problematic release.


This version of process-gpt-agent-sdk might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-gpt-agent-sdk
3
- Version: 0.3.10
3
+ Version: 0.3.11
4
4
  Summary: Supabase 기반 이벤트/작업 폴링으로 A2A AgentExecutor를 실행하는 SDK
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/your-org/process-gpt-agent-sdk
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-gpt-agent-sdk
3
- Version: 0.3.10
3
+ Version: 0.3.11
4
4
  Summary: Supabase 기반 이벤트/작업 폴링으로 A2A AgentExecutor를 실행하는 SDK
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/your-org/process-gpt-agent-sdk
@@ -5,4 +5,5 @@ process_gpt_agent_sdk.egg-info/SOURCES.txt
5
5
  process_gpt_agent_sdk.egg-info/dependency_links.txt
6
6
  process_gpt_agent_sdk.egg-info/requires.txt
7
7
  process_gpt_agent_sdk.egg-info/top_level.txt
8
+ processgpt_agent_sdk/database.py
8
9
  processgpt_agent_sdk/processgpt_agent_framework.py
@@ -0,0 +1,537 @@
1
+ import os
2
+ import json
3
+ import asyncio
4
+ import socket
5
+ import uuid
6
+ from typing import Any, Dict, List, Optional, Tuple, Callable, TypeVar
7
+
8
+ from dotenv import load_dotenv
9
+ from supabase import Client, create_client
10
+ import logging
11
+ import random
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ # 모듈 전역 로거 (정상 경로는 로깅하지 않고, 오류 시에만 사용)
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ # ============================================================================
21
+ # Utility: 재시도 헬퍼 및 유틸
22
+ # 설명: 동기 DB 호출을 안전하게 재시도 (지수 백오프 + 지터) 및 유틸
23
+ # ============================================================================
24
+
25
+ async def _async_retry(
26
+ fn: Callable[[], T],
27
+ *,
28
+ name: str,
29
+ retries: int = 3,
30
+ base_delay: float = 0.8,
31
+ fallback: Optional[Callable[[], T]] = None,
32
+ ) -> Optional[T]:
33
+ """지수 백오프+jitter로 재시도하고 실패 시 fallback/None 반환."""
34
+ last_err: Optional[Exception] = None
35
+ for attempt in range(1, retries + 1):
36
+ try:
37
+ return await asyncio.to_thread(fn)
38
+ except Exception as e:
39
+ last_err = e
40
+ jitter = random.uniform(0, 0.3)
41
+ delay = base_delay * (2 ** (attempt - 1)) + jitter
42
+ await asyncio.sleep(delay)
43
+ if last_err is not None:
44
+ logger.error(
45
+ "retry failed: name=%s retries=%s error=%s", name, retries, str(last_err),
46
+ exc_info=last_err,
47
+ )
48
+ if fallback is not None:
49
+ try:
50
+ fb_val = fallback()
51
+ return fb_val
52
+ except Exception as fb_err:
53
+ logger.error("fallback failed: name=%s error=%s", name, str(fb_err), exc_info=fb_err)
54
+ return None
55
+ return None
56
+
57
+
58
+ def _is_valid_uuid(value: str) -> bool:
59
+ """UUID 문자열 형식 검증 (v1~v8 포함)"""
60
+ try:
61
+ uuid.UUID(value)
62
+ return True
63
+ except Exception:
64
+ return False
65
+
66
+ def _to_jsonable(value: Any) -> Any:
67
+ """간단한 JSON 변환: dict 재귀, list/tuple/set→list, 기본형 유지, 나머지는 repr."""
68
+ try:
69
+ if value is None or isinstance(value, (str, int, float, bool)):
70
+ return value
71
+ if isinstance(value, dict):
72
+ return {str(k): _to_jsonable(v) for k, v in value.items()}
73
+ if isinstance(value, (list, tuple, set)):
74
+ return [_to_jsonable(v) for v in list(value)]
75
+ if hasattr(value, "__dict__"):
76
+ return _to_jsonable(vars(value))
77
+ return repr(value)
78
+ except Exception:
79
+ return repr(value)
80
+
81
+
82
+ # ============================================================================
83
+ # DB 연결/클라이언트
84
+ # 설명: 환경 변수 로드, Supabase 클라이언트 초기화/반환, 컨슈머 식별자
85
+ # ============================================================================
86
+ _supabase_client: Optional[Client] = None
87
+
88
+
89
+ def initialize_db() -> None:
90
+ """환경변수 로드 및 Supabase 클라이언트 초기화"""
91
+ global _supabase_client
92
+ if _supabase_client is not None:
93
+ return
94
+ try:
95
+ if os.getenv("ENV") != "production":
96
+ load_dotenv()
97
+ supabase_url = os.getenv("SUPABASE_URL") or os.getenv("SUPABASE_KEY_URL")
98
+ supabase_key = os.getenv("SUPABASE_KEY") or os.getenv("SUPABASE_ANON_KEY")
99
+ if not supabase_url or not supabase_key:
100
+ raise RuntimeError("SUPABASE_URL 및 SUPABASE_KEY가 필요합니다")
101
+ _supabase_client = create_client(supabase_url, supabase_key)
102
+ except Exception as e:
103
+ logger.error("initialize_db failed: %s", str(e), exc_info=e)
104
+ raise
105
+
106
+
107
+ def get_db_client() -> Client:
108
+ """초기화된 Supabase 클라이언트 반환."""
109
+ if _supabase_client is None:
110
+ raise RuntimeError("DB 미초기화: initialize_db() 먼저 호출")
111
+ return _supabase_client
112
+
113
+
114
+ def get_consumer_id() -> str:
115
+ """파드/프로세스 식별자 생성(CONSUMER_ID>HOST:PID)."""
116
+ env_consumer = os.getenv("CONSUMER_ID")
117
+ if env_consumer:
118
+ return env_consumer
119
+ host = socket.gethostname()
120
+ pid = os.getpid()
121
+ return f"{host}:{pid}"
122
+
123
+
124
+ # ============================================================================
125
+ # 데이터 조회
126
+ # 설명: TODOLIST 테이블 조회, 완료 output 목록 조회, 이벤트 조회, 폼 조회, 테넌트 MCP 설정 조회, 사용자 및 에이전트 조회
127
+ # ============================================================================
128
+ async def polling_pending_todos(agent_orch: str, consumer: str) -> Optional[Dict[str, Any]]:
129
+ """TODOLIST 테이블에서 대기중인 워크아이템을 조회 (agent_orch 전달).
130
+
131
+ - 정상 동작 시 로그를 남기지 않는다.
132
+ - 예외 시에만 풍부한 에러 정보를 남기되, 호출자에게 None을 반환하여 폴링 루프가 중단되지 않게 한다.
133
+ """
134
+ if agent_orch is None:
135
+ agent_orch = ""
136
+ if consumer is None:
137
+ consumer = ""
138
+
139
+ def _call():
140
+ client = get_db_client()
141
+ consumer_id = consumer or socket.gethostname()
142
+ env = (os.getenv("ENV") or "").lower()
143
+
144
+ if env == "dev":
145
+ resp = client.rpc(
146
+ "fetch_pending_task_dev",
147
+ {"p_agent_orch": agent_orch, "p_consumer": consumer_id, "p_limit": 1, "p_tenant_id": "uengine"},
148
+ ).execute()
149
+ else:
150
+ resp = client.rpc(
151
+ "fetch_pending_task",
152
+ {"p_agent_orch": agent_orch, "p_consumer": consumer_id, "p_limit": 1},
153
+ ).execute()
154
+
155
+ rows = resp.data or []
156
+ return rows[0] if rows else None
157
+
158
+ return await _async_retry(_call, name="polling_pending_todos", fallback=lambda: None)
159
+
160
+
161
+ def fetch_human_response_sync(job_id: str) -> Optional[Dict[str, Any]]:
162
+ """events에서 특정 job_id의 human_response 조회"""
163
+ if not job_id:
164
+ return None
165
+ try:
166
+ client = get_db_client()
167
+ resp = (
168
+ client
169
+ .table("events")
170
+ .select("*")
171
+ .eq("job_id", job_id)
172
+ .eq("event_type", "human_response")
173
+ .execute()
174
+ )
175
+ rows = resp.data or []
176
+ return rows[0] if rows else None
177
+ except Exception as e:
178
+ logger.error("fetch_human_response_sync failed: %s", str(e), exc_info=e)
179
+ return None
180
+
181
+
182
+ async def fetch_task_status(todo_id: str) -> Optional[str]:
183
+ """todo의 draft_status를 조회한다."""
184
+ if not todo_id:
185
+ return None
186
+ def _call():
187
+ client = get_db_client()
188
+ return (
189
+ client.table("todolist").select("draft_status").eq("id", todo_id).single().execute()
190
+ )
191
+
192
+ try:
193
+ resp = await _async_retry(_call, name="fetch_task_status")
194
+ except Exception as e:
195
+ logger.error("fetch_task_status fatal: %s", str(e), exc_info=e)
196
+ return None
197
+ if not resp or not getattr(resp, "data", None):
198
+ return None
199
+ try:
200
+ return resp.data.get("draft_status")
201
+ except Exception as e:
202
+ logger.error("fetch_task_status parse error: %s", str(e), exc_info=e)
203
+ return None
204
+
205
+
206
+
207
+ async def fetch_all_agents() -> List[Dict[str, Any]]:
208
+ """모든 에이전트 목록을 정규화하여 반환한다."""
209
+ def _call():
210
+ client = get_db_client()
211
+ return (
212
+ client.table("users")
213
+ .select("id, username, role, goal, persona, tools, profile, model, tenant_id, is_agent")
214
+ .eq("is_agent", True)
215
+ .execute()
216
+ )
217
+
218
+ try:
219
+ resp = await _async_retry(_call, name="fetch_all_agents")
220
+ except Exception as e:
221
+ logger.error("fetch_all_agents fatal: %s", str(e), exc_info=e)
222
+ return []
223
+ rows = resp.data or [] if resp else []
224
+ try:
225
+ normalized: List[Dict[str, Any]] = []
226
+ for row in rows:
227
+ normalized.append(
228
+ {
229
+ "id": row.get("id"),
230
+ "name": row.get("username"),
231
+ "role": row.get("role"),
232
+ "goal": row.get("goal"),
233
+ "persona": row.get("persona"),
234
+ "tools": row.get("tools") or "mem0",
235
+ "profile": row.get("profile"),
236
+ "model": row.get("model"),
237
+ "tenant_id": row.get("tenant_id"),
238
+ "endpoint": row.get("endpoint"),
239
+ }
240
+ )
241
+ return normalized
242
+ except Exception as e:
243
+ logger.error("fetch_all_agents parse error: %s", str(e), exc_info=e)
244
+ return []
245
+
246
+
247
+ async def fetch_agent_data(user_ids: str) -> List[Dict[str, Any]]:
248
+ """TODOLIST의 user_id 값으로, 역할로 지정된 에이전트를 조회하고 정규화해 반환한다."""
249
+
250
+ raw_ids = [x.strip() for x in (user_ids or "").split(",") if x.strip()]
251
+ valid_ids = [x for x in raw_ids if _is_valid_uuid(x)]
252
+
253
+ if not valid_ids:
254
+ return await fetch_all_agents()
255
+
256
+ def _call():
257
+ client = get_db_client()
258
+ resp = (
259
+ client
260
+ .table("users")
261
+ .select("id, username, role, goal, persona, tools, profile, model, tenant_id, is_agent")
262
+ .in_("id", valid_ids)
263
+ .eq("is_agent", True)
264
+ .execute()
265
+ )
266
+ rows = resp.data or []
267
+ normalized: List[Dict[str, Any]] = []
268
+ for row in rows:
269
+ normalized.append(
270
+ {
271
+ "id": row.get("id"),
272
+ "name": row.get("username"),
273
+ "role": row.get("role"),
274
+ "goal": row.get("goal"),
275
+ "persona": row.get("persona"),
276
+ "tools": row.get("tools") or "mem0",
277
+ "profile": row.get("profile"),
278
+ "model": row.get("model"),
279
+ "tenant_id": row.get("tenant_id"),
280
+ "endpoint": row.get("endpoint"),
281
+ }
282
+ )
283
+ return normalized
284
+
285
+ try:
286
+ result = await _async_retry(_call, name="fetch_agent_data", fallback=lambda: [])
287
+ except Exception as e:
288
+ logger.error("fetch_agent_data fatal: %s", str(e), exc_info=e)
289
+ return await fetch_all_agents()
290
+
291
+ if not result:
292
+ return await fetch_all_agents()
293
+
294
+ return result
295
+
296
+
297
+ async def fetch_form_types(tool_val: str, tenant_id: str) -> Tuple[str, List[Dict[str, Any]], Optional[str]]:
298
+ """폼 타입 정의를 조회해 (form_id, fields, html)로 반환한다."""
299
+ if tool_val is None:
300
+ tool_val = ""
301
+ if tenant_id is None:
302
+ tenant_id = ""
303
+ form_id = tool_val[12:] if tool_val.startswith("formHandler:") else tool_val
304
+
305
+ def _call():
306
+ client = get_db_client()
307
+ resp = (
308
+ client
309
+ .table("form_def")
310
+ .select("fields_json, html")
311
+ .eq("id", form_id)
312
+ .eq("tenant_id", tenant_id)
313
+ .execute()
314
+ )
315
+ fields_json = resp.data[0].get("fields_json") if resp.data else None
316
+ form_html = resp.data[0].get("html") if resp.data else None
317
+ if not fields_json:
318
+ return form_id, [{"key": form_id, "type": "default", "text": ""}], form_html
319
+ return form_id, fields_json, form_html
320
+
321
+ try:
322
+ resp = await _async_retry(
323
+ _call,
324
+ name="fetch_form_types",
325
+ fallback=lambda: (form_id, [{"key": form_id, "type": "default", "text": ""}], None),
326
+ )
327
+ except Exception as e:
328
+ logger.error("fetch_form_types fatal: %s", str(e), exc_info=e)
329
+ resp = None
330
+ return resp if resp else (form_id, [{"key": form_id, "type": "default", "text": ""}], None)
331
+
332
+
333
+ async def fetch_tenant_mcp_config(tenant_id: str) -> Optional[Dict[str, Any]]:
334
+ """테넌트 MCP 설정을 조회해 반환한다."""
335
+ if not tenant_id:
336
+ return None
337
+ def _call():
338
+ client = get_db_client()
339
+ return client.table("tenants").select("mcp").eq("id", tenant_id).single().execute()
340
+
341
+ try:
342
+ resp = await _async_retry(_call, name="fetch_tenant_mcp_config", fallback=lambda: None)
343
+ except Exception as e:
344
+ logger.error("fetch_tenant_mcp_config fatal: %s", str(e), exc_info=e)
345
+ return None
346
+ return resp.data.get("mcp") if resp and getattr(resp, "data", None) else None
347
+
348
+
349
+ async def fetch_human_users_by_proc_inst_id(proc_inst_id: str) -> str:
350
+ """proc_inst_id로 현재 프로세스의 모든 사용자 이메일 목록을 쉼표로 반환한다."""
351
+ if not proc_inst_id:
352
+ return ""
353
+
354
+ def _sync():
355
+ try:
356
+ supabase = get_db_client()
357
+
358
+ resp = (
359
+ supabase
360
+ .table('todolist')
361
+ .select('user_id')
362
+ .eq('proc_inst_id', proc_inst_id)
363
+ .execute()
364
+ )
365
+
366
+ if not resp.data:
367
+ return ""
368
+
369
+ all_user_ids = set()
370
+ for row in resp.data:
371
+ user_id = row.get('user_id', '')
372
+ if user_id:
373
+ ids = [id.strip() for id in user_id.split(',') if id.strip()]
374
+ all_user_ids.update(ids)
375
+
376
+ if not all_user_ids:
377
+ return ""
378
+
379
+ human_user_emails = []
380
+ for user_id in all_user_ids:
381
+ if not _is_valid_uuid(user_id):
382
+ continue
383
+
384
+ user_resp = (
385
+ supabase
386
+ .table('users')
387
+ .select('id, email, is_agent')
388
+ .eq('id', user_id)
389
+ .execute()
390
+ )
391
+
392
+ if user_resp.data:
393
+ user = user_resp.data[0]
394
+ is_agent = user.get('is_agent')
395
+ if not is_agent:
396
+ email = (user.get('email') or '').strip()
397
+ if email:
398
+ human_user_emails.append(email)
399
+
400
+ return ','.join(human_user_emails)
401
+
402
+ except Exception as e:
403
+ logger.error("fetch_human_users_by_proc_inst_id failed: %s", str(e), exc_info=e)
404
+ return ""
405
+
406
+ return await asyncio.to_thread(_sync)
407
+
408
+
409
+ # ============================================================================
410
+ # 데이터 저장
411
+ # 설명: 이벤트/알림/작업 결과 저장
412
+ # ============================================================================
413
+ async def record_event(payload: Dict[str, Any]) -> None:
414
+ """UI용 events 테이블에 이벤트 기록 (전달된 payload 그대로 저장)"""
415
+ if payload is None:
416
+ logger.error("record_event invalid payload: None")
417
+ return
418
+ def _call():
419
+ client = get_db_client()
420
+ safe_payload = _to_jsonable(payload)
421
+ # 상태값이 빈 문자열이면 NULL로
422
+ if isinstance(safe_payload, dict):
423
+ status_val = safe_payload.get("status")
424
+ if status_val == "":
425
+ safe_payload["status"] = None
426
+ return client.table("events").insert(safe_payload).execute()
427
+
428
+ try:
429
+ resp = await _async_retry(_call, name="record_event", fallback=lambda: None)
430
+ except Exception as e:
431
+ try:
432
+ logger.error("record_event fatal: %s payload=%s", str(e), json.dumps(_to_jsonable(payload), ensure_ascii=False), exc_info=e)
433
+ except Exception:
434
+ logger.error("record_event fatal (payload dump failed): %s", str(e), exc_info=e)
435
+ return
436
+ if resp is None:
437
+ try:
438
+ logger.error("events insert 실패: payload=%s", json.dumps(_to_jsonable(payload), ensure_ascii=False))
439
+ except Exception:
440
+ logger.error("events insert 실패 (payload dump failed)")
441
+
442
+
443
+
444
+ async def save_task_result(todo_id: str, result: Any, final: bool = False) -> None:
445
+ """작업 결과를 저장한다. final=True 시 최종 저장."""
446
+ if not todo_id:
447
+ logger.error("save_task_result invalid todo_id: %s", str(todo_id))
448
+ return
449
+ # 안전한 직렬화: 실패 시 문자열화하여 저장자가 원인 파악 가능
450
+ def _safe_payload(val: Any) -> Any:
451
+ try:
452
+ return _to_jsonable(val)
453
+ except Exception as e:
454
+ logger.error("save_task_result payload serialization failed: %s", str(e), exc_info=e)
455
+ try:
456
+ return {"repr": repr(val)}
457
+ except Exception:
458
+ return {"error": "unserializable payload"}
459
+
460
+ def _call():
461
+ client = get_db_client()
462
+ payload = _safe_payload(result)
463
+ return client.rpc(
464
+ "save_task_result",
465
+ {"p_todo_id": todo_id, "p_payload": payload, "p_final": bool(final)},
466
+ ).execute()
467
+
468
+ try:
469
+ await _async_retry(_call, name="save_task_result", fallback=lambda: None)
470
+ except Exception as e:
471
+ logger.error("save_task_result fatal: %s", str(e), exc_info=e)
472
+
473
+
474
+ def save_notification(
475
+ *,
476
+ title: str,
477
+ notif_type: str,
478
+ description: Optional[str] = None,
479
+ user_ids_csv: Optional[str] = None,
480
+ tenant_id: Optional[str] = None,
481
+ url: Optional[str] = None,
482
+ from_user_id: Optional[str] = None,
483
+ ) -> None:
484
+ """notifications 테이블에 알림 저장"""
485
+ try:
486
+ # 대상 사용자가 없으면 작업 생략
487
+ if not user_ids_csv:
488
+ return
489
+
490
+ client = get_db_client()
491
+
492
+ user_ids: List[str] = [uid.strip() for uid in user_ids_csv.split(',') if uid and uid.strip()]
493
+ if not user_ids:
494
+ return
495
+
496
+ rows: List[Dict[str, Any]] = []
497
+ for uid in user_ids:
498
+ rows.append(
499
+ {
500
+ "id": str(uuid.uuid4()),
501
+ "user_id": uid,
502
+ "tenant_id": tenant_id,
503
+ "title": title,
504
+ "description": description,
505
+ "type": notif_type,
506
+ "url": url,
507
+ "from_user_id": from_user_id,
508
+ }
509
+ )
510
+
511
+ client.table("notifications").insert(rows).execute()
512
+ except Exception as e:
513
+ logger.error("save_notification failed: %s", str(e), exc_info=e)
514
+
515
+ # ============================================================================
516
+ # 상태 변경
517
+ # 설명: 실패 작업 상태 업데이트
518
+ # ============================================================================
519
+
520
+ async def update_task_error(todo_id: str) -> None:
521
+ """실패 작업의 상태를 FAILED로 갱신한다."""
522
+ if not todo_id:
523
+ return
524
+ def _call():
525
+ client = get_db_client()
526
+ return (
527
+ client
528
+ .table('todolist')
529
+ .update({'draft_status': 'FAILED', 'consumer': None})
530
+ .eq('id', todo_id)
531
+ .execute()
532
+ )
533
+
534
+ try:
535
+ await _async_retry(_call, name="update_task_error", fallback=lambda: None)
536
+ except Exception as e:
537
+ logger.error("update_task_error fatal: %s", str(e), exc_info=e)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "process-gpt-agent-sdk"
7
- version = "0.3.10"
7
+ version = "0.3.11"
8
8
  description = "Supabase 기반 이벤트/작업 폴링으로 A2A AgentExecutor를 실행하는 SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"