process-gpt-agent-sdk 0.2.11__py3-none-any.whl → 0.3.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.

Potentially problematic release.


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

@@ -0,0 +1,518 @@
1
+ import asyncio
2
+ import logging
3
+ import json
4
+ import os
5
+ import signal
6
+ import uuid
7
+ from typing import Dict, Any, Optional
8
+ from dataclasses import dataclass
9
+
10
+ from dotenv import load_dotenv
11
+
12
+ from a2a.server.agent_execution import AgentExecutor, RequestContext
13
+ from a2a.server.events import EventQueue, Event
14
+ from a2a.types import TaskArtifactUpdateEvent, TaskState, TaskStatusUpdateEvent
15
+
16
+ from .database import (
17
+ initialize_db,
18
+ polling_pending_todos,
19
+ record_events_bulk,
20
+ record_event,
21
+ save_task_result,
22
+ update_task_error,
23
+ get_consumer_id,
24
+ fetch_form_def,
25
+ fetch_users_grouped,
26
+ fetch_email_users_by_proc_inst_id,
27
+ fetch_tenant_mcp,
28
+ )
29
+ from .utils import summarize_error_to_user, summarize_feedback, set_agent_model
30
+
31
+ load_dotenv()
32
+ logging.basicConfig(level=logging.INFO)
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # ------------------------------ 커스텀 예외 ------------------------------
36
+ class ContextPreparationError(Exception):
37
+ """컨텍스트 준비 실패를 상위 경계에서 단일 처리하기 위한 래퍼 예외."""
38
+ def __init__(self, original: Exception, friendly: Optional[str] = None):
39
+ super().__init__(f"{type(original).__name__}: {str(original)}")
40
+ self.original = original
41
+ self.friendly = friendly
42
+
43
+ # ------------------------------ Event Coalescing (env tunable) ------------------------------
44
+ COALESCE_DELAY = float(os.getenv("EVENT_COALESCE_DELAY_SEC", "1.0")) # 최대 지연
45
+ COALESCE_BATCH = int(os.getenv("EVENT_COALESCE_BATCH", "3")) # 즉시 flush 임계치
46
+
47
+ _EVENT_BUF: list[Dict[str, Any]] = []
48
+ _EVENT_TIMER: Optional[asyncio.TimerHandle] = None
49
+ _EVENT_LOCK = asyncio.Lock()
50
+
51
+ async def _flush_events_now():
52
+ """버퍼된 이벤트를 bulk RPC로 즉시 저장"""
53
+ global _EVENT_BUF, _EVENT_TIMER
54
+ async with _EVENT_LOCK:
55
+ buf = _EVENT_BUF[:]
56
+ _EVENT_BUF.clear()
57
+ if _EVENT_TIMER and not _EVENT_TIMER.cancelled():
58
+ _EVENT_TIMER.cancel()
59
+ _EVENT_TIMER = None
60
+ if not buf:
61
+ return
62
+
63
+ logger.info("📤 이벤트 버퍼 플러시 시작 - %d개 이벤트", len(buf))
64
+ # 실제 성공/실패 로깅은 record_events_bulk 내부에서 수행
65
+ await record_events_bulk(buf)
66
+ # 여기서는 시도 사실만 남김(성공처럼 보이는 'flushed' 오해 방지)
67
+ logger.info("🔄 이벤트 버퍼 플러시 시도 완료 - %d개 이벤트", len(buf))
68
+
69
+ def _schedule_delayed_flush():
70
+ global _EVENT_TIMER
71
+ if _EVENT_TIMER is None:
72
+ loop = asyncio.get_running_loop()
73
+ _EVENT_TIMER = loop.call_later(COALESCE_DELAY, lambda: asyncio.create_task(_flush_events_now()))
74
+
75
+ async def enqueue_ui_event_coalesced(payload: Dict[str, Any]):
76
+ """1초 코얼레싱 / COALESCE_BATCH개 모이면 즉시 플러시 (환경변수로 조절 가능)"""
77
+ global _EVENT_BUF
78
+ to_flush_now = False
79
+ async with _EVENT_LOCK:
80
+ _EVENT_BUF.append(payload)
81
+ logger.info("📥 이벤트 버퍼에 추가 - 현재 %d개 (임계치: %d개)", len(_EVENT_BUF), COALESCE_BATCH)
82
+ if len(_EVENT_BUF) >= COALESCE_BATCH:
83
+ to_flush_now = True
84
+ logger.info("⚡ 임계치 도달 - 즉시 플러시 예정")
85
+ else:
86
+ _schedule_delayed_flush()
87
+ logger.info("⏰ 지연 플러시 스케줄링")
88
+ if to_flush_now:
89
+ await _flush_events_now()
90
+
91
+ # ------------------------------ Request Context ------------------------------
92
+ @dataclass
93
+ class TodoListRowContext:
94
+ row: Dict[str, Any]
95
+
96
+ class ProcessGPTRequestContext(RequestContext):
97
+ def __init__(self, row: Dict[str, Any]):
98
+ self.row = row
99
+ self._user_input = (row.get("query") or "").strip()
100
+ self._message = self._user_input
101
+ self._current_task = None
102
+ self._task_state = row.get("draft_status") or ""
103
+ self._extra_context: Dict[str, Any] = {}
104
+
105
+ async def prepare_context(self) -> None:
106
+ """익스큐터를 위한 컨텍스트 준비를 합니다."""
107
+
108
+ effective_proc_inst_id = self.row.get("root_proc_inst_id") or self.row.get("proc_inst_id")
109
+ tool_val = self.row.get("tool") or ""
110
+ tenant_id = self.row.get("tenant_id") or ""
111
+ user_ids = self.row.get("user_id") or ""
112
+
113
+ try:
114
+ # 데이터베이스 조회
115
+ user_id_list = [u.strip() for u in (user_ids or '').split(',') if u.strip()]
116
+ notify_task = fetch_email_users_by_proc_inst_id(effective_proc_inst_id)
117
+ mcp_task = fetch_tenant_mcp(tenant_id)
118
+ form_task = fetch_form_def(tool_val, tenant_id)
119
+ users_task = fetch_users_grouped(user_id_list)
120
+
121
+ notify_emails, tenant_mcp, form_tuple, users_group = await asyncio.gather(
122
+ notify_task, mcp_task, form_task, users_task
123
+ )
124
+ form_id, form_fields, form_html = form_tuple
125
+ agents, users = users_group
126
+
127
+ # 글로벌 에이전트 모델 설정
128
+ set_agent_model(agents[0] if agents else None)
129
+
130
+ logger.info("\n\n🔍 [데이터베이스 조회 결과]")
131
+
132
+ # Users 정보
133
+ if users:
134
+ user_info = []
135
+ for u in users[:5]:
136
+ name = u.get("name", u.get("user_name", "Unknown"))
137
+ email = u.get("email", "")
138
+ user_info.append(f"{name}({email})" if email else name)
139
+ logger.info("• Users (%d명): %s%s", len(users), ", ".join(user_info), "..." if len(users) > 5 else "")
140
+ else:
141
+ logger.info("• Users: 없음")
142
+
143
+ # Agents 정보
144
+ if agents:
145
+ agent_info = []
146
+ for a in agents:
147
+ name = a.get("name", a.get("agent_name", "Unknown"))
148
+ tools = a.get("tools", "")
149
+ tool_str = f"[{tools}]" if tools else ""
150
+ agent_info.append(f"{name}{tool_str}")
151
+ logger.info("• Agents (%d개): %s%s", len(agents), ", ".join(agent_info), "..." if len(agents) > 5 else "")
152
+ else:
153
+ logger.info("• Agents: 없음")
154
+
155
+ # Form 정보
156
+ if form_fields:
157
+ pretty_json = json.dumps(form_fields, ensure_ascii=False, separators=(',', ':'))
158
+ logger.info("• Form: %s (%d개 필드) - %s", form_id, len(form_fields), pretty_json)
159
+ else:
160
+ logger.info("• Form: %s (필드 없음)", form_id)
161
+
162
+ # Notify 정보
163
+ if notify_emails:
164
+ email_list = notify_emails.split(',') if ',' in notify_emails else [notify_emails]
165
+ logger.info("• Notify (%d개): %s", len(email_list),
166
+ ", ".join(email_list[:3]) + ("..." if len(email_list) > 3 else ""))
167
+ else:
168
+ logger.info("• Notify: 없음")
169
+
170
+ # MCP 정보 - 상세 표시
171
+ if tenant_mcp:
172
+ logger.info("• %s 테넌트에 연결된 MCP 설정 정보가 존재합니다.", tenant_id)
173
+ else:
174
+ logger.info("• %s 테넌트에 연결된 MCP 설정 정보가 존재하지 않습니다.", tenant_id)
175
+
176
+ # 피드백 처리
177
+ feedback_data = self.row.get("feedback")
178
+ content_data = self.row.get("output") or self.row.get("draft")
179
+ summarized_feedback = ""
180
+ if feedback_data:
181
+ logger.info("\n\n📝 [피드백 처리]")
182
+ logger.info("• %d자 → AI 요약 중...", len(feedback_data))
183
+ summarized_feedback = await summarize_feedback(feedback_data, content_data)
184
+ logger.info("• 요약 완료: %d자", len(summarized_feedback))
185
+
186
+ # 컨텍스트 구성
187
+ self._extra_context = {
188
+ "id": self.row.get("id"),
189
+ "proc_inst_id": effective_proc_inst_id,
190
+ "root_proc_inst_id": self.row.get("root_proc_inst_id"),
191
+ "activity_name": self.row.get("activity_name"),
192
+ "agents": agents,
193
+ "users": users,
194
+ "tenant_mcp": tenant_mcp,
195
+ "form_fields": form_fields,
196
+ "form_html": form_html,
197
+ "form_id": form_id,
198
+ "notify_user_emails": notify_emails,
199
+ "summarized_feedback": summarized_feedback,
200
+ }
201
+
202
+ logger.info("\n\n🎉 [컨텍스트 준비 완료] 모든 데이터 준비됨")
203
+
204
+ except Exception as e:
205
+ logger.error("❌ [데이터 조회 실패] %s", str(e))
206
+ raise ContextPreparationError(e)
207
+
208
+ def get_user_input(self) -> str:
209
+ return self._user_input
210
+
211
+ @property
212
+ def message(self) -> str:
213
+ return self._message
214
+
215
+ @property
216
+ def current_task(self):
217
+ return self._current_task
218
+
219
+ @current_task.setter
220
+ def current_task(self, task):
221
+ self._current_task = task
222
+
223
+ @property
224
+ def task_state(self) -> str:
225
+ return self._task_state
226
+
227
+ def get_context_data(self) -> Dict[str, Any]:
228
+ return {"row": self.row, "extras": self._extra_context}
229
+
230
+ # ------------------------------ Event Queue ------------------------------
231
+ class ProcessGPTEventQueue(EventQueue):
232
+ def __init__(self, todolist_id: str, agent_orch: str, proc_inst_id: Optional[str]):
233
+ self.todolist_id = todolist_id
234
+ self.agent_orch = agent_orch
235
+ self.proc_inst_id = proc_inst_id
236
+ super().__init__()
237
+
238
+ def enqueue_event(self, event: Event):
239
+ try:
240
+ proc_inst_id_val = getattr(event, "contextId", None) or self.proc_inst_id
241
+ todo_id_val = getattr(event, "taskId", None) or str(self.todolist_id)
242
+ logger.info("\n\n📨 이벤트 수신: %s (task=%s)", type(event).__name__, self.todolist_id)
243
+
244
+ # 1) 결과물 저장
245
+ if isinstance(event, TaskArtifactUpdateEvent):
246
+ logger.info("📄 아티팩트 업데이트 이벤트 처리 중...")
247
+ try:
248
+ is_final = bool(
249
+ getattr(event, "final", None)
250
+ or getattr(event, "lastChunk", None)
251
+ or getattr(event, "last_chunk", None)
252
+ or getattr(event, "last", None)
253
+ )
254
+ artifact_content = self._extract_payload(event)
255
+ logger.info("💾 아티팩트 저장 중... (final=%s)", is_final)
256
+ asyncio.create_task(save_task_result(self.todolist_id, artifact_content, is_final))
257
+ logger.info("✅ 아티팩트 저장 완료")
258
+ except Exception as e:
259
+ logger.exception("❌ 아티팩트 저장 실패: %s", str(e))
260
+ return
261
+
262
+ # 2) 상태 이벤트 저장(코얼레싱 → bulk)
263
+ if isinstance(event, TaskStatusUpdateEvent):
264
+ logger.info("📊 상태 업데이트 이벤트 처리 중...")
265
+ metadata = getattr(event, "metadata", None) or {}
266
+ crew_type_val = metadata.get("crew_type")
267
+ status_obj = getattr(event, "status", None)
268
+ state_val = getattr(status_obj, "state", None)
269
+ event_type_val = {TaskState.input_required: "human_asked"}.get(state_val) or metadata.get("event_type")
270
+ status_val = metadata.get("status")
271
+ job_id_val = metadata.get("job_id")
272
+
273
+ logger.info("🔍 이벤트 메타데이터 분석 - event_type: %s, status: %s", event_type_val, status_val)
274
+
275
+ try:
276
+ payload: Dict[str, Any] = {
277
+ "id": str(uuid.uuid4()),
278
+ "job_id": job_id_val,
279
+ "todo_id": str(todo_id_val),
280
+ "proc_inst_id": proc_inst_id_val,
281
+ "crew_type": crew_type_val,
282
+ "event_type": event_type_val,
283
+ "data": self._extract_payload(event),
284
+ "status": status_val or None,
285
+ }
286
+ logger.info("📤 상태 이벤트 큐에 추가 중...")
287
+ asyncio.create_task(enqueue_ui_event_coalesced(payload))
288
+ logger.info("✅ 상태 이벤트 큐 추가 완료")
289
+ except Exception as e:
290
+ logger.exception("❌ 상태 이벤트 기록 실패: %s", str(e))
291
+ return
292
+
293
+ except Exception as e:
294
+ logger.error("❌ 이벤트 처리 실패: %s", str(e))
295
+ raise
296
+
297
+ def _extract_payload(self, event: Event) -> Any:
298
+ try:
299
+ artifact_or_none = getattr(event, "artifact", None)
300
+ status_or_none = getattr(event, "status", None)
301
+ message_or_none = getattr(status_or_none, "message", None)
302
+ source = artifact_or_none if artifact_or_none is not None else message_or_none
303
+ return self._parse_json_or_text(source)
304
+ except Exception:
305
+ return {}
306
+
307
+ def _parse_json_or_text(self, value: Any) -> Any:
308
+ try:
309
+ if value is None:
310
+ return {}
311
+ if isinstance(value, str):
312
+ text = value.strip()
313
+ if not text:
314
+ return ""
315
+ try:
316
+ return json.loads(text)
317
+ except Exception:
318
+ return text
319
+ if hasattr(value, "model_dump") and callable(getattr(value, "model_dump")):
320
+ value = value.model_dump()
321
+ elif not isinstance(value, dict) and hasattr(value, "dict") and callable(getattr(value, "dict")):
322
+ value = value.dict()
323
+ elif not isinstance(value, dict) and hasattr(value, "__dict__"):
324
+ value = value.__dict__
325
+ if isinstance(value, dict):
326
+ parts = value.get("parts")
327
+ if isinstance(parts, list) and parts:
328
+ first = parts[0] if isinstance(parts[0], dict) else None
329
+ if first and isinstance(first, dict):
330
+ txt = first.get("text") or first.get("content") or first.get("data")
331
+ if isinstance(txt, str):
332
+ try:
333
+ return json.loads(txt)
334
+ except Exception:
335
+ return txt
336
+ top_text = value.get("text") or value.get("content") or value.get("data")
337
+ if isinstance(top_text, str):
338
+ try:
339
+ return json.loads(top_text)
340
+ except Exception:
341
+ return top_text
342
+ return value
343
+ return value
344
+ except Exception:
345
+ return {}
346
+
347
+ def task_done(self) -> None:
348
+ try:
349
+ logger.info("🏁 작업 완료 이벤트 생성 중...")
350
+ payload: Dict[str, Any] = {
351
+ "id": str(uuid.uuid4()),
352
+ "job_id": "CREW_FINISHED",
353
+ "todo_id": str(self.todolist_id),
354
+ "proc_inst_id": self.proc_inst_id,
355
+ "crew_type": "agent",
356
+ "data": "Task completed successfully",
357
+ "event_type": "crew_completed",
358
+ "status": None,
359
+ }
360
+ logger.info("📤 작업 완료 이벤트 큐에 추가 중...")
361
+ asyncio.create_task(enqueue_ui_event_coalesced(payload))
362
+ logger.info("✅ 작업 완료 이벤트 기록 완료")
363
+ except Exception as e:
364
+ logger.error("❌ 작업 완료 이벤트 기록 실패: %s", str(e))
365
+ raise
366
+
367
+ # ------------------------------ Agent Server ------------------------------
368
+ class ProcessGPTAgentServer:
369
+ def __init__(self, agent_executor: AgentExecutor, agent_type: str):
370
+ self.agent_executor = agent_executor
371
+ self.agent_orch = agent_type
372
+ self.is_running = False
373
+ self._shutdown_event = asyncio.Event()
374
+ self._current_todo_id: Optional[str] = None # 진행 중 작업 추적(참고용)
375
+
376
+ async def _install_signal_handlers(self):
377
+ loop = asyncio.get_running_loop()
378
+ try:
379
+ loop.add_signal_handler(signal.SIGTERM, lambda: self._shutdown_event.set())
380
+ loop.add_signal_handler(signal.SIGINT, lambda: self._shutdown_event.set())
381
+ except NotImplementedError:
382
+ # Windows 등 일부 환경은 지원 안 됨
383
+ pass
384
+
385
+ async def run(self):
386
+ self.is_running = True
387
+ logger.info("\n\n🚀 ProcessGPT Agent Server START (agent=%s)\n", self.agent_orch)
388
+ initialize_db()
389
+ await self._install_signal_handlers()
390
+
391
+ while self.is_running and not self._shutdown_event.is_set():
392
+ try:
393
+ logger.info("🔍 [폴링 시작] 작업 대기 중... (agent_orch=%s)", self.agent_orch)
394
+
395
+ row = await polling_pending_todos(self.agent_orch, get_consumer_id())
396
+
397
+ if row:
398
+ logger.info("✅ [새 작업 발견] Task ID: %s", row.get("id"))
399
+ logger.info("• Activity: %s | Tool: %s | Tenant: %s",
400
+ row.get("activity_name"), row.get("tool"), row.get("tenant_id"))
401
+ try:
402
+ self._current_todo_id = str(row.get("id"))
403
+ await self.process_todolist_item(row)
404
+ except Exception as e:
405
+ # 경계에서 처리(에러 이벤트 + FAILED 마킹) 후 예외 재전달됨.
406
+ logger.exception("process_todolist_item failed: %s", str(e))
407
+ finally:
408
+ self._current_todo_id = None
409
+ # 작업이 있었으므로 슬립 생략 → 즉시 다음 폴링
410
+ continue
411
+
412
+ # 작업 없을 때만 10초 대기
413
+ await asyncio.sleep(10)
414
+
415
+ except Exception as e:
416
+ # 폴링 자체 오류는 특정 작업에 귀속되지 않으므로 상태 마킹 대상 없음
417
+ logger.exception("run loop error: %s", str(e))
418
+ await asyncio.sleep(10)
419
+
420
+ # 종료 시 남은 이벤트 강제 flush (오류로 간주하지 않음)
421
+ try:
422
+ await _flush_events_now()
423
+ logger.info("🧹 graceful shutdown: pending events flushed")
424
+ except Exception as e:
425
+ logger.exception("flush on shutdown failed: %s", str(e))
426
+
427
+ logger.info("👋 Agent server stopped.")
428
+
429
+ async def process_todolist_item(self, row: Dict[str, Any]):
430
+ """
431
+ 경계 정책(최종본):
432
+ - 어떤 예외든 여기에서 잡힘
433
+ - 항상 단일 경로로:
434
+ 1) 사용자 친화 5줄 설명 생성
435
+ 2) event_type='error' 단건 이벤트 기록
436
+ 3) todolist를 FAILED로 마킹
437
+ 4) 예외 재전달(상위 루프는 죽지 않고 다음 폴링)
438
+ """
439
+ task_id = row.get("id")
440
+ logger.info("\n🎯 [작업 처리 시작] Task ID: %s", task_id)
441
+
442
+ friendly_text: Optional[str] = None
443
+
444
+ try:
445
+ # 1) 컨텍스트 준비 (실패 시 ContextPreparationError로 올라옴)
446
+ context = ProcessGPTRequestContext(row)
447
+ await context.prepare_context()
448
+
449
+ # 2) 실행
450
+ logger.info("\n\n🤖 [Agent Orchestrator 실행]")
451
+ event_queue = ProcessGPTEventQueue(str(task_id), self.agent_orch, row.get("proc_inst_id"))
452
+ await self.agent_executor.execute(context, event_queue)
453
+ event_queue.task_done()
454
+ logger.info("\n\n🎉 [Agent Orchestrator 완료] Task ID: %s", task_id)
455
+
456
+ except Exception as e:
457
+ logger.error("❌ 작업 처리 중 오류 발생: %s", str(e))
458
+
459
+ # 컨텍스트 실패라면 friendly가 없을 수 있어, 여기서 반드시 생성
460
+ try:
461
+ logger.info("📝 사용자 친화 오류 메시지 생성 중...")
462
+ if isinstance(e, ContextPreparationError) and e.friendly:
463
+ friendly_text = e.friendly
464
+ else:
465
+ friendly_text = await summarize_error_to_user(
466
+ e if not isinstance(e, ContextPreparationError) else e.original,
467
+ {
468
+ "task_id": task_id,
469
+ "proc_inst_id": row.get("proc_inst_id"),
470
+ "agent_orch": self.agent_orch,
471
+ "tool": row.get("tool"),
472
+ },
473
+ )
474
+ logger.info("✅ 사용자 친화 오류 메시지 생성 완료")
475
+ except Exception:
476
+ logger.warning("⚠️ 사용자 친화 오류 메시지 생성 실패")
477
+ # 요약 생성 실패 시에도 처리 계속
478
+ friendly_text = None
479
+
480
+ # 에러 이벤트 기록(단건). 실패해도 로그만 남기고 진행.
481
+ logger.info("📤 오류 이벤트 기록 중...")
482
+ payload: Dict[str, Any] = {
483
+ "id": str(uuid.uuid4()),
484
+ "job_id": "TASK_ERROR",
485
+ "todo_id": str(task_id),
486
+ "proc_inst_id": row.get("proc_inst_id"),
487
+ "crew_type": "agent",
488
+ "event_type": "error",
489
+ "data": {
490
+ "name": "시스템 오류 알림",
491
+ "goal": "오류 원인과 대처 안내를 전달합니다.",
492
+ "agent_profile": "/images/chat-icon.png",
493
+ "friendly": friendly_text or "처리 중 오류가 발생했습니다. 로그를 확인해 주세요.",
494
+ "raw_error": f"{type(e).__name__}: {str(e)}" if not isinstance(e, ContextPreparationError) else f"{type(e.original).__name__}: {str(e.original)}",
495
+ }
496
+ }
497
+ try:
498
+ asyncio.create_task(record_event(payload))
499
+ logger.info("✅ 오류 이벤트 기록 완료")
500
+ except Exception:
501
+ logger.exception("❌ 오류 이벤트 기록 실패")
502
+
503
+ # 상태 FAILED 마킹
504
+ logger.info("🏷️ 작업 상태 FAILED로 마킹 중...")
505
+ try:
506
+ await update_task_error(str(task_id))
507
+ logger.info("✅ 작업 상태 FAILED 마킹 완료")
508
+ except Exception:
509
+ logger.exception("❌ 작업 상태 FAILED 마킹 실패")
510
+
511
+ # 상위로 재전달하여 루프는 계속(죽지 않음)
512
+ logger.error("🔄 오류 처리 완료 - 다음 작업으로 계속 진행")
513
+ raise
514
+
515
+ def stop(self):
516
+ self.is_running = False
517
+ self._shutdown_event.set()
518
+ logger.info("ProcessGPT Agent Server stopping...")