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