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.
Files changed (42) hide show
  1. billing/__init__.py +6 -0
  2. billing/api.py +55 -0
  3. billing/client.py +14 -0
  4. billing/schema.py +15 -0
  5. constants/__init__.py +90 -0
  6. core/__init__.py +53 -0
  7. core/agents/__init__.py +42 -0
  8. core/agents/execution/__init__.py +49 -0
  9. core/agents/execution/api.py +283 -0
  10. core/agents/execution/client.py +1139 -0
  11. core/agents/models.py +99 -0
  12. core/workflows/WORKFLOW_ARCHITECTURE.md +417 -0
  13. core/workflows/__init__.py +31 -0
  14. core/workflows/bulk/__init__.py +14 -0
  15. core/workflows/bulk/api.py +202 -0
  16. core/workflows/bulk/client.py +115 -0
  17. core/workflows/bulk/schema.py +58 -0
  18. core/workflows/models.py +49 -0
  19. core/workflows/scheduling/__init__.py +9 -0
  20. core/workflows/scheduling/api.py +179 -0
  21. core/workflows/scheduling/client.py +128 -0
  22. core/workflows/scheduling/schema.py +74 -0
  23. core/workflows/tool_execution/__init__.py +16 -0
  24. core/workflows/tool_execution/api.py +172 -0
  25. core/workflows/tool_execution/client.py +195 -0
  26. core/workflows/tool_execution/schema.py +40 -0
  27. exceptions/__init__.py +21 -0
  28. simplai_sdk/__init__.py +7 -0
  29. simplai_sdk/simplai.py +239 -0
  30. simplai_sdk-0.1.0.dist-info/METADATA +728 -0
  31. simplai_sdk-0.1.0.dist-info/RECORD +42 -0
  32. simplai_sdk-0.1.0.dist-info/WHEEL +5 -0
  33. simplai_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  34. simplai_sdk-0.1.0.dist-info/top_level.txt +7 -0
  35. traces/__init__.py +1 -0
  36. traces/agents/__init__.py +55 -0
  37. traces/agents/api.py +350 -0
  38. traces/agents/client.py +697 -0
  39. traces/agents/models.py +249 -0
  40. traces/workflows/__init__.py +0 -0
  41. utils/__init__.py +0 -0
  42. 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
+