simplai-sdk 0.1.0__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.
- billing/__init__.py +6 -0
- billing/api.py +55 -0
- billing/client.py +14 -0
- billing/schema.py +15 -0
- constants/__init__.py +90 -0
- core/__init__.py +53 -0
- core/agents/__init__.py +42 -0
- core/agents/execution/__init__.py +49 -0
- core/agents/execution/api.py +283 -0
- core/agents/execution/client.py +1139 -0
- core/agents/models.py +99 -0
- core/workflows/WORKFLOW_ARCHITECTURE.md +417 -0
- core/workflows/__init__.py +31 -0
- core/workflows/bulk/__init__.py +14 -0
- core/workflows/bulk/api.py +202 -0
- core/workflows/bulk/client.py +115 -0
- core/workflows/bulk/schema.py +58 -0
- core/workflows/models.py +49 -0
- core/workflows/scheduling/__init__.py +9 -0
- core/workflows/scheduling/api.py +179 -0
- core/workflows/scheduling/client.py +128 -0
- core/workflows/scheduling/schema.py +74 -0
- core/workflows/tool_execution/__init__.py +16 -0
- core/workflows/tool_execution/api.py +172 -0
- core/workflows/tool_execution/client.py +195 -0
- core/workflows/tool_execution/schema.py +40 -0
- exceptions/__init__.py +21 -0
- simplai_sdk/__init__.py +7 -0
- simplai_sdk/simplai.py +239 -0
- simplai_sdk-0.1.0.dist-info/METADATA +728 -0
- simplai_sdk-0.1.0.dist-info/RECORD +42 -0
- simplai_sdk-0.1.0.dist-info/WHEEL +5 -0
- simplai_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- simplai_sdk-0.1.0.dist-info/top_level.txt +7 -0
- traces/__init__.py +1 -0
- traces/agents/__init__.py +55 -0
- traces/agents/api.py +350 -0
- traces/agents/client.py +697 -0
- traces/agents/models.py +249 -0
- traces/workflows/__init__.py +0 -0
- utils/__init__.py +0 -0
- utils/config.py +117 -0
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..models import AgentExecutionError, AgentMessage, AgentResult, AgentStatus, AgentStreamChunk
|
|
11
|
+
from constants import (
|
|
12
|
+
AGENT_CONVERSATION_FETCH_DETAILS_PATH,
|
|
13
|
+
AGENT_CONVERSATION_FETCH_PATH,
|
|
14
|
+
AGENT_CONVERSATION_PATH,
|
|
15
|
+
AGENT_STREAM_PATH,
|
|
16
|
+
DEFAULT_BASE_URL,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentClient:
|
|
21
|
+
"""Low-level HTTP client for the Simplai agent API.
|
|
22
|
+
|
|
23
|
+
This class is reusable and manages underlying HTTP clients for efficiency.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
api_key: PIM-SID key used for authenticating with the Simplai edge service.
|
|
27
|
+
base_url: Base URL of the Simplai edge service.
|
|
28
|
+
timeout: Default request timeout in seconds.
|
|
29
|
+
max_retries: Number of retries for transient HTTP errors.
|
|
30
|
+
backoff_factor: Base factor (in seconds) used for exponential backoff.
|
|
31
|
+
user_id: Optional user ID for agent conversations.
|
|
32
|
+
tenant_id: Optional tenant ID (defaults to "1").
|
|
33
|
+
project_id: Optional project ID.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Fallback: when fetchDetails returns no response text, get it via GET /conversation/fetch (SSE)
|
|
37
|
+
_CONVERSATION_FETCH_INITIAL_DELAY = 2.0
|
|
38
|
+
_CONVERSATION_FETCH_MAX_RETRIES = 15
|
|
39
|
+
_CONVERSATION_FETCH_RETRY_DELAY = 2.0
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
api_key: str,
|
|
44
|
+
*,
|
|
45
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
max_retries: int = 3,
|
|
48
|
+
backoff_factor: float = 0.5,
|
|
49
|
+
user_id: Optional[str] = None,
|
|
50
|
+
tenant_id: str = "1",
|
|
51
|
+
project_id: Optional[int] = None,
|
|
52
|
+
seller_id: Optional[str] = None,
|
|
53
|
+
client_id: Optional[str] = None,
|
|
54
|
+
seller_profile_id: Optional[str] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.api_key = api_key
|
|
57
|
+
self.base_url = base_url.rstrip("/")
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
self.max_retries = max_retries
|
|
60
|
+
self.backoff_factor = backoff_factor
|
|
61
|
+
self.user_id = user_id
|
|
62
|
+
self.tenant_id = tenant_id
|
|
63
|
+
self.project_id = project_id
|
|
64
|
+
self.seller_id = seller_id
|
|
65
|
+
self.client_id = client_id
|
|
66
|
+
self.seller_profile_id = seller_profile_id
|
|
67
|
+
|
|
68
|
+
self._sync_client: Optional[httpx.Client] = None
|
|
69
|
+
self._async_client: Optional[httpx.AsyncClient] = None
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Internal HTTP helpers
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _get_sync_client(self) -> httpx.Client:
|
|
76
|
+
if self._sync_client is None:
|
|
77
|
+
self._sync_client = httpx.Client(timeout=self.timeout)
|
|
78
|
+
return self._sync_client
|
|
79
|
+
|
|
80
|
+
def _get_async_client(self) -> httpx.AsyncClient:
|
|
81
|
+
if self._async_client is None:
|
|
82
|
+
self._async_client = httpx.AsyncClient(timeout=self.timeout)
|
|
83
|
+
return self._async_client
|
|
84
|
+
|
|
85
|
+
def _headers(self) -> Dict[str, str]:
|
|
86
|
+
headers = {
|
|
87
|
+
"PIM-SID": self.api_key, # Uppercase as shown in curl
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Required headers - X-USER-ID is mandatory for validation
|
|
92
|
+
# The backend uses X-USER-ID for userId, sellerId, and sellerProfileId
|
|
93
|
+
if self.user_id:
|
|
94
|
+
headers["X-USER-ID"] = self.user_id
|
|
95
|
+
else:
|
|
96
|
+
# Use seller_id as fallback, or default to "sdk-user"
|
|
97
|
+
headers["X-USER-ID"] = self.seller_id if self.seller_id else "sdk-user"
|
|
98
|
+
|
|
99
|
+
# X-TENANT-ID is required (defaults to "1" in __init__)
|
|
100
|
+
if self.tenant_id:
|
|
101
|
+
headers["X-TENANT-ID"] = self.tenant_id
|
|
102
|
+
|
|
103
|
+
# Optional headers
|
|
104
|
+
if self.project_id:
|
|
105
|
+
headers["X-PROJECT-ID"] = str(self.project_id)
|
|
106
|
+
if self.seller_id:
|
|
107
|
+
headers["X-SELLER-ID"] = self.seller_id
|
|
108
|
+
if self.client_id:
|
|
109
|
+
headers["X-CLIENT-ID"] = self.client_id
|
|
110
|
+
if self.seller_profile_id:
|
|
111
|
+
headers["X-SELLER-PROFILE-ID"] = self.seller_profile_id
|
|
112
|
+
|
|
113
|
+
return headers
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Public HTTP methods (sync)
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def chat_once(
|
|
120
|
+
self,
|
|
121
|
+
agent_id: str,
|
|
122
|
+
message: str,
|
|
123
|
+
chat_history: Optional[List[AgentMessage]] = None,
|
|
124
|
+
conversation_id: Optional[str] = None,
|
|
125
|
+
version_id: Optional[str] = None,
|
|
126
|
+
state_override: Optional[Dict[str, Any]] = None,
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
"""Send a single agent chat request and return the response."""
|
|
129
|
+
url = self.base_url + AGENT_CONVERSATION_PATH
|
|
130
|
+
# Use snake_case to match the actual API structure from curl
|
|
131
|
+
payload: Dict[str, Any] = {
|
|
132
|
+
# In the SimplAI app, "model" is a logical model name (e.g., "sync mode"),
|
|
133
|
+
# while app_id/model_id carry the actual agent identifier.
|
|
134
|
+
"model": "sync mode",
|
|
135
|
+
"app_id": agent_id,
|
|
136
|
+
"model_id": agent_id,
|
|
137
|
+
"action": "START_SCREEN",
|
|
138
|
+
"query": {
|
|
139
|
+
"message": message,
|
|
140
|
+
"message_type": "text", # snake_case
|
|
141
|
+
"message_category": "", # snake_case
|
|
142
|
+
},
|
|
143
|
+
"language_code": "EN", # snake_case
|
|
144
|
+
"source": "APP", # Must be one of: APP, MOBILE_WEB, WEB, SLACK, API, EMBED
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if conversation_id:
|
|
148
|
+
payload["conversation_id"] = conversation_id # snake_case
|
|
149
|
+
if version_id:
|
|
150
|
+
payload["version_id"] = version_id # snake_case
|
|
151
|
+
else:
|
|
152
|
+
payload["version_id"] = "latest" # Default as shown in curl
|
|
153
|
+
|
|
154
|
+
if state_override:
|
|
155
|
+
payload["state_override"] = state_override
|
|
156
|
+
else:
|
|
157
|
+
# Add default state_override if not provided
|
|
158
|
+
payload["state_override"] = {
|
|
159
|
+
"sys": {
|
|
160
|
+
"user_timezone": "UTC",
|
|
161
|
+
"language_code": "en-US"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if chat_history:
|
|
166
|
+
# Include chat_history in payload if explicitly provided
|
|
167
|
+
# Note: If conversation_id is provided but chat_history is None,
|
|
168
|
+
# the backend will automatically fetch history from the database
|
|
169
|
+
payload["chat_history"] = [
|
|
170
|
+
{"role": msg.role, "content": msg.content} for msg in chat_history
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
resp_json = self._request_with_retries_sync("POST", url, json=payload)
|
|
174
|
+
return resp_json
|
|
175
|
+
|
|
176
|
+
def fetch_message_once(
|
|
177
|
+
self,
|
|
178
|
+
conversation_id: str,
|
|
179
|
+
message_id: str,
|
|
180
|
+
) -> tuple[Dict[str, Any], Optional[str], Optional[str]]:
|
|
181
|
+
"""Fetch the status/result for a single message.
|
|
182
|
+
|
|
183
|
+
Uses the /conversation/fetchDetails endpoint which may return JSON or plain string.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Tuple of (response_dict, trace_id, node_id)
|
|
187
|
+
"""
|
|
188
|
+
url = self.base_url + AGENT_CONVERSATION_FETCH_DETAILS_PATH
|
|
189
|
+
params = {"cId": conversation_id, "mId": message_id}
|
|
190
|
+
client = self._get_sync_client()
|
|
191
|
+
|
|
192
|
+
response = client.get(url, headers=self._headers(), params=params)
|
|
193
|
+
response.raise_for_status()
|
|
194
|
+
|
|
195
|
+
# Extract trace_id and node_id from response headers
|
|
196
|
+
trace_id = response.headers.get("trace_id") or response.headers.get("trace-id")
|
|
197
|
+
node_id = response.headers.get("node_id") or response.headers.get("node-id")
|
|
198
|
+
|
|
199
|
+
# Get message_status from headers (0/1=processing, 2=completed)
|
|
200
|
+
message_status = response.headers.get("message_status")
|
|
201
|
+
if message_status:
|
|
202
|
+
try:
|
|
203
|
+
message_status = int(message_status)
|
|
204
|
+
except (ValueError, TypeError):
|
|
205
|
+
message_status = None
|
|
206
|
+
|
|
207
|
+
# Try to parse as JSON first
|
|
208
|
+
try:
|
|
209
|
+
resp_json = response.json()
|
|
210
|
+
# The response structure is: BaseRestResponse<Map<String, Object>>
|
|
211
|
+
# { "result": { messageId: Messages object } }
|
|
212
|
+
result = resp_json.get("result", {})
|
|
213
|
+
if isinstance(result, dict) and message_id in result:
|
|
214
|
+
message_data = result[message_id]
|
|
215
|
+
if isinstance(message_data, dict):
|
|
216
|
+
# The message_data is the Messages object with fields like:
|
|
217
|
+
# queryResult, messageStatus, id, etc.
|
|
218
|
+
query_result = message_data.get("queryResult") or message_data.get("query_result") or ""
|
|
219
|
+
msg_status = message_data.get("messageStatus") or message_data.get("message_status") or message_status
|
|
220
|
+
|
|
221
|
+
return ({
|
|
222
|
+
"result": {
|
|
223
|
+
"response": [{
|
|
224
|
+
"id": message_data.get("id") or message_id,
|
|
225
|
+
"queryResult": query_result,
|
|
226
|
+
"messageStatus": msg_status,
|
|
227
|
+
}]
|
|
228
|
+
}
|
|
229
|
+
}, trace_id, node_id)
|
|
230
|
+
# Fallback: return the raw JSON structure
|
|
231
|
+
return (resp_json, trace_id, node_id)
|
|
232
|
+
except (json.JSONDecodeError, ValueError):
|
|
233
|
+
# If not JSON, it might be a plain string (queryResult directly)
|
|
234
|
+
query_result = response.text.strip()
|
|
235
|
+
|
|
236
|
+
return ({
|
|
237
|
+
"result": {
|
|
238
|
+
"response": [{
|
|
239
|
+
"id": message_id,
|
|
240
|
+
"queryResult": query_result,
|
|
241
|
+
"messageStatus": message_status,
|
|
242
|
+
}]
|
|
243
|
+
}
|
|
244
|
+
}, trace_id, node_id)
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Public HTTP methods (async)
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
async def achat_once(
|
|
251
|
+
self,
|
|
252
|
+
agent_id: str,
|
|
253
|
+
message: str,
|
|
254
|
+
chat_history: Optional[List[AgentMessage]] = None,
|
|
255
|
+
conversation_id: Optional[str] = None,
|
|
256
|
+
version_id: Optional[str] = None,
|
|
257
|
+
state_override: Optional[Dict[str, Any]] = None,
|
|
258
|
+
) -> Dict[str, Any]:
|
|
259
|
+
"""Async variant of chat_once."""
|
|
260
|
+
url = self.base_url + AGENT_CONVERSATION_PATH
|
|
261
|
+
# Use snake_case to match the actual API structure from curl
|
|
262
|
+
payload: Dict[str, Any] = {
|
|
263
|
+
# Match the same semantics as in chat_once()
|
|
264
|
+
"model": "sync mode",
|
|
265
|
+
"app_id": agent_id,
|
|
266
|
+
"model_id": agent_id,
|
|
267
|
+
"action": "START_SCREEN",
|
|
268
|
+
"query": {
|
|
269
|
+
"message": message,
|
|
270
|
+
"message_type": "text", # snake_case
|
|
271
|
+
"message_category": "", # snake_case
|
|
272
|
+
},
|
|
273
|
+
"language_code": "EN", # snake_case
|
|
274
|
+
"source": "APP", # Must be one of: APP, MOBILE_WEB, WEB, SLACK, API, EMBED
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if conversation_id:
|
|
278
|
+
payload["conversation_id"] = conversation_id # snake_case
|
|
279
|
+
if version_id:
|
|
280
|
+
payload["version_id"] = version_id # snake_case
|
|
281
|
+
else:
|
|
282
|
+
payload["version_id"] = "latest" # Default as shown in curl
|
|
283
|
+
|
|
284
|
+
if state_override:
|
|
285
|
+
payload["state_override"] = state_override
|
|
286
|
+
else:
|
|
287
|
+
# Add default state_override if not provided
|
|
288
|
+
payload["state_override"] = {
|
|
289
|
+
"sys": {
|
|
290
|
+
"user_timezone": "UTC",
|
|
291
|
+
"language_code": "en-US"
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if chat_history:
|
|
296
|
+
# Include chat_history in payload if explicitly provided
|
|
297
|
+
# Note: If conversation_id is provided but chat_history is None,
|
|
298
|
+
# the backend will automatically fetch history from the database
|
|
299
|
+
payload["chat_history"] = [
|
|
300
|
+
{"role": msg.role, "content": msg.content} for msg in chat_history
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
resp_json = await self._request_with_retries_async("POST", url, json=payload)
|
|
304
|
+
return resp_json
|
|
305
|
+
|
|
306
|
+
async def afetch_message_once(
|
|
307
|
+
self,
|
|
308
|
+
conversation_id: str,
|
|
309
|
+
message_id: str,
|
|
310
|
+
) -> tuple[Dict[str, Any], Optional[str], Optional[str]]:
|
|
311
|
+
"""Async variant of fetch_message_once.
|
|
312
|
+
|
|
313
|
+
Uses the /conversation/fetchDetails endpoint which returns JSON format
|
|
314
|
+
instead of SSE, making it better for non-streaming polling.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Tuple of (response_dict, trace_id, node_id)
|
|
318
|
+
"""
|
|
319
|
+
url = self.base_url + AGENT_CONVERSATION_FETCH_DETAILS_PATH
|
|
320
|
+
params = {"cId": conversation_id, "mId": message_id}
|
|
321
|
+
client = self._get_async_client()
|
|
322
|
+
|
|
323
|
+
response = await client.get(url, headers=self._headers(), params=params)
|
|
324
|
+
response.raise_for_status()
|
|
325
|
+
|
|
326
|
+
# Extract trace_id and node_id from response headers
|
|
327
|
+
trace_id = response.headers.get("trace_id") or response.headers.get("trace-id")
|
|
328
|
+
node_id = response.headers.get("node_id") or response.headers.get("node-id")
|
|
329
|
+
|
|
330
|
+
# Get message_status from headers (0/1=processing, 2=completed)
|
|
331
|
+
message_status = response.headers.get("message_status")
|
|
332
|
+
if message_status:
|
|
333
|
+
try:
|
|
334
|
+
message_status = int(message_status)
|
|
335
|
+
except (ValueError, TypeError):
|
|
336
|
+
message_status = None
|
|
337
|
+
|
|
338
|
+
# Try to parse as JSON first
|
|
339
|
+
try:
|
|
340
|
+
resp_json = response.json()
|
|
341
|
+
|
|
342
|
+
# Check if trace_id and node_id are in the response body
|
|
343
|
+
if not trace_id:
|
|
344
|
+
trace_id = resp_json.get("trace_id") or resp_json.get("traceId")
|
|
345
|
+
if not node_id:
|
|
346
|
+
node_id = resp_json.get("node_id") or resp_json.get("nodeId") or resp_json.get("job_id") or resp_json.get("jobId")
|
|
347
|
+
|
|
348
|
+
# Also check in nested result structure
|
|
349
|
+
result = resp_json.get("result", {})
|
|
350
|
+
if isinstance(result, dict):
|
|
351
|
+
if not trace_id:
|
|
352
|
+
trace_id = result.get("trace_id") or result.get("traceId")
|
|
353
|
+
if not node_id:
|
|
354
|
+
node_id = result.get("node_id") or result.get("nodeId") or result.get("job_id") or result.get("jobId")
|
|
355
|
+
|
|
356
|
+
if message_id in result:
|
|
357
|
+
message_data = result[message_id]
|
|
358
|
+
if isinstance(message_data, dict):
|
|
359
|
+
# Check in message_data as well
|
|
360
|
+
if not trace_id:
|
|
361
|
+
trace_id = message_data.get("trace_id") or message_data.get("traceId")
|
|
362
|
+
if not node_id:
|
|
363
|
+
# Try node_id, nodeId, job_id, jobId
|
|
364
|
+
node_id = message_data.get("node_id") or message_data.get("nodeId") or message_data.get("job_id") or message_data.get("jobId")
|
|
365
|
+
|
|
366
|
+
# The message_data is the Messages object with fields like:
|
|
367
|
+
# queryResult, messageStatus, id, etc.
|
|
368
|
+
query_result = message_data.get("queryResult") or message_data.get("query_result") or ""
|
|
369
|
+
msg_status = message_data.get("messageStatus") or message_data.get("message_status") or message_status
|
|
370
|
+
|
|
371
|
+
return ({
|
|
372
|
+
"result": {
|
|
373
|
+
"response": [{
|
|
374
|
+
"id": message_data.get("id") or message_id,
|
|
375
|
+
"queryResult": query_result,
|
|
376
|
+
"messageStatus": msg_status,
|
|
377
|
+
}]
|
|
378
|
+
}
|
|
379
|
+
}, trace_id, node_id)
|
|
380
|
+
# Fallback: return the raw JSON structure
|
|
381
|
+
return (resp_json, trace_id, node_id)
|
|
382
|
+
except (json.JSONDecodeError, ValueError):
|
|
383
|
+
# If not JSON, it might be a plain string (queryResult directly)
|
|
384
|
+
query_result = response.text.strip()
|
|
385
|
+
|
|
386
|
+
return ({
|
|
387
|
+
"result": {
|
|
388
|
+
"response": [{
|
|
389
|
+
"id": message_id,
|
|
390
|
+
"queryResult": query_result,
|
|
391
|
+
"messageStatus": message_status,
|
|
392
|
+
}]
|
|
393
|
+
}
|
|
394
|
+
}, trace_id, node_id)
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _normalize_conversation_fetch_response(raw: str) -> str:
|
|
398
|
+
"""Treat backend placeholder 'processing' as empty."""
|
|
399
|
+
if not raw or raw.strip() == "processing":
|
|
400
|
+
return ""
|
|
401
|
+
return raw
|
|
402
|
+
|
|
403
|
+
def _fetch_response_via_conversation_fetch(
|
|
404
|
+
self, conversation_id: str, message_id: str
|
|
405
|
+
) -> str:
|
|
406
|
+
"""Sync: get agent response via GET /conversation/fetch (SSE) when fetchDetails returns no text."""
|
|
407
|
+
time.sleep(self._CONVERSATION_FETCH_INITIAL_DELAY)
|
|
408
|
+
url = self.base_url + AGENT_CONVERSATION_FETCH_PATH
|
|
409
|
+
params = {"cId": conversation_id, "mId": message_id}
|
|
410
|
+
client = self._get_sync_client()
|
|
411
|
+
for attempt in range(self._CONVERSATION_FETCH_MAX_RETRIES):
|
|
412
|
+
accumulated = ""
|
|
413
|
+
try:
|
|
414
|
+
with client.stream("GET", url, headers=self._headers(), params=params) as response:
|
|
415
|
+
response.raise_for_status()
|
|
416
|
+
for line in response.iter_lines():
|
|
417
|
+
content = self._parse_sse_line(line)
|
|
418
|
+
if content is not None and "endstream" not in content.lower():
|
|
419
|
+
accumulated += content
|
|
420
|
+
except Exception:
|
|
421
|
+
pass
|
|
422
|
+
result = self._normalize_conversation_fetch_response(accumulated)
|
|
423
|
+
if result:
|
|
424
|
+
return result
|
|
425
|
+
if attempt < self._CONVERSATION_FETCH_MAX_RETRIES - 1:
|
|
426
|
+
time.sleep(self._CONVERSATION_FETCH_RETRY_DELAY)
|
|
427
|
+
return ""
|
|
428
|
+
|
|
429
|
+
async def _afetch_response_via_conversation_fetch(
|
|
430
|
+
self, conversation_id: str, message_id: str
|
|
431
|
+
) -> str:
|
|
432
|
+
"""Get agent response via GET /conversation/fetch (SSE) when fetchDetails returns no text."""
|
|
433
|
+
await asyncio.sleep(self._CONVERSATION_FETCH_INITIAL_DELAY)
|
|
434
|
+
url = self.base_url + AGENT_CONVERSATION_FETCH_PATH
|
|
435
|
+
params = {"cId": conversation_id, "mId": message_id}
|
|
436
|
+
client = self._get_async_client()
|
|
437
|
+
for attempt in range(self._CONVERSATION_FETCH_MAX_RETRIES):
|
|
438
|
+
accumulated = ""
|
|
439
|
+
try:
|
|
440
|
+
async with client.stream("GET", url, headers=self._headers(), params=params) as response:
|
|
441
|
+
response.raise_for_status()
|
|
442
|
+
async for line in response.aiter_lines():
|
|
443
|
+
content = self._parse_sse_line(line)
|
|
444
|
+
if content is not None and "endstream" not in content.lower():
|
|
445
|
+
accumulated += content
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
result = self._normalize_conversation_fetch_response(accumulated)
|
|
449
|
+
if result:
|
|
450
|
+
return result
|
|
451
|
+
if attempt < self._CONVERSATION_FETCH_MAX_RETRIES - 1:
|
|
452
|
+
await asyncio.sleep(self._CONVERSATION_FETCH_RETRY_DELAY)
|
|
453
|
+
return ""
|
|
454
|
+
|
|
455
|
+
# ------------------------------------------------------------------
|
|
456
|
+
# High-level polling (sync)
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def chat_and_wait(
|
|
460
|
+
self,
|
|
461
|
+
agent_id: str,
|
|
462
|
+
message: str,
|
|
463
|
+
chat_history: Optional[List[AgentMessage]] = None,
|
|
464
|
+
conversation_id: Optional[str] = None,
|
|
465
|
+
version_id: Optional[str] = None,
|
|
466
|
+
state_override: Optional[Dict[str, Any]] = None,
|
|
467
|
+
*,
|
|
468
|
+
poll_interval: float = 2.0,
|
|
469
|
+
timeout: Optional[float] = None,
|
|
470
|
+
) -> AgentResult:
|
|
471
|
+
"""Send agent chat request and block until response is ready."""
|
|
472
|
+
# Create conversation
|
|
473
|
+
response = self.chat_once(
|
|
474
|
+
agent_id, message, chat_history, conversation_id, version_id, state_override
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Extract conversation_id and message_id from response
|
|
478
|
+
conv_id, msg_id = self._extract_ids(response)
|
|
479
|
+
start = time.monotonic()
|
|
480
|
+
|
|
481
|
+
# Poll for completion
|
|
482
|
+
while True:
|
|
483
|
+
if timeout is not None:
|
|
484
|
+
elapsed = time.monotonic() - start
|
|
485
|
+
if elapsed >= timeout:
|
|
486
|
+
raise AgentExecutionError(
|
|
487
|
+
f"Agent conversation {conv_id} timed out after {elapsed:.1f}s"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
status_payload, trace_id, node_id = self.fetch_message_once(conv_id, msg_id)
|
|
491
|
+
status, response_text = self._parse_message_response(status_payload)
|
|
492
|
+
|
|
493
|
+
if status in {AgentStatus.COMPLETED, AgentStatus.FAILED, AgentStatus.TIMEOUT}:
|
|
494
|
+
if status != AgentStatus.COMPLETED:
|
|
495
|
+
raise AgentExecutionError(
|
|
496
|
+
f"Agent conversation {conv_id} finished with status {status.value}"
|
|
497
|
+
)
|
|
498
|
+
if not response_text:
|
|
499
|
+
response_text = self._fetch_response_via_conversation_fetch(conv_id, msg_id)
|
|
500
|
+
# Re-fetch details so payload/trace_id/node_id reflect latest (backend may set trace_id after COMPLETED)
|
|
501
|
+
status_payload, trace_id, node_id = self.fetch_message_once(conv_id, msg_id)
|
|
502
|
+
return AgentResult(
|
|
503
|
+
conversation_id=conv_id,
|
|
504
|
+
message_id=msg_id,
|
|
505
|
+
status=status,
|
|
506
|
+
response=response_text,
|
|
507
|
+
payload=status_payload,
|
|
508
|
+
trace_id=trace_id,
|
|
509
|
+
node_id=node_id,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Still processing; sleep and poll again
|
|
513
|
+
time.sleep(poll_interval)
|
|
514
|
+
|
|
515
|
+
# ------------------------------------------------------------------
|
|
516
|
+
# High-level polling (async)
|
|
517
|
+
# ------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
async def achat_and_wait(
|
|
520
|
+
self,
|
|
521
|
+
agent_id: str,
|
|
522
|
+
message: str,
|
|
523
|
+
chat_history: Optional[List[AgentMessage]] = None,
|
|
524
|
+
conversation_id: Optional[str] = None,
|
|
525
|
+
version_id: Optional[str] = None,
|
|
526
|
+
state_override: Optional[Dict[str, Any]] = None,
|
|
527
|
+
*,
|
|
528
|
+
poll_interval: float = 2.0,
|
|
529
|
+
timeout: Optional[float] = None,
|
|
530
|
+
) -> AgentResult:
|
|
531
|
+
"""Async variant of chat_and_wait."""
|
|
532
|
+
# Create conversation
|
|
533
|
+
response = await self.achat_once(
|
|
534
|
+
agent_id, message, chat_history, conversation_id, version_id, state_override
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Extract conversation_id and message_id from response
|
|
538
|
+
conv_id, msg_id = self._extract_ids(response)
|
|
539
|
+
start = time.monotonic()
|
|
540
|
+
|
|
541
|
+
# Poll for completion
|
|
542
|
+
while True:
|
|
543
|
+
if timeout is not None:
|
|
544
|
+
elapsed = time.monotonic() - start
|
|
545
|
+
if elapsed >= timeout:
|
|
546
|
+
raise AgentExecutionError(
|
|
547
|
+
f"Agent conversation {conv_id} timed out after {elapsed:.1f}s"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
status_payload, trace_id, node_id = await self.afetch_message_once(conv_id, msg_id)
|
|
551
|
+
status, response_text = self._parse_message_response(status_payload)
|
|
552
|
+
|
|
553
|
+
if status in {AgentStatus.COMPLETED, AgentStatus.FAILED, AgentStatus.TIMEOUT}:
|
|
554
|
+
if status != AgentStatus.COMPLETED:
|
|
555
|
+
raise AgentExecutionError(
|
|
556
|
+
f"Agent conversation {conv_id} finished with status {status.value}"
|
|
557
|
+
)
|
|
558
|
+
if not response_text:
|
|
559
|
+
response_text = await self._afetch_response_via_conversation_fetch(
|
|
560
|
+
conv_id, msg_id
|
|
561
|
+
)
|
|
562
|
+
# Re-fetch details so payload/trace_id/node_id reflect latest (backend may set trace_id after COMPLETED)
|
|
563
|
+
status_payload, trace_id, node_id = await self.afetch_message_once(
|
|
564
|
+
conv_id, msg_id
|
|
565
|
+
)
|
|
566
|
+
return AgentResult(
|
|
567
|
+
conversation_id=conv_id,
|
|
568
|
+
message_id=msg_id,
|
|
569
|
+
status=status,
|
|
570
|
+
response=response_text,
|
|
571
|
+
payload=status_payload,
|
|
572
|
+
trace_id=trace_id,
|
|
573
|
+
node_id=node_id,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# Still processing; sleep and poll again
|
|
577
|
+
await asyncio.sleep(poll_interval)
|
|
578
|
+
|
|
579
|
+
# ------------------------------------------------------------------
|
|
580
|
+
# Streaming support
|
|
581
|
+
# ------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
def stream_chat(
|
|
584
|
+
self,
|
|
585
|
+
agent_id: str,
|
|
586
|
+
message: str,
|
|
587
|
+
chat_history: Optional[List[AgentMessage]] = None,
|
|
588
|
+
conversation_id: Optional[str] = None,
|
|
589
|
+
version_id: Optional[str] = None,
|
|
590
|
+
state_override: Optional[Dict[str, Any]] = None,
|
|
591
|
+
*,
|
|
592
|
+
on_chunk: Optional[Callable[[AgentStreamChunk], None]] = None,
|
|
593
|
+
) -> AgentResult:
|
|
594
|
+
"""Stream agent chat response and call on_chunk callback for each chunk."""
|
|
595
|
+
# Create conversation
|
|
596
|
+
response = self.chat_once(
|
|
597
|
+
agent_id, message, chat_history, conversation_id, version_id, state_override
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
conv_id, msg_id = self._extract_ids(response)
|
|
601
|
+
|
|
602
|
+
# Use messageId directly as the streaming key (Redis channel: chat:{messageId})
|
|
603
|
+
stream_url = self.base_url + AGENT_STREAM_PATH.format(key=msg_id)
|
|
604
|
+
client = self._get_sync_client()
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
with client.stream("GET", stream_url, headers=self._headers()) as response:
|
|
608
|
+
accumulated_content = ""
|
|
609
|
+
trace_id: Optional[str] = None
|
|
610
|
+
node_id: Optional[str] = None
|
|
611
|
+
tree_id: Optional[str] = None
|
|
612
|
+
first_chunk_processed = False
|
|
613
|
+
|
|
614
|
+
# Extract trace_id, node_id, and tree_id from response headers (if available)
|
|
615
|
+
trace_id = response.headers.get("trace_id") or response.headers.get("trace-id")
|
|
616
|
+
node_id = response.headers.get("node_id") or response.headers.get("node-id")
|
|
617
|
+
tree_id = response.headers.get("tree_id") or response.headers.get("tree-id")
|
|
618
|
+
|
|
619
|
+
for line in response.iter_lines():
|
|
620
|
+
# Parse SSE format (handles both SSE and plain text for backward compatibility)
|
|
621
|
+
content = self._parse_sse_line(line)
|
|
622
|
+
|
|
623
|
+
# Skip empty lines, comments, and event types
|
|
624
|
+
if content is None:
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
# Check for endstream marker (case-insensitive)
|
|
628
|
+
if "endstream" in content.lower():
|
|
629
|
+
# Stream ended
|
|
630
|
+
break
|
|
631
|
+
|
|
632
|
+
# Try to extract trace_id, node_id, and tree_id from first chunk if not in headers
|
|
633
|
+
if not first_chunk_processed:
|
|
634
|
+
first_chunk_processed = True
|
|
635
|
+
# Try parsing first chunk as JSON to extract trace info
|
|
636
|
+
if not trace_id or not node_id:
|
|
637
|
+
try:
|
|
638
|
+
import json
|
|
639
|
+
# First chunk might be JSON with trace info
|
|
640
|
+
chunk_data = json.loads(content)
|
|
641
|
+
if isinstance(chunk_data, dict):
|
|
642
|
+
trace_id = trace_id or chunk_data.get("trace_id") or chunk_data.get("traceId")
|
|
643
|
+
node_id = node_id or chunk_data.get("node_id") or chunk_data.get("nodeId")
|
|
644
|
+
tree_id = tree_id or chunk_data.get("tree_id") or chunk_data.get("treeId")
|
|
645
|
+
# If it's a JSON object, extract content separately
|
|
646
|
+
content = chunk_data.get("content", content)
|
|
647
|
+
except (json.JSONDecodeError, ValueError):
|
|
648
|
+
# Not JSON, use as-is
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
chunk = AgentStreamChunk(
|
|
652
|
+
content=content,
|
|
653
|
+
conversation_id=conv_id,
|
|
654
|
+
message_id=msg_id,
|
|
655
|
+
is_complete=False,
|
|
656
|
+
trace_id=trace_id,
|
|
657
|
+
node_id=node_id,
|
|
658
|
+
tree_id=tree_id,
|
|
659
|
+
)
|
|
660
|
+
accumulated_content += content
|
|
661
|
+
if on_chunk:
|
|
662
|
+
on_chunk(chunk)
|
|
663
|
+
|
|
664
|
+
# Final result
|
|
665
|
+
final_response, final_trace_id, final_node_id = self.fetch_message_once(conv_id, msg_id)
|
|
666
|
+
status, response_text = self._parse_message_response(final_response)
|
|
667
|
+
|
|
668
|
+
# Use trace_id and node_id from stream if available, otherwise from final response
|
|
669
|
+
result_trace_id = trace_id or final_trace_id
|
|
670
|
+
result_node_id = node_id or final_node_id
|
|
671
|
+
|
|
672
|
+
return AgentResult(
|
|
673
|
+
conversation_id=conv_id,
|
|
674
|
+
message_id=msg_id,
|
|
675
|
+
status=status,
|
|
676
|
+
response=response_text or accumulated_content,
|
|
677
|
+
payload=final_response,
|
|
678
|
+
trace_id=result_trace_id,
|
|
679
|
+
node_id=result_node_id,
|
|
680
|
+
)
|
|
681
|
+
except Exception as e:
|
|
682
|
+
raise AgentExecutionError(f"Streaming failed: {str(e)}") from e
|
|
683
|
+
|
|
684
|
+
async def astream_chat(
|
|
685
|
+
self,
|
|
686
|
+
agent_id: str,
|
|
687
|
+
message: str,
|
|
688
|
+
chat_history: Optional[List[AgentMessage]] = None,
|
|
689
|
+
conversation_id: Optional[str] = None,
|
|
690
|
+
version_id: Optional[str] = None,
|
|
691
|
+
state_override: Optional[Dict[str, Any]] = None,
|
|
692
|
+
*,
|
|
693
|
+
on_chunk: Optional[Callable[[AgentStreamChunk], None]] = None,
|
|
694
|
+
) -> AgentResult:
|
|
695
|
+
"""Async variant of stream_chat."""
|
|
696
|
+
# Create conversation
|
|
697
|
+
response = await self.achat_once(
|
|
698
|
+
agent_id, message, chat_history, conversation_id, version_id, state_override
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
conv_id, msg_id = self._extract_ids(response)
|
|
702
|
+
|
|
703
|
+
# Use messageId directly as the streaming key (Redis channel: chat:{messageId})
|
|
704
|
+
stream_url = self.base_url + AGENT_STREAM_PATH.format(key=msg_id)
|
|
705
|
+
client = self._get_async_client()
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
accumulated_content = ""
|
|
709
|
+
trace_id: Optional[str] = None
|
|
710
|
+
node_id: Optional[str] = None
|
|
711
|
+
first_chunk_processed = False
|
|
712
|
+
|
|
713
|
+
async with client.stream("GET", stream_url, headers=self._headers()) as response:
|
|
714
|
+
# Extract trace_id, node_id, and tree_id from response headers (if available)
|
|
715
|
+
trace_id = response.headers.get("trace_id") or response.headers.get("trace-id")
|
|
716
|
+
node_id = response.headers.get("node_id") or response.headers.get("node-id")
|
|
717
|
+
tree_id = response.headers.get("tree_id") or response.headers.get("tree-id")
|
|
718
|
+
|
|
719
|
+
async for line in response.aiter_lines():
|
|
720
|
+
# Parse SSE format (handles both SSE and plain text for backward compatibility)
|
|
721
|
+
content = self._parse_sse_line(line)
|
|
722
|
+
|
|
723
|
+
# Skip empty lines, comments, and event types
|
|
724
|
+
if content is None:
|
|
725
|
+
continue
|
|
726
|
+
|
|
727
|
+
# Check for endstream marker (case-insensitive)
|
|
728
|
+
if "endstream" in content.lower():
|
|
729
|
+
# Stream ended
|
|
730
|
+
break
|
|
731
|
+
|
|
732
|
+
# Try to extract trace_id, node_id, and tree_id from first chunk if not in headers
|
|
733
|
+
if not first_chunk_processed:
|
|
734
|
+
first_chunk_processed = True
|
|
735
|
+
# Try parsing first chunk as JSON to extract trace info
|
|
736
|
+
if not trace_id or not node_id:
|
|
737
|
+
try:
|
|
738
|
+
import json
|
|
739
|
+
# First chunk might be JSON with trace info
|
|
740
|
+
chunk_data = json.loads(content)
|
|
741
|
+
if isinstance(chunk_data, dict):
|
|
742
|
+
trace_id = trace_id or chunk_data.get("trace_id") or chunk_data.get("traceId")
|
|
743
|
+
node_id = node_id or chunk_data.get("node_id") or chunk_data.get("nodeId")
|
|
744
|
+
tree_id = tree_id or chunk_data.get("tree_id") or chunk_data.get("treeId")
|
|
745
|
+
# If it's a JSON object, extract content separately
|
|
746
|
+
content = chunk_data.get("content", content)
|
|
747
|
+
except (json.JSONDecodeError, ValueError):
|
|
748
|
+
# Not JSON, use as-is
|
|
749
|
+
pass
|
|
750
|
+
|
|
751
|
+
chunk = AgentStreamChunk(
|
|
752
|
+
content=content,
|
|
753
|
+
conversation_id=conv_id,
|
|
754
|
+
message_id=msg_id,
|
|
755
|
+
is_complete=False,
|
|
756
|
+
trace_id=trace_id,
|
|
757
|
+
node_id=node_id,
|
|
758
|
+
tree_id=tree_id,
|
|
759
|
+
)
|
|
760
|
+
accumulated_content += content
|
|
761
|
+
if on_chunk:
|
|
762
|
+
on_chunk(chunk)
|
|
763
|
+
|
|
764
|
+
# Final result
|
|
765
|
+
final_response, final_trace_id, final_node_id = await self.afetch_message_once(conv_id, msg_id)
|
|
766
|
+
status, response_text = self._parse_message_response(final_response)
|
|
767
|
+
|
|
768
|
+
# Use trace_id and node_id from stream if available, otherwise from final response
|
|
769
|
+
result_trace_id = trace_id or final_trace_id
|
|
770
|
+
result_node_id = node_id or final_node_id
|
|
771
|
+
|
|
772
|
+
return AgentResult(
|
|
773
|
+
conversation_id=conv_id,
|
|
774
|
+
message_id=msg_id,
|
|
775
|
+
status=status,
|
|
776
|
+
response=response_text or accumulated_content,
|
|
777
|
+
payload=final_response,
|
|
778
|
+
trace_id=result_trace_id,
|
|
779
|
+
node_id=result_node_id,
|
|
780
|
+
)
|
|
781
|
+
except Exception as e:
|
|
782
|
+
raise AgentExecutionError(f"Streaming failed: {str(e)}") from e
|
|
783
|
+
|
|
784
|
+
# ------------------------------------------------------------------
|
|
785
|
+
# Internal utilities
|
|
786
|
+
# ------------------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
def _request_with_retries_sync(
|
|
789
|
+
self,
|
|
790
|
+
method: str,
|
|
791
|
+
url: str,
|
|
792
|
+
*,
|
|
793
|
+
json: Optional[Dict[str, Any]] = None,
|
|
794
|
+
params: Optional[Dict[str, Any]] = None,
|
|
795
|
+
) -> Dict[str, Any]:
|
|
796
|
+
client = self._get_sync_client()
|
|
797
|
+
last_exc: Optional[BaseException] = None
|
|
798
|
+
|
|
799
|
+
for attempt in range(self.max_retries + 1):
|
|
800
|
+
try:
|
|
801
|
+
response = client.request(
|
|
802
|
+
method,
|
|
803
|
+
url,
|
|
804
|
+
headers=self._headers(),
|
|
805
|
+
json=json,
|
|
806
|
+
params=params,
|
|
807
|
+
)
|
|
808
|
+
# Check for 511 before raise_for_status (it's treated as server error but is actually auth error)
|
|
809
|
+
if response.status_code == 511:
|
|
810
|
+
body_preview = response.text[:500] if response.text else ""
|
|
811
|
+
headers_sent = {k: "***" if k.lower() == "pim-sid" else v for k, v in self._headers().items()}
|
|
812
|
+
raise AgentExecutionError(
|
|
813
|
+
f"Network Authentication Required (511)\n"
|
|
814
|
+
f"This usually means the API key is invalid or missing required headers.\n"
|
|
815
|
+
f"URL: {url}\n"
|
|
816
|
+
f"Response: {body_preview}\n"
|
|
817
|
+
f"Headers sent: {headers_sent}\n"
|
|
818
|
+
f"Please verify your API_KEY in .env file is correct."
|
|
819
|
+
)
|
|
820
|
+
response.raise_for_status()
|
|
821
|
+
return self._parse_json(response)
|
|
822
|
+
except (httpx.HTTPStatusError, httpx.TransportError) as exc:
|
|
823
|
+
last_exc = exc
|
|
824
|
+
status = (
|
|
825
|
+
getattr(exc.response, "status_code", None)
|
|
826
|
+
if isinstance(exc, httpx.HTTPStatusError)
|
|
827
|
+
else None
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
# For client errors (4xx, 511), surface a clear SDK error immediately.
|
|
831
|
+
if isinstance(exc, httpx.HTTPStatusError) and status is not None:
|
|
832
|
+
# Get full response body for better debugging
|
|
833
|
+
body_text = exc.response.text if exc.response.text else ""
|
|
834
|
+
try:
|
|
835
|
+
# Try to parse as JSON to get structured error message
|
|
836
|
+
body_json = exc.response.json() if body_text else {}
|
|
837
|
+
import json as json_module
|
|
838
|
+
# Show full JSON response for debugging
|
|
839
|
+
error_detail = json_module.dumps(body_json, indent=2) if body_json else body_text[:1000]
|
|
840
|
+
# Also try to extract message field if present
|
|
841
|
+
if isinstance(body_json, dict):
|
|
842
|
+
error_msg_text = body_json.get("message") or body_json.get("error") or ""
|
|
843
|
+
if error_msg_text:
|
|
844
|
+
error_detail = f"{error_msg_text}\n\nFull response:\n{error_detail}"
|
|
845
|
+
except:
|
|
846
|
+
error_detail = body_text[:1000]
|
|
847
|
+
|
|
848
|
+
# Include more details for debugging
|
|
849
|
+
headers_sent = {k: "***" if k.lower() == "pim-sid" else v for k, v in self._headers().items()}
|
|
850
|
+
payload_sent = json if json else {}
|
|
851
|
+
# Add helpful suggestions for 400 errors
|
|
852
|
+
suggestions = ""
|
|
853
|
+
if status == 400:
|
|
854
|
+
suggestions = (
|
|
855
|
+
"\n\nTroubleshooting tips for 400 Bad Request:\n"
|
|
856
|
+
"1. Verify the agent_id exists and is published in your environment\n"
|
|
857
|
+
"2. Check if X-PROJECT-ID header is required (set PROJECT_ID in .env)\n"
|
|
858
|
+
"3. Ensure USER_ID, SELLER_ID, SELLER_PROFILE_ID match the agent's configuration\n"
|
|
859
|
+
"4. Check backend logs using requestId to see the specific validation error\n"
|
|
860
|
+
)
|
|
861
|
+
error_msg = (
|
|
862
|
+
f"Request to {url} failed with {status} {exc.response.reason_phrase}\n"
|
|
863
|
+
f"Response body: {error_detail}\n"
|
|
864
|
+
f"Headers sent: {headers_sent}\n"
|
|
865
|
+
f"Payload sent: {json_module.dumps(payload_sent, indent=2)[:1000]}"
|
|
866
|
+
f"{suggestions}"
|
|
867
|
+
)
|
|
868
|
+
if 400 <= status < 500 or status == 511:
|
|
869
|
+
raise AgentExecutionError(error_msg) from exc
|
|
870
|
+
|
|
871
|
+
should_retry = status is None or (500 <= status < 600 and status != 511)
|
|
872
|
+
if not should_retry or attempt >= self.max_retries:
|
|
873
|
+
raise
|
|
874
|
+
|
|
875
|
+
backoff = self.backoff_factor * (2**attempt)
|
|
876
|
+
time.sleep(backoff)
|
|
877
|
+
|
|
878
|
+
assert last_exc is not None
|
|
879
|
+
raise last_exc
|
|
880
|
+
|
|
881
|
+
async def _request_with_retries_async(
|
|
882
|
+
self,
|
|
883
|
+
method: str,
|
|
884
|
+
url: str,
|
|
885
|
+
*,
|
|
886
|
+
json: Optional[Dict[str, Any]] = None,
|
|
887
|
+
params: Optional[Dict[str, Any]] = None,
|
|
888
|
+
) -> Dict[str, Any]:
|
|
889
|
+
client = self._get_async_client()
|
|
890
|
+
last_exc: Optional[BaseException] = None
|
|
891
|
+
|
|
892
|
+
for attempt in range(self.max_retries + 1):
|
|
893
|
+
try:
|
|
894
|
+
response = await client.request(
|
|
895
|
+
method,
|
|
896
|
+
url,
|
|
897
|
+
headers=self._headers(),
|
|
898
|
+
json=json,
|
|
899
|
+
params=params,
|
|
900
|
+
)
|
|
901
|
+
# Check for 511 before raise_for_status (it's treated as server error but is actually auth error)
|
|
902
|
+
if response.status_code == 511:
|
|
903
|
+
body_preview = response.text[:500] if response.text else ""
|
|
904
|
+
headers_sent = {k: "***" if k.lower() == "pim-sid" else v for k, v in self._headers().items()}
|
|
905
|
+
raise AgentExecutionError(
|
|
906
|
+
f"Network Authentication Required (511)\n"
|
|
907
|
+
f"This usually means the API key is invalid or missing required headers.\n"
|
|
908
|
+
f"URL: {url}\n"
|
|
909
|
+
f"Response: {body_preview}\n"
|
|
910
|
+
f"Headers sent: {headers_sent}\n"
|
|
911
|
+
f"Please verify your API_KEY in .env file is correct."
|
|
912
|
+
)
|
|
913
|
+
response.raise_for_status()
|
|
914
|
+
return self._parse_json(response)
|
|
915
|
+
except (httpx.HTTPStatusError, httpx.TransportError) as exc:
|
|
916
|
+
last_exc = exc
|
|
917
|
+
status = (
|
|
918
|
+
getattr(exc.response, "status_code", None)
|
|
919
|
+
if isinstance(exc, httpx.HTTPStatusError)
|
|
920
|
+
else None
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
# For client errors (4xx, 511), surface a clear SDK error immediately.
|
|
924
|
+
if isinstance(exc, httpx.HTTPStatusError) and status is not None:
|
|
925
|
+
# Get full response body for better debugging
|
|
926
|
+
body_text = exc.response.text if exc.response.text else ""
|
|
927
|
+
try:
|
|
928
|
+
# Try to parse as JSON to get structured error message
|
|
929
|
+
body_json = exc.response.json() if body_text else {}
|
|
930
|
+
import json as json_module
|
|
931
|
+
# Show full JSON response for debugging
|
|
932
|
+
error_detail = json_module.dumps(body_json, indent=2) if body_json else body_text[:1000]
|
|
933
|
+
# Also try to extract message field if present
|
|
934
|
+
if isinstance(body_json, dict):
|
|
935
|
+
error_msg_text = body_json.get("message") or body_json.get("error") or ""
|
|
936
|
+
if error_msg_text:
|
|
937
|
+
error_detail = f"{error_msg_text}\n\nFull response:\n{error_detail}"
|
|
938
|
+
except:
|
|
939
|
+
error_detail = body_text[:1000]
|
|
940
|
+
|
|
941
|
+
# Include more details for debugging
|
|
942
|
+
headers_sent = {k: "***" if k.lower() == "pim-sid" else v for k, v in self._headers().items()}
|
|
943
|
+
payload_sent = json if json else {}
|
|
944
|
+
# Add helpful suggestions for 400 errors
|
|
945
|
+
suggestions = ""
|
|
946
|
+
if status == 400:
|
|
947
|
+
suggestions = (
|
|
948
|
+
"\n\nTroubleshooting tips for 400 Bad Request:\n"
|
|
949
|
+
"1. Verify the agent_id exists and is published in your environment\n"
|
|
950
|
+
"2. Check if X-PROJECT-ID header is required (set PROJECT_ID in .env)\n"
|
|
951
|
+
"3. Ensure USER_ID, SELLER_ID, SELLER_PROFILE_ID match the agent's configuration\n"
|
|
952
|
+
"4. Check backend logs using requestId to see the specific validation error\n"
|
|
953
|
+
)
|
|
954
|
+
error_msg = (
|
|
955
|
+
f"Request to {url} failed with {status} {exc.response.reason_phrase}\n"
|
|
956
|
+
f"Response body: {error_detail}\n"
|
|
957
|
+
f"Headers sent: {headers_sent}\n"
|
|
958
|
+
f"Payload sent: {json_module.dumps(payload_sent, indent=2)[:1000]}"
|
|
959
|
+
f"{suggestions}"
|
|
960
|
+
)
|
|
961
|
+
if 400 <= status < 500 or status == 511:
|
|
962
|
+
raise AgentExecutionError(error_msg) from exc
|
|
963
|
+
|
|
964
|
+
should_retry = status is None or (500 <= status < 600 and status != 511)
|
|
965
|
+
if not should_retry or attempt >= self.max_retries:
|
|
966
|
+
raise
|
|
967
|
+
|
|
968
|
+
backoff = self.backoff_factor * (2**attempt)
|
|
969
|
+
await asyncio.sleep(backoff)
|
|
970
|
+
|
|
971
|
+
assert last_exc is not None
|
|
972
|
+
raise last_exc
|
|
973
|
+
|
|
974
|
+
@staticmethod
|
|
975
|
+
def _parse_json(response: httpx.Response) -> Dict[str, Any]:
|
|
976
|
+
try:
|
|
977
|
+
data = response.json()
|
|
978
|
+
except json.JSONDecodeError:
|
|
979
|
+
raise AgentExecutionError(
|
|
980
|
+
f"Invalid JSON response from {response.url!s}: {response.text[:200]}"
|
|
981
|
+
)
|
|
982
|
+
if not isinstance(data, dict):
|
|
983
|
+
raise AgentExecutionError(
|
|
984
|
+
f"Expected JSON object from {response.url!s}, got: {type(data).__name__}"
|
|
985
|
+
)
|
|
986
|
+
return data
|
|
987
|
+
|
|
988
|
+
@staticmethod
|
|
989
|
+
def _extract_ids(payload: Dict[str, Any]) -> tuple[str, str]:
|
|
990
|
+
"""Extract conversation_id and message_id from API response.
|
|
991
|
+
|
|
992
|
+
Response structure: BaseRestResponse<ChatResponse>
|
|
993
|
+
{
|
|
994
|
+
"result": {
|
|
995
|
+
"conversationId": "...",
|
|
996
|
+
"messageId": "...",
|
|
997
|
+
"response": [Messages...]
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
"""
|
|
1001
|
+
# Extract from BaseRestResponse<ChatResponse> structure
|
|
1002
|
+
result = payload.get("result", {})
|
|
1003
|
+
if isinstance(result, dict):
|
|
1004
|
+
# ChatResponse has conversationId and messageId at top level
|
|
1005
|
+
conv_id = result.get("conversationId") or result.get("conversation_id")
|
|
1006
|
+
msg_id = result.get("messageId") or result.get("message_id")
|
|
1007
|
+
|
|
1008
|
+
# If not at top level, try from first message in response array
|
|
1009
|
+
if not (conv_id and msg_id):
|
|
1010
|
+
response_list = result.get("response", [])
|
|
1011
|
+
if response_list and isinstance(response_list, list) and len(response_list) > 0:
|
|
1012
|
+
message = response_list[0]
|
|
1013
|
+
if isinstance(message, dict):
|
|
1014
|
+
# Try to get from message object
|
|
1015
|
+
if not conv_id:
|
|
1016
|
+
conv_id = message.get("cId") or message.get("conversationId") or message.get("conversation_id")
|
|
1017
|
+
if not msg_id:
|
|
1018
|
+
msg_id = message.get("id") or message.get("messageId") or message.get("message_id")
|
|
1019
|
+
|
|
1020
|
+
if conv_id and msg_id:
|
|
1021
|
+
return str(conv_id), str(msg_id)
|
|
1022
|
+
|
|
1023
|
+
# Fallback: try direct keys in payload
|
|
1024
|
+
conv_id = payload.get("conversationId") or payload.get("conversation_id")
|
|
1025
|
+
msg_id = payload.get("messageId") or payload.get("message_id") or payload.get("id")
|
|
1026
|
+
|
|
1027
|
+
if conv_id and msg_id:
|
|
1028
|
+
return str(conv_id), str(msg_id)
|
|
1029
|
+
|
|
1030
|
+
raise AgentExecutionError(
|
|
1031
|
+
f"Could not find conversation_id and message_id in response: {json.dumps(payload)[:200]}"
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
@staticmethod
|
|
1035
|
+
def _parse_sse_line(line: str) -> Optional[str]:
|
|
1036
|
+
"""Parse a Server-Sent Events (SSE) format line.
|
|
1037
|
+
|
|
1038
|
+
SSE format rules:
|
|
1039
|
+
- Lines starting with "data:" contain the actual content
|
|
1040
|
+
- Lines starting with "event:" are event types (ignored)
|
|
1041
|
+
- Lines starting with ":" are comments (ignored)
|
|
1042
|
+
- Empty lines separate events (ignored)
|
|
1043
|
+
- If line doesn't match SSE format, return as-is (backward compatibility)
|
|
1044
|
+
|
|
1045
|
+
Args:
|
|
1046
|
+
line: Raw line from the stream
|
|
1047
|
+
|
|
1048
|
+
Returns:
|
|
1049
|
+
Content string if it's a data line or plain text, None if it should be ignored
|
|
1050
|
+
"""
|
|
1051
|
+
if not line:
|
|
1052
|
+
return None # Empty lines are event separators in SSE
|
|
1053
|
+
|
|
1054
|
+
line = line.strip()
|
|
1055
|
+
if not line:
|
|
1056
|
+
return None
|
|
1057
|
+
|
|
1058
|
+
# SSE format: "data: <content>"
|
|
1059
|
+
if line.startswith("data:"):
|
|
1060
|
+
# Extract content after "data:" (may have leading space)
|
|
1061
|
+
content = line[5:].lstrip()
|
|
1062
|
+
return content
|
|
1063
|
+
|
|
1064
|
+
# SSE format: "event: <event_type>" - ignore
|
|
1065
|
+
if line.startswith("event:"):
|
|
1066
|
+
return None
|
|
1067
|
+
|
|
1068
|
+
# SSE format: ": <comment>" - ignore
|
|
1069
|
+
if line.startswith(":"):
|
|
1070
|
+
return None
|
|
1071
|
+
|
|
1072
|
+
# Not SSE format - return as-is for backward compatibility
|
|
1073
|
+
# (handles plain text streams from Redis)
|
|
1074
|
+
return line
|
|
1075
|
+
|
|
1076
|
+
# Keys in result that are metadata, not message_id (External API puts message_id as key)
|
|
1077
|
+
_RESULT_METADATA_KEYS = frozenset({
|
|
1078
|
+
"trace_id", "traceId", "node_id", "nodeId", "job_id", "jobId",
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
@staticmethod
|
|
1082
|
+
def _parse_message_response(payload: Dict[str, Any]) -> tuple[AgentStatus, str]:
|
|
1083
|
+
"""Parse message response and extract status and text.
|
|
1084
|
+
|
|
1085
|
+
Supports:
|
|
1086
|
+
1) Standard shape: result.response[0].queryResult / messageStatus
|
|
1087
|
+
2) External API shape: result[message_id] = message object (or null), result.trace_id
|
|
1088
|
+
"""
|
|
1089
|
+
# Extract from BaseRestResponse<ChatResponse> structure
|
|
1090
|
+
result = payload.get("result", {})
|
|
1091
|
+
if isinstance(result, dict):
|
|
1092
|
+
# Standard: result.response = [ { queryResult, messageStatus } ]
|
|
1093
|
+
response_list = result.get("response", [])
|
|
1094
|
+
if response_list and isinstance(response_list, list) and len(response_list) > 0:
|
|
1095
|
+
message = response_list[0]
|
|
1096
|
+
if isinstance(message, dict):
|
|
1097
|
+
status_raw = message.get("messageStatus") or message.get("message_status")
|
|
1098
|
+
response_text = message.get("queryResult") or message.get("query_result") or ""
|
|
1099
|
+
if status_raw is not None:
|
|
1100
|
+
status = AgentStatus.from_raw(status_raw)
|
|
1101
|
+
else:
|
|
1102
|
+
status = AgentStatus.COMPLETED if response_text else AgentStatus.PROCESSING
|
|
1103
|
+
return status, str(response_text) if response_text else ""
|
|
1104
|
+
|
|
1105
|
+
# External API shape: result = { message_id: message_data_or_null, trace_id: ... }
|
|
1106
|
+
# message_id is the key that's not in _RESULT_METADATA_KEYS
|
|
1107
|
+
for key, value in result.items():
|
|
1108
|
+
if key in AgentClient._RESULT_METADATA_KEYS:
|
|
1109
|
+
continue
|
|
1110
|
+
# This key is the message_id; value is message data or None
|
|
1111
|
+
if isinstance(value, dict):
|
|
1112
|
+
response_text = value.get("queryResult") or value.get("query_result") or ""
|
|
1113
|
+
status_raw = value.get("messageStatus") or value.get("message_status")
|
|
1114
|
+
status = AgentStatus.from_raw(status_raw) if status_raw is not None else (
|
|
1115
|
+
AgentStatus.COMPLETED if response_text else AgentStatus.PROCESSING
|
|
1116
|
+
)
|
|
1117
|
+
return status, str(response_text) if response_text else ""
|
|
1118
|
+
# value is None (backend sent getResult() which is null) -> return empty response
|
|
1119
|
+
return AgentStatus.COMPLETED, ""
|
|
1120
|
+
|
|
1121
|
+
# Fallback: direct keys; never use result dict as response text
|
|
1122
|
+
status_raw = payload.get("messageStatus") or payload.get("status")
|
|
1123
|
+
raw_result = payload.get("result")
|
|
1124
|
+
raw_response = payload.get("response")
|
|
1125
|
+
response_text = (
|
|
1126
|
+
payload.get("queryResult")
|
|
1127
|
+
or payload.get("query_result")
|
|
1128
|
+
or (raw_result if isinstance(raw_result, str) else "")
|
|
1129
|
+
or (raw_response if isinstance(raw_response, str) else "")
|
|
1130
|
+
or ""
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
if status_raw is not None:
|
|
1134
|
+
status = AgentStatus.from_raw(status_raw)
|
|
1135
|
+
else:
|
|
1136
|
+
status = AgentStatus.COMPLETED if response_text else AgentStatus.PROCESSING
|
|
1137
|
+
|
|
1138
|
+
return status, str(response_text) if response_text else ""
|
|
1139
|
+
|