agnt5 0.3.0a8__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of agnt5 might be problematic. Click here for more details.

agnt5/client.py ADDED
@@ -0,0 +1,1478 @@
1
+ """AGNT5 Client SDK for invoking components."""
2
+
3
+ import json
4
+ import os
5
+ from typing import Any, AsyncIterator, Dict, Iterator, Optional
6
+ from urllib.parse import urljoin
7
+
8
+ import httpx
9
+
10
+ from .events import Event, EventType
11
+
12
+ # Environment variable for API key
13
+ AGNT5_API_KEY_ENV = "AGNT5_API_KEY"
14
+
15
+
16
+ def _parse_sse_to_event(event_type_str: str, data: Dict[str, Any]) -> Event:
17
+ """Convert SSE event type and data to typed Event object.
18
+
19
+ Args:
20
+ event_type_str: The event type string from SSE (e.g., "agent.started")
21
+ data: The parsed JSON data from the SSE data field
22
+
23
+ Returns:
24
+ Event object with typed event_type and data payload
25
+ """
26
+ try:
27
+ event_type = EventType(event_type_str)
28
+ except ValueError:
29
+ # Unknown event type - store as-is with a generic type
30
+ # This allows forward compatibility with new event types
31
+ return Event(
32
+ event_type=EventType.PROGRESS_UPDATE,
33
+ data={"_raw_event_type": event_type_str, **data},
34
+ content_index=data.get("index", 0),
35
+ sequence=data.get("sequence", 0),
36
+ )
37
+
38
+ return Event(
39
+ event_type=event_type,
40
+ data=data,
41
+ content_index=data.get("index", 0),
42
+ sequence=data.get("sequence", 0),
43
+ )
44
+
45
+
46
+ class Client:
47
+ """Client for invoking AGNT5 components.
48
+
49
+ This client provides a simple interface for calling functions, workflows,
50
+ and other components deployed on AGNT5.
51
+
52
+ Example:
53
+ ```python
54
+ from agnt5 import Client
55
+
56
+ # Local development (no auth needed)
57
+ client = Client("http://localhost:34181")
58
+ result = client.run("greet", {"name": "Alice"})
59
+ print(result) # {"message": "Hello, Alice!"}
60
+
61
+ # Production with API key
62
+ client = Client(
63
+ gateway_url="https://api.agnt5.com",
64
+ api_key="agnt5_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
65
+ )
66
+
67
+ # Or use AGNT5_API_KEY environment variable
68
+ # export AGNT5_API_KEY=agnt5_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
69
+ client = Client(gateway_url="https://api.agnt5.com")
70
+ ```
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ gateway_url: str = "http://localhost:34181",
76
+ timeout: float = 30.0,
77
+ api_key: Optional[str] = None,
78
+ ):
79
+ """Initialize the AGNT5 client.
80
+
81
+ Args:
82
+ gateway_url: Base URL of the AGNT5 gateway (default: http://localhost:34181)
83
+ timeout: Request timeout in seconds (default: 30.0)
84
+ api_key: Service key for authentication. If not provided, falls back to
85
+ AGNT5_API_KEY environment variable. Keys start with "agnt5_sk_".
86
+ """
87
+ self.gateway_url = gateway_url.rstrip("/")
88
+ self.timeout = timeout
89
+ # Use provided api_key or fallback to environment variable
90
+ self.api_key = api_key or os.environ.get(AGNT5_API_KEY_ENV)
91
+ self._client = httpx.Client(timeout=timeout)
92
+
93
+ def _build_headers(
94
+ self,
95
+ session_id: Optional[str] = None,
96
+ user_id: Optional[str] = None,
97
+ ) -> Dict[str, str]:
98
+ """Build request headers with authentication and optional session/user context.
99
+
100
+ Args:
101
+ session_id: Session identifier for multi-turn conversations
102
+ user_id: User identifier for user-scoped memory
103
+
104
+ Returns:
105
+ Dictionary of HTTP headers
106
+ """
107
+ headers = {"Content-Type": "application/json"}
108
+ if self.api_key:
109
+ headers["X-API-KEY"] = self.api_key
110
+ if session_id:
111
+ headers["X-Session-ID"] = session_id
112
+ if user_id:
113
+ headers["X-User-ID"] = user_id
114
+ return headers
115
+
116
+ def run(
117
+ self,
118
+ component: str,
119
+ input_data: Optional[Dict[str, Any]] = None,
120
+ component_type: str = "function",
121
+ session_id: Optional[str] = None,
122
+ user_id: Optional[str] = None,
123
+ ) -> Dict[str, Any]:
124
+ """Execute a component synchronously and wait for the result.
125
+
126
+ This is a blocking call that waits for the component to complete execution.
127
+
128
+ Args:
129
+ component: Name of the component to execute
130
+ input_data: Input data for the component (will be sent as JSON body)
131
+ component_type: Type of component - "function", "workflow", "agent", "tool" (default: "function")
132
+ session_id: Session identifier for multi-turn conversations (optional)
133
+ user_id: User identifier for user-scoped memory (optional)
134
+
135
+ Returns:
136
+ Dictionary containing the component's output
137
+
138
+ Raises:
139
+ RunError: If the component execution fails
140
+ httpx.HTTPError: If the HTTP request fails
141
+
142
+ Example:
143
+ ```python
144
+ # Simple function call (default)
145
+ result = client.run("greet", {"name": "Alice"})
146
+
147
+ # Workflow execution (explicit)
148
+ result = client.run("order_fulfillment", {"order_id": "123"}, component_type="workflow")
149
+
150
+ # Multi-turn conversation with session
151
+ result = client.run("chat", {"message": "Hello"}, session_id="session-123")
152
+
153
+ # User-scoped memory
154
+ result = client.run("assistant", {"message": "Help me"}, user_id="user-456")
155
+
156
+ # No input data
157
+ result = client.run("get_status")
158
+ ```
159
+ """
160
+ if input_data is None:
161
+ input_data = {}
162
+
163
+ # Build URL with component type
164
+ url = urljoin(self.gateway_url + "/", f"v1/run/{component_type}/{component}")
165
+
166
+ # Make request with auth and session headers
167
+ response = self._client.post(
168
+ url,
169
+ json=input_data,
170
+ headers=self._build_headers(session_id=session_id, user_id=user_id),
171
+ )
172
+
173
+ # Handle errors
174
+ if response.status_code == 404:
175
+ try:
176
+ error_data = response.json()
177
+ raise RunError(
178
+ error_data.get("error", "Component not found"),
179
+ run_id=error_data.get("runId"),
180
+ )
181
+ except ValueError:
182
+ # JSON parsing failed
183
+ raise RunError(f"Component '{component}' not found")
184
+
185
+ if response.status_code == 503:
186
+ error_data = response.json()
187
+ raise RunError(
188
+ f"Service unavailable: {error_data.get('error', 'Unknown error')}",
189
+ run_id=error_data.get("runId"),
190
+ )
191
+
192
+ if response.status_code == 504:
193
+ error_data = response.json()
194
+ raise RunError(
195
+ "Execution timeout",
196
+ run_id=error_data.get("runId"),
197
+ )
198
+
199
+ # Handle 500 errors with our RunResponse format
200
+ if response.status_code == 500:
201
+ try:
202
+ error_data = response.json()
203
+ raise RunError(
204
+ error_data.get("error", "Unknown error"),
205
+ run_id=error_data.get("runId"),
206
+ )
207
+ except ValueError:
208
+ # JSON parsing failed, fall through to raise_for_status
209
+ response.raise_for_status()
210
+ else:
211
+ # For other error codes, use standard HTTP error handling
212
+ response.raise_for_status()
213
+
214
+ # Parse response
215
+ data = response.json()
216
+
217
+ # Check execution status
218
+ if data.get("status") == "failed":
219
+ raise RunError(
220
+ data.get("error", "Unknown error"),
221
+ run_id=data.get("runId"),
222
+ )
223
+
224
+ # Return output
225
+ return data.get("output", {})
226
+
227
+ def submit(
228
+ self,
229
+ component: str,
230
+ input_data: Optional[Dict[str, Any]] = None,
231
+ component_type: str = "function",
232
+ ) -> str:
233
+ """Submit a component for async execution and return immediately.
234
+
235
+ This is a non-blocking call that returns a run ID immediately.
236
+ Use get_status() to check progress and get_result() to retrieve the output.
237
+
238
+ Args:
239
+ component: Name of the component to execute
240
+ input_data: Input data for the component (will be sent as JSON body)
241
+ component_type: Type of component - "function", "workflow", "agent", "tool" (default: "function")
242
+
243
+ Returns:
244
+ String containing the run ID
245
+
246
+ Raises:
247
+ httpx.HTTPError: If the HTTP request fails
248
+
249
+ Example:
250
+ ```python
251
+ # Submit async function (default)
252
+ run_id = client.submit("process_video", {"url": "https://..."})
253
+ print(f"Submitted: {run_id}")
254
+
255
+ # Submit workflow
256
+ run_id = client.submit("order_fulfillment", {"order_id": "123"}, component_type="workflow")
257
+
258
+ # Check status later
259
+ status = client.get_status(run_id)
260
+ if status["status"] == "completed":
261
+ result = client.get_result(run_id)
262
+ ```
263
+ """
264
+ if input_data is None:
265
+ input_data = {}
266
+
267
+ # Build URL with component type
268
+ url = urljoin(self.gateway_url + "/", f"v1/submit/{component_type}/{component}")
269
+
270
+ # Make request with auth headers
271
+ response = self._client.post(
272
+ url,
273
+ json=input_data,
274
+ headers=self._build_headers(),
275
+ )
276
+
277
+ # Handle errors
278
+ response.raise_for_status()
279
+
280
+ # Parse response and extract run ID
281
+ data = response.json()
282
+ return data.get("runId", "")
283
+
284
+ def get_status(self, run_id: str) -> Dict[str, Any]:
285
+ """Get the current status of a run.
286
+
287
+ Args:
288
+ run_id: The run ID returned from submit()
289
+
290
+ Returns:
291
+ Dictionary containing status information:
292
+ {
293
+ "runId": "...",
294
+ "status": "pending|running|completed|failed|cancelled",
295
+ "submittedAt": 1234567890,
296
+ "startedAt": 1234567891, // optional
297
+ "completedAt": 1234567892 // optional
298
+ }
299
+
300
+ Raises:
301
+ httpx.HTTPError: If the HTTP request fails
302
+
303
+ Example:
304
+ ```python
305
+ status = client.get_status(run_id)
306
+ print(f"Status: {status['status']}")
307
+ ```
308
+ """
309
+ url = urljoin(self.gateway_url + "/", f"v1/status/{run_id}")
310
+
311
+ response = self._client.get(url, headers=self._build_headers())
312
+ response.raise_for_status()
313
+
314
+ return response.json()
315
+
316
+ def get_result(self, run_id: str) -> Dict[str, Any]:
317
+ """Get the result of a completed run.
318
+
319
+ This will raise an error if the run is not yet complete.
320
+
321
+ Args:
322
+ run_id: The run ID returned from submit()
323
+
324
+ Returns:
325
+ Dictionary containing the component's output
326
+
327
+ Raises:
328
+ RunError: If the run failed or is not yet complete
329
+ httpx.HTTPError: If the HTTP request fails
330
+
331
+ Example:
332
+ ```python
333
+ try:
334
+ result = client.get_result(run_id)
335
+ print(result)
336
+ except RunError as e:
337
+ if "not complete" in str(e):
338
+ print("Run is still in progress")
339
+ else:
340
+ print(f"Run failed: {e}")
341
+ ```
342
+ """
343
+ url = urljoin(self.gateway_url + "/", f"v1/result/{run_id}")
344
+
345
+ response = self._client.get(url, headers=self._build_headers())
346
+
347
+ # Handle 404 - run not complete or not found
348
+ if response.status_code == 404:
349
+ error_data = response.json()
350
+ error_msg = error_data.get("error", "Run not found or not complete")
351
+ current_status = error_data.get("status", "unknown")
352
+ raise RunError(f"{error_msg} (status: {current_status})", run_id=run_id)
353
+
354
+ # Handle other errors
355
+ response.raise_for_status()
356
+
357
+ # Parse response
358
+ data = response.json()
359
+
360
+ # Check if run failed
361
+ if data.get("status") == "failed":
362
+ raise RunError(
363
+ data.get("error", "Unknown error"),
364
+ run_id=run_id,
365
+ )
366
+
367
+ # Return output
368
+ return data.get("output", {})
369
+
370
+ def wait_for_result(
371
+ self,
372
+ run_id: str,
373
+ timeout: float = 300.0,
374
+ poll_interval: float = 1.0,
375
+ ) -> Dict[str, Any]:
376
+ """Wait for a run to complete and return the result.
377
+
378
+ This polls the status endpoint until the run completes or times out.
379
+
380
+ Args:
381
+ run_id: The run ID returned from submit()
382
+ timeout: Maximum time to wait in seconds (default: 300)
383
+ poll_interval: How often to check status in seconds (default: 1.0)
384
+
385
+ Returns:
386
+ Dictionary containing the component's output
387
+
388
+ Raises:
389
+ RunError: If the run fails or times out
390
+ httpx.HTTPError: If the HTTP request fails
391
+
392
+ Example:
393
+ ```python
394
+ # Submit and wait for result
395
+ run_id = client.submit("long_task", {"data": "..."})
396
+ try:
397
+ result = client.wait_for_result(run_id, timeout=600)
398
+ print(result)
399
+ except RunError as e:
400
+ print(f"Failed: {e}")
401
+ ```
402
+ """
403
+ import time
404
+
405
+ start_time = time.time()
406
+
407
+ while True:
408
+ # Check timeout
409
+ elapsed = time.time() - start_time
410
+ if elapsed >= timeout:
411
+ raise RunError(
412
+ f"Timeout waiting for run to complete after {timeout}s",
413
+ run_id=run_id,
414
+ )
415
+
416
+ # Get current status
417
+ status = self.get_status(run_id)
418
+ current_status = status.get("status", "")
419
+
420
+ # Check if complete
421
+ if current_status in ("completed", "failed", "cancelled"):
422
+ # Get result (will raise if failed)
423
+ return self.get_result(run_id)
424
+
425
+ # Wait before next poll
426
+ time.sleep(poll_interval)
427
+
428
+ def stream(
429
+ self,
430
+ component: str,
431
+ input_data: Optional[Dict[str, Any]] = None,
432
+ ):
433
+ """Stream responses from a component using Server-Sent Events (SSE).
434
+
435
+ This method yields chunks as they arrive from the component.
436
+ Perfect for LLM token streaming and incremental responses.
437
+
438
+ Args:
439
+ component: Name of the component to execute
440
+ input_data: Input data for the component (will be sent as JSON body)
441
+
442
+ Yields:
443
+ String chunks as they arrive from the component
444
+
445
+ Raises:
446
+ RunError: If the component execution fails
447
+ httpx.HTTPError: If the HTTP request fails
448
+
449
+ Example:
450
+ ```python
451
+ # Stream LLM tokens
452
+ for chunk in client.stream("generate_text", {"prompt": "Write a story"}):
453
+ print(chunk, end="", flush=True)
454
+ ```
455
+ """
456
+ if input_data is None:
457
+ input_data = {}
458
+
459
+ # Build URL
460
+ url = urljoin(self.gateway_url + "/", f"v1/stream/{component}")
461
+
462
+ # Use streaming request with auth headers
463
+ with self._client.stream(
464
+ "POST",
465
+ url,
466
+ json=input_data,
467
+ headers=self._build_headers(),
468
+ timeout=300.0, # 5 minute timeout for streaming
469
+ ) as response:
470
+ # Check for errors
471
+ if response.status_code != 200:
472
+ # For streaming responses, we can't read the full text
473
+ # Just raise an HTTP error
474
+ raise RunError(
475
+ f"HTTP {response.status_code}: Streaming request failed",
476
+ run_id=None,
477
+ )
478
+
479
+ # Parse SSE stream
480
+ current_event = None
481
+ for line in response.iter_lines():
482
+ line = line.strip()
483
+
484
+ # Skip empty lines and comments
485
+ if not line or line.startswith(":"):
486
+ continue
487
+
488
+ # Parse event type: "event: output.delta"
489
+ if line.startswith("event: "):
490
+ current_event = line[7:] # Remove "event: " prefix
491
+ continue
492
+
493
+ # Parse SSE format: "data: {...}"
494
+ if line.startswith("data: "):
495
+ data_str = line[6:] # Remove "data: " prefix
496
+
497
+ try:
498
+ data = json.loads(data_str)
499
+
500
+ # Check for completion
501
+ if data.get("done") or current_event == "done":
502
+ return
503
+
504
+ # Check for error
505
+ if "error" in data:
506
+ raise RunError(
507
+ data.get("error"),
508
+ run_id=data.get("runId"),
509
+ )
510
+
511
+ # Yield chunk from output.delta events
512
+ if current_event == "output.delta" and "content" in data:
513
+ yield data["content"]
514
+ # Also support legacy "chunk" format
515
+ elif "chunk" in data:
516
+ yield data["chunk"]
517
+
518
+ except json.JSONDecodeError:
519
+ # Skip malformed JSON
520
+ continue
521
+
522
+ def stream_events(
523
+ self,
524
+ component: str,
525
+ input_data: Optional[Dict[str, Any]] = None,
526
+ component_type: str = "function",
527
+ session_id: Optional[str] = None,
528
+ user_id: Optional[str] = None,
529
+ timeout: float = 300.0,
530
+ ) -> Iterator[Event]:
531
+ """Stream typed Event objects from a component execution.
532
+
533
+ This method yields Event objects as they arrive from the component,
534
+ providing full access to the event taxonomy including agent lifecycle,
535
+ LM streaming, tool calls, and workflow events.
536
+
537
+ Args:
538
+ component: Name of the component to execute
539
+ input_data: Input data for the component (will be sent as JSON body)
540
+ component_type: Type of component - "function", "workflow", "agent", "tool"
541
+ session_id: Session identifier for multi-turn conversations (optional)
542
+ user_id: User identifier for user-scoped memory (optional)
543
+ timeout: Stream timeout in seconds (default: 300.0 / 5 minutes)
544
+
545
+ Yields:
546
+ Event objects as they arrive from the stream
547
+
548
+ Raises:
549
+ RunError: If the component execution fails
550
+ httpx.HTTPError: If the HTTP request fails
551
+
552
+ Example:
553
+ ```python
554
+ from agnt5 import Client, EventType
555
+
556
+ client = Client()
557
+
558
+ # Stream agent events
559
+ for event in client.stream_events("my_agent", {"message": "Hi"}, "agent"):
560
+ if event.event_type == EventType.AGENT_STARTED:
561
+ print(f"Agent started: {event.data['agent_name']}")
562
+ elif event.event_type == EventType.LM_MESSAGE_DELTA:
563
+ print(event.data['content'], end='', flush=True)
564
+ elif event.event_type == EventType.AGENT_COMPLETED:
565
+ print(f"\\nDone: {event.data['output']}")
566
+ ```
567
+ """
568
+ if timeout <= 0:
569
+ raise ValueError("timeout must be a positive number")
570
+
571
+ if input_data is None:
572
+ input_data = {}
573
+
574
+ # Build URL with component type (using v2 streaming endpoint)
575
+ url = urljoin(self.gateway_url + "/", f"v1/streamv2/{component_type}/{component}")
576
+
577
+ # Use streaming request with auth and session headers
578
+ with self._client.stream(
579
+ "POST",
580
+ url,
581
+ json=input_data,
582
+ headers=self._build_headers(session_id=session_id, user_id=user_id),
583
+ timeout=timeout,
584
+ ) as response:
585
+ # Check for errors
586
+ if response.status_code != 200:
587
+ # Try to get error details from response body
588
+ try:
589
+ error_body = response.read().decode("utf-8")
590
+ error_data = json.loads(error_body)
591
+ error_msg = error_data.get("error", f"HTTP {response.status_code}")
592
+ run_id = error_data.get("runId")
593
+ except (json.JSONDecodeError, UnicodeDecodeError):
594
+ error_msg = f"HTTP {response.status_code}: Streaming request failed"
595
+ run_id = None
596
+ raise RunError(error_msg, run_id=run_id)
597
+
598
+ # Parse SSE stream
599
+ current_event_type: Optional[str] = None
600
+ for line in response.iter_lines():
601
+ line = line.strip()
602
+
603
+ # Skip empty lines and comments (keep-alive)
604
+ if not line or line.startswith(":"):
605
+ continue
606
+
607
+ # Parse event type: "event: agent.started"
608
+ if line.startswith("event: "):
609
+ current_event_type = line[7:] # Remove "event: " prefix
610
+ continue
611
+
612
+ # Parse SSE data: "data: {...}"
613
+ if line.startswith("data: "):
614
+ data_str = line[6:] # Remove "data: " prefix
615
+
616
+ try:
617
+ data = json.loads(data_str)
618
+
619
+ # Check for completion signal
620
+ if data.get("done") or current_event_type == "done":
621
+ return
622
+
623
+ # Check for error event
624
+ if current_event_type == "error" or "error" in data:
625
+ error_msg = data.get("error", "Unknown streaming error")
626
+ raise RunError(error_msg, run_id=data.get("runId"))
627
+
628
+ # Yield typed Event object
629
+ if current_event_type:
630
+ yield _parse_sse_to_event(current_event_type, data)
631
+
632
+ except json.JSONDecodeError:
633
+ # Skip malformed JSON
634
+ continue
635
+
636
+ def entity(self, entity_type: str, key: str) -> "EntityProxy":
637
+ """Get a proxy for calling methods on a durable entity.
638
+
639
+ This provides a fluent API for entity method invocations with key-based routing.
640
+
641
+ Args:
642
+ entity_type: The entity class name (e.g., "Counter", "ShoppingCart")
643
+ key: The entity instance key (e.g., "user-123", "cart-alice")
644
+
645
+ Returns:
646
+ EntityProxy that allows method calls on the entity
647
+
648
+ Example:
649
+ ```python
650
+ # Call entity method
651
+ result = client.entity("Counter", "user-123").increment(amount=5)
652
+ print(result) # 5
653
+
654
+ # Shopping cart
655
+ result = client.entity("ShoppingCart", "user-alice").add_item(
656
+ item_id="item-123",
657
+ quantity=2,
658
+ price=29.99
659
+ )
660
+ ```
661
+ """
662
+ return EntityProxy(self, entity_type, key)
663
+
664
+ def workflow(self, workflow_name: str) -> "WorkflowProxy":
665
+ """Get a proxy for invoking a workflow with fluent API.
666
+
667
+ This provides a convenient API for workflow invocations, including
668
+ a chat() method for multi-turn conversation workflows.
669
+
670
+ Args:
671
+ workflow_name: Name of the workflow to invoke
672
+
673
+ Returns:
674
+ WorkflowProxy that provides workflow-specific methods
675
+
676
+ Example:
677
+ ```python
678
+ # Standard workflow execution
679
+ result = client.workflow("order_process").run(order_id="123")
680
+
681
+ # Chat workflow with session
682
+ response = client.workflow("support_bot").chat(
683
+ message="My order hasn't arrived",
684
+ session_id="user-123",
685
+ )
686
+
687
+ # Continue conversation
688
+ response = client.workflow("support_bot").chat(
689
+ message="Can you track it?",
690
+ session_id="user-123",
691
+ )
692
+ ```
693
+ """
694
+ return WorkflowProxy(self, workflow_name)
695
+
696
+ def session(self, session_type: str, key: str) -> "SessionProxy":
697
+ """Get a proxy for a session entity (OpenAI/ADK-style API).
698
+
699
+ This is a convenience wrapper around entity() specifically for SessionEntity subclasses,
700
+ providing a familiar API for developers coming from OpenAI Agents SDK or Google ADK.
701
+
702
+ Args:
703
+ session_type: The session entity class name (e.g., "Conversation", "ChatSession")
704
+ key: The session instance key (typically user ID or session ID)
705
+
706
+ Returns:
707
+ SessionProxy that provides session-specific methods
708
+
709
+ Example:
710
+ ```python
711
+ # Create a conversation session
712
+ session = client.session("Conversation", "user-alice")
713
+
714
+ # Chat with the session
715
+ response = session.chat("Hello! How are you?")
716
+ print(response)
717
+
718
+ # Get conversation history
719
+ history = session.get_history()
720
+ for msg in history:
721
+ print(f"{msg['role']}: {msg['content']}")
722
+ ```
723
+ """
724
+ return SessionProxy(self, session_type, key)
725
+
726
+ def close(self):
727
+ """Close the underlying HTTP client."""
728
+ self._client.close()
729
+
730
+ def __enter__(self):
731
+ """Context manager entry."""
732
+ return self
733
+
734
+ def __exit__(self, exc_type, exc_val, exc_tb):
735
+ """Context manager exit."""
736
+ self.close()
737
+
738
+
739
+ class EntityProxy:
740
+ """Proxy for calling methods on a durable entity instance.
741
+
742
+ This class enables fluent method calls on entities using Python's
743
+ attribute access. Any method call is translated to an HTTP request
744
+ to /entity/:type/:key/:method.
745
+
746
+ Example:
747
+ ```python
748
+ counter = client.entity("Counter", "user-123")
749
+ result = counter.increment(amount=5) # Calls /entity/Counter/user-123/increment
750
+ ```
751
+ """
752
+
753
+ def __init__(self, client: "Client", entity_type: str, key: str):
754
+ """Initialize entity proxy.
755
+
756
+ Args:
757
+ client: The AGNT5 client instance
758
+ entity_type: The entity class name
759
+ key: The entity instance key
760
+ """
761
+ self._client = client
762
+ self._entity_type = entity_type
763
+ self._key = key
764
+
765
+ def __getattr__(self, method_name: str):
766
+ """Dynamic method lookup that creates entity method callers.
767
+
768
+ Args:
769
+ method_name: The entity method to call
770
+
771
+ Returns:
772
+ Callable that executes the entity method
773
+ """
774
+
775
+ def method_caller(*args, **kwargs) -> Any:
776
+ """Call an entity method with the given parameters.
777
+
778
+ Args:
779
+ *args: Positional arguments (not recommended, use kwargs)
780
+ **kwargs: Method parameters as keyword arguments
781
+
782
+ Returns:
783
+ The method's return value
784
+
785
+ Raises:
786
+ RunError: If the method execution fails
787
+ ValueError: If both positional and keyword arguments are provided
788
+ """
789
+ # Convert positional args to kwargs if provided
790
+ if args and kwargs:
791
+ raise ValueError(
792
+ f"Cannot mix positional and keyword arguments when calling entity method '{method_name}'. "
793
+ "Please use keyword arguments only."
794
+ )
795
+
796
+ # If positional args provided, we can't convert them without knowing parameter names
797
+ # Raise helpful error
798
+ if args:
799
+ raise ValueError(
800
+ f"Entity method '{method_name}' requires keyword arguments, but got {len(args)} positional arguments. "
801
+ f"Example: .{method_name}(param1=value1, param2=value2)"
802
+ )
803
+
804
+ # Build URL: /v1/entity/:entityType/:key/:method
805
+ url = urljoin(
806
+ self._client.gateway_url + "/",
807
+ f"v1/entity/{self._entity_type}/{self._key}/{method_name}",
808
+ )
809
+
810
+ # Make request with method parameters as JSON body and auth headers
811
+ response = self._client._client.post(
812
+ url,
813
+ json=kwargs,
814
+ headers=self._client._build_headers(),
815
+ )
816
+
817
+ # Handle errors
818
+ if response.status_code == 504:
819
+ error_data = response.json()
820
+ raise RunError(
821
+ "Execution timeout",
822
+ run_id=error_data.get("run_id"),
823
+ )
824
+
825
+ if response.status_code == 500:
826
+ try:
827
+ error_data = response.json()
828
+ raise RunError(
829
+ error_data.get("error", "Unknown error"),
830
+ run_id=error_data.get("run_id"),
831
+ )
832
+ except ValueError:
833
+ response.raise_for_status()
834
+ else:
835
+ response.raise_for_status()
836
+
837
+ # Parse response
838
+ data = response.json()
839
+
840
+ # Check execution status
841
+ if data.get("status") == "failed":
842
+ raise RunError(
843
+ data.get("error", "Unknown error"),
844
+ run_id=data.get("run_id"),
845
+ )
846
+
847
+ # Return output
848
+ return data.get("output")
849
+
850
+ return method_caller
851
+
852
+
853
+ class SessionProxy(EntityProxy):
854
+ """Proxy for session entities with conversation-specific helper methods.
855
+
856
+ This extends EntityProxy to provide familiar APIs for session-based
857
+ conversations, similar to OpenAI Agents SDK and Google ADK.
858
+
859
+ Example:
860
+ ```python
861
+ # Create a session
862
+ session = client.session("Conversation", "user-alice")
863
+
864
+ # Chat
865
+ response = session.chat("Tell me about AI")
866
+
867
+ # Get history
868
+ history = session.get_history()
869
+ ```
870
+ """
871
+
872
+ def chat(self, message: str, **kwargs) -> str:
873
+ """Send a message to the conversation session.
874
+
875
+ This is a convenience method that calls the `chat` method on the
876
+ underlying SessionEntity and returns just the response text.
877
+
878
+ Args:
879
+ message: The user's message
880
+ **kwargs: Additional parameters to pass to the chat method
881
+
882
+ Returns:
883
+ The assistant's response as a string
884
+
885
+ Example:
886
+ ```python
887
+ response = session.chat("What is the weather today?")
888
+ print(response)
889
+ ```
890
+ """
891
+ # Call the chat method via the entity proxy
892
+ result = self.__getattr__("chat")(message=message, **kwargs)
893
+
894
+ # SessionEntity.chat() returns a dict with 'response' key
895
+ if isinstance(result, dict) and "response" in result:
896
+ return result["response"]
897
+
898
+ # If it's already a string, return as-is
899
+ return str(result)
900
+
901
+ def get_history(self) -> list:
902
+ """Get the conversation history for this session.
903
+
904
+ Returns:
905
+ List of message dictionaries with 'role' and 'content' keys
906
+
907
+ Example:
908
+ ```python
909
+ history = session.get_history()
910
+ for msg in history:
911
+ print(f"{msg['role']}: {msg['content']}")
912
+ ```
913
+ """
914
+ return self.__getattr__("get_history")()
915
+
916
+ def add_message(self, role: str, content: str) -> dict:
917
+ """Add a message to the conversation history.
918
+
919
+ Args:
920
+ role: Message role ('user', 'assistant', or 'system')
921
+ content: Message content
922
+
923
+ Returns:
924
+ Dictionary confirming the message was added
925
+
926
+ Example:
927
+ ```python
928
+ session.add_message("system", "You are a helpful assistant")
929
+ session.add_message("user", "Hello!")
930
+ ```
931
+ """
932
+ return self.__getattr__("add_message")(role=role, content=content)
933
+
934
+ def clear_history(self) -> dict:
935
+ """Clear the conversation history for this session.
936
+
937
+ Returns:
938
+ Dictionary confirming the history was cleared
939
+
940
+ Example:
941
+ ```python
942
+ session.clear_history()
943
+ ```
944
+ """
945
+ return self.__getattr__("clear_history")()
946
+
947
+
948
+ class WorkflowProxy:
949
+ """Proxy for invoking workflows with a fluent API.
950
+
951
+ Provides convenient methods for workflow execution, including
952
+ a chat() method for multi-turn conversation workflows.
953
+
954
+ Example:
955
+ ```python
956
+ # Standard workflow
957
+ result = client.workflow("order_process").run(order_id="123")
958
+
959
+ # Chat workflow
960
+ response = client.workflow("support_bot").chat(
961
+ message="Help me",
962
+ session_id="user-123",
963
+ )
964
+ ```
965
+ """
966
+
967
+ def __init__(self, client: "Client", workflow_name: str):
968
+ """Initialize workflow proxy.
969
+
970
+ Args:
971
+ client: The AGNT5 client instance
972
+ workflow_name: Name of the workflow
973
+ """
974
+ self._client = client
975
+ self._workflow_name = workflow_name
976
+
977
+ def run(
978
+ self,
979
+ session_id: Optional[str] = None,
980
+ user_id: Optional[str] = None,
981
+ **kwargs,
982
+ ) -> Dict[str, Any]:
983
+ """Execute the workflow synchronously.
984
+
985
+ Args:
986
+ session_id: Session identifier for multi-turn workflows (optional)
987
+ user_id: User identifier for user-scoped memory (optional)
988
+ **kwargs: Input parameters for the workflow
989
+
990
+ Returns:
991
+ Dictionary containing the workflow's output
992
+
993
+ Example:
994
+ ```python
995
+ result = client.workflow("order_process").run(
996
+ order_id="123",
997
+ customer_id="cust-456",
998
+ )
999
+ ```
1000
+ """
1001
+ return self._client.run(
1002
+ component=self._workflow_name,
1003
+ input_data=kwargs,
1004
+ component_type="workflow",
1005
+ session_id=session_id,
1006
+ user_id=user_id,
1007
+ )
1008
+
1009
+ def chat(
1010
+ self,
1011
+ message: str,
1012
+ session_id: Optional[str] = None,
1013
+ user_id: Optional[str] = None,
1014
+ **kwargs,
1015
+ ) -> Dict[str, Any]:
1016
+ """Send a message to a chat-enabled workflow.
1017
+
1018
+ This is a convenience method for multi-turn conversation workflows.
1019
+ The message is passed as the 'message' input parameter.
1020
+
1021
+ Args:
1022
+ message: The user's message
1023
+ session_id: Session identifier for conversation continuity (recommended)
1024
+ user_id: User identifier for user-scoped memory (optional)
1025
+ **kwargs: Additional input parameters for the workflow
1026
+
1027
+ Returns:
1028
+ Dictionary containing the workflow's response (typically has 'response' key)
1029
+
1030
+ Example:
1031
+ ```python
1032
+ # First message
1033
+ result = client.workflow("support_bot").chat(
1034
+ message="My order hasn't arrived",
1035
+ session_id="session-123",
1036
+ )
1037
+ print(result.get("response"))
1038
+
1039
+ # Continue conversation
1040
+ result = client.workflow("support_bot").chat(
1041
+ message="Can you track it?",
1042
+ session_id="session-123",
1043
+ )
1044
+ ```
1045
+ """
1046
+ # Merge message into kwargs
1047
+ input_data = {"message": message, **kwargs}
1048
+
1049
+ return self._client.run(
1050
+ component=self._workflow_name,
1051
+ input_data=input_data,
1052
+ component_type="workflow",
1053
+ session_id=session_id,
1054
+ user_id=user_id,
1055
+ )
1056
+
1057
+ def stream_events(
1058
+ self,
1059
+ session_id: Optional[str] = None,
1060
+ user_id: Optional[str] = None,
1061
+ timeout: float = 300.0,
1062
+ **kwargs,
1063
+ ) -> Iterator[Event]:
1064
+ """Stream typed Event objects from workflow execution.
1065
+
1066
+ This method yields Event objects as they arrive from the workflow,
1067
+ including nested events from agents and functions called within the workflow.
1068
+
1069
+ Args:
1070
+ session_id: Session identifier for multi-turn workflows (optional)
1071
+ user_id: User identifier for user-scoped memory (optional)
1072
+ timeout: Stream timeout in seconds (default: 300.0 / 5 minutes)
1073
+ **kwargs: Input parameters for the workflow
1074
+
1075
+ Yields:
1076
+ Event objects as they arrive from the stream
1077
+
1078
+ Example:
1079
+ ```python
1080
+ from agnt5 import Client, EventType
1081
+
1082
+ # Stream workflow events
1083
+ for event in client.workflow("research_workflow").stream_events(query="AI"):
1084
+ if event.event_type == EventType.WORKFLOW_STEP_STARTED:
1085
+ print(f"Step started: {event.data.get('step_name')}")
1086
+ elif event.event_type == EventType.LM_MESSAGE_DELTA:
1087
+ print(event.data['content'], end='', flush=True)
1088
+ elif event.event_type == EventType.WORKFLOW_STEP_COMPLETED:
1089
+ print(f"\\nStep done: {event.data.get('step_name')}")
1090
+ ```
1091
+ """
1092
+ return self._client.stream_events(
1093
+ component=self._workflow_name,
1094
+ input_data=kwargs,
1095
+ component_type="workflow",
1096
+ session_id=session_id,
1097
+ user_id=user_id,
1098
+ timeout=timeout,
1099
+ )
1100
+
1101
+ def submit(self, **kwargs) -> str:
1102
+ """Submit the workflow for async execution.
1103
+
1104
+ Args:
1105
+ **kwargs: Input parameters for the workflow
1106
+
1107
+ Returns:
1108
+ Run ID for tracking the execution
1109
+
1110
+ Example:
1111
+ ```python
1112
+ run_id = client.workflow("long_process").submit(data="...")
1113
+ # Check status later
1114
+ status = client.get_status(run_id)
1115
+ ```
1116
+ """
1117
+ return self._client.submit(
1118
+ component=self._workflow_name,
1119
+ input_data=kwargs,
1120
+ component_type="workflow",
1121
+ )
1122
+
1123
+
1124
+ class AsyncClient:
1125
+ """Async client for invoking AGNT5 components.
1126
+
1127
+ This client provides an async interface for calling functions, workflows,
1128
+ and other components deployed on AGNT5. Use this when you need to stream
1129
+ events in an async context or integrate with async frameworks.
1130
+
1131
+ Example:
1132
+ ```python
1133
+ import asyncio
1134
+ from agnt5 import AsyncClient, EventType
1135
+
1136
+ async def main():
1137
+ async with AsyncClient() as client:
1138
+ # Stream agent events asynchronously
1139
+ async for event in client.stream_events("my_agent", {"msg": "Hi"}, "agent"):
1140
+ if event.event_type == EventType.LM_MESSAGE_DELTA:
1141
+ print(event.data['content'], end='', flush=True)
1142
+
1143
+ asyncio.run(main())
1144
+ ```
1145
+ """
1146
+
1147
+ def __init__(
1148
+ self,
1149
+ gateway_url: str = "http://localhost:34181",
1150
+ timeout: float = 30.0,
1151
+ api_key: Optional[str] = None,
1152
+ ):
1153
+ """Initialize the async AGNT5 client.
1154
+
1155
+ Args:
1156
+ gateway_url: Base URL of the AGNT5 gateway (default: http://localhost:34181)
1157
+ timeout: Request timeout in seconds (default: 30.0)
1158
+ api_key: Service key for authentication. If not provided, falls back to
1159
+ AGNT5_API_KEY environment variable. Keys start with "agnt5_sk_".
1160
+ """
1161
+ self.gateway_url = gateway_url.rstrip("/")
1162
+ self.timeout = timeout
1163
+ # Use provided api_key or fallback to environment variable
1164
+ self.api_key = api_key or os.environ.get(AGNT5_API_KEY_ENV)
1165
+ self._client: Optional[httpx.AsyncClient] = None
1166
+
1167
+ def _build_headers(
1168
+ self,
1169
+ session_id: Optional[str] = None,
1170
+ user_id: Optional[str] = None,
1171
+ ) -> Dict[str, str]:
1172
+ """Build request headers with authentication and optional session/user context.
1173
+
1174
+ Args:
1175
+ session_id: Session identifier for multi-turn conversations
1176
+ user_id: User identifier for user-scoped memory
1177
+
1178
+ Returns:
1179
+ Dictionary of HTTP headers
1180
+ """
1181
+ headers = {"Content-Type": "application/json"}
1182
+ if self.api_key:
1183
+ headers["X-API-KEY"] = self.api_key
1184
+ if session_id:
1185
+ headers["X-Session-ID"] = session_id
1186
+ if user_id:
1187
+ headers["X-User-ID"] = user_id
1188
+ return headers
1189
+
1190
+ async def __aenter__(self) -> "AsyncClient":
1191
+ """Async context manager entry."""
1192
+ self._client = httpx.AsyncClient(timeout=self.timeout)
1193
+ return self
1194
+
1195
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
1196
+ """Async context manager exit."""
1197
+ if self._client:
1198
+ await self._client.aclose()
1199
+ self._client = None
1200
+
1201
+ async def _ensure_client(self) -> httpx.AsyncClient:
1202
+ """Ensure async client is available."""
1203
+ if self._client is None:
1204
+ self._client = httpx.AsyncClient(timeout=self.timeout)
1205
+ return self._client
1206
+
1207
+ async def close(self) -> None:
1208
+ """Close the underlying async HTTP client."""
1209
+ if self._client:
1210
+ await self._client.aclose()
1211
+ self._client = None
1212
+
1213
+ async def run(
1214
+ self,
1215
+ component: str,
1216
+ input_data: Optional[Dict[str, Any]] = None,
1217
+ component_type: str = "function",
1218
+ session_id: Optional[str] = None,
1219
+ user_id: Optional[str] = None,
1220
+ ) -> Dict[str, Any]:
1221
+ """Execute a component asynchronously and wait for the result.
1222
+
1223
+ Args:
1224
+ component: Name of the component to execute
1225
+ input_data: Input data for the component
1226
+ component_type: Type of component - "function", "workflow", "agent", "tool"
1227
+ session_id: Session identifier for multi-turn conversations
1228
+ user_id: User identifier for user-scoped memory
1229
+
1230
+ Returns:
1231
+ Dictionary containing the component's output
1232
+
1233
+ Raises:
1234
+ RunError: If the component execution fails
1235
+ httpx.HTTPError: If the HTTP request fails
1236
+ """
1237
+ if input_data is None:
1238
+ input_data = {}
1239
+
1240
+ client = await self._ensure_client()
1241
+ url = urljoin(self.gateway_url + "/", f"v1/run/{component_type}/{component}")
1242
+
1243
+ response = await client.post(
1244
+ url,
1245
+ json=input_data,
1246
+ headers=self._build_headers(session_id=session_id, user_id=user_id),
1247
+ )
1248
+
1249
+ if response.status_code == 404:
1250
+ try:
1251
+ error_data = response.json()
1252
+ raise RunError(
1253
+ error_data.get("error", "Component not found"),
1254
+ run_id=error_data.get("runId"),
1255
+ )
1256
+ except ValueError:
1257
+ raise RunError(f"Component '{component}' not found")
1258
+
1259
+ if response.status_code in (500, 503, 504):
1260
+ try:
1261
+ error_data = response.json()
1262
+ raise RunError(
1263
+ error_data.get("error", "Unknown error"),
1264
+ run_id=error_data.get("runId"),
1265
+ )
1266
+ except ValueError:
1267
+ response.raise_for_status()
1268
+ else:
1269
+ response.raise_for_status()
1270
+
1271
+ data = response.json()
1272
+ if data.get("status") == "failed":
1273
+ raise RunError(data.get("error", "Unknown error"), run_id=data.get("runId"))
1274
+
1275
+ return data.get("output", {})
1276
+
1277
+ async def stream_events(
1278
+ self,
1279
+ component: str,
1280
+ input_data: Optional[Dict[str, Any]] = None,
1281
+ component_type: str = "function",
1282
+ session_id: Optional[str] = None,
1283
+ user_id: Optional[str] = None,
1284
+ timeout: float = 300.0,
1285
+ ) -> AsyncIterator[Event]:
1286
+ """Async stream typed Event objects from a component execution.
1287
+
1288
+ This method yields Event objects as they arrive from the component,
1289
+ providing full access to the event taxonomy including agent lifecycle,
1290
+ LM streaming, tool calls, and workflow events.
1291
+
1292
+ Args:
1293
+ component: Name of the component to execute
1294
+ input_data: Input data for the component
1295
+ component_type: Type of component - "function", "workflow", "agent", "tool"
1296
+ session_id: Session identifier for multi-turn conversations
1297
+ user_id: User identifier for user-scoped memory
1298
+ timeout: Stream timeout in seconds (default: 300.0 / 5 minutes)
1299
+
1300
+ Yields:
1301
+ Event objects as they arrive from the stream
1302
+
1303
+ Raises:
1304
+ RunError: If the component execution fails
1305
+ httpx.HTTPError: If the HTTP request fails
1306
+
1307
+ Example:
1308
+ ```python
1309
+ async with AsyncClient() as client:
1310
+ async for event in client.stream_events("my_agent", {"msg": "Hi"}, "agent"):
1311
+ if event.event_type == EventType.AGENT_STARTED:
1312
+ print(f"Agent started: {event.data['agent_name']}")
1313
+ elif event.event_type == EventType.LM_MESSAGE_DELTA:
1314
+ print(event.data['content'], end='', flush=True)
1315
+ ```
1316
+ """
1317
+ if timeout <= 0:
1318
+ raise ValueError("timeout must be a positive number")
1319
+
1320
+ if input_data is None:
1321
+ input_data = {}
1322
+
1323
+ client = await self._ensure_client()
1324
+ url = urljoin(self.gateway_url + "/", f"v1/streamv2/{component_type}/{component}")
1325
+
1326
+ async with client.stream(
1327
+ "POST",
1328
+ url,
1329
+ json=input_data,
1330
+ headers=self._build_headers(session_id=session_id, user_id=user_id),
1331
+ timeout=timeout,
1332
+ ) as response:
1333
+ if response.status_code != 200:
1334
+ # Try to get error details from response body
1335
+ try:
1336
+ error_body = (await response.aread()).decode("utf-8")
1337
+ error_data = json.loads(error_body)
1338
+ error_msg = error_data.get("error", f"HTTP {response.status_code}")
1339
+ run_id = error_data.get("runId")
1340
+ except (json.JSONDecodeError, UnicodeDecodeError):
1341
+ error_msg = f"HTTP {response.status_code}: Streaming request failed"
1342
+ run_id = None
1343
+ raise RunError(error_msg, run_id=run_id)
1344
+
1345
+ current_event_type: Optional[str] = None
1346
+ async for line in response.aiter_lines():
1347
+ line = line.strip()
1348
+
1349
+ # Skip empty lines and comments (keep-alive)
1350
+ if not line or line.startswith(":"):
1351
+ continue
1352
+
1353
+ # Parse event type: "event: agent.started"
1354
+ if line.startswith("event: "):
1355
+ current_event_type = line[7:]
1356
+ continue
1357
+
1358
+ # Parse SSE data: "data: {...}"
1359
+ if line.startswith("data: "):
1360
+ data_str = line[6:]
1361
+
1362
+ try:
1363
+ data = json.loads(data_str)
1364
+
1365
+ # Check for completion signal
1366
+ if data.get("done") or current_event_type == "done":
1367
+ return
1368
+
1369
+ # Check for error event
1370
+ if current_event_type == "error" or "error" in data:
1371
+ error_msg = data.get("error", "Unknown streaming error")
1372
+ raise RunError(error_msg, run_id=data.get("runId"))
1373
+
1374
+ # Yield typed Event object
1375
+ if current_event_type:
1376
+ yield _parse_sse_to_event(current_event_type, data)
1377
+
1378
+ except json.JSONDecodeError:
1379
+ continue
1380
+
1381
+ async def submit(
1382
+ self,
1383
+ component: str,
1384
+ input_data: Optional[Dict[str, Any]] = None,
1385
+ component_type: str = "function",
1386
+ ) -> str:
1387
+ """Submit a component for async execution and return immediately.
1388
+
1389
+ Args:
1390
+ component: Name of the component to execute
1391
+ input_data: Input data for the component
1392
+ component_type: Type of component
1393
+
1394
+ Returns:
1395
+ String containing the run ID
1396
+ """
1397
+ if input_data is None:
1398
+ input_data = {}
1399
+
1400
+ client = await self._ensure_client()
1401
+ url = urljoin(self.gateway_url + "/", f"v1/submit/{component_type}/{component}")
1402
+
1403
+ response = await client.post(
1404
+ url,
1405
+ json=input_data,
1406
+ headers=self._build_headers(),
1407
+ )
1408
+ response.raise_for_status()
1409
+
1410
+ data = response.json()
1411
+ return data.get("runId", "")
1412
+
1413
+ async def get_status(self, run_id: str) -> Dict[str, Any]:
1414
+ """Get the current status of a run.
1415
+
1416
+ Args:
1417
+ run_id: The run ID returned from submit()
1418
+
1419
+ Returns:
1420
+ Dictionary containing status information
1421
+ """
1422
+ client = await self._ensure_client()
1423
+ url = urljoin(self.gateway_url + "/", f"v1/status/{run_id}")
1424
+
1425
+ response = await client.get(url, headers=self._build_headers())
1426
+ response.raise_for_status()
1427
+
1428
+ return response.json()
1429
+
1430
+ async def get_result(self, run_id: str) -> Dict[str, Any]:
1431
+ """Get the result of a completed run.
1432
+
1433
+ Args:
1434
+ run_id: The run ID returned from submit()
1435
+
1436
+ Returns:
1437
+ Dictionary containing the component's output
1438
+
1439
+ Raises:
1440
+ RunError: If the run failed or is not yet complete
1441
+ """
1442
+ client = await self._ensure_client()
1443
+ url = urljoin(self.gateway_url + "/", f"v1/result/{run_id}")
1444
+
1445
+ response = await client.get(url, headers=self._build_headers())
1446
+
1447
+ if response.status_code == 404:
1448
+ error_data = response.json()
1449
+ error_msg = error_data.get("error", "Run not found or not complete")
1450
+ current_status = error_data.get("status", "unknown")
1451
+ raise RunError(f"{error_msg} (status: {current_status})", run_id=run_id)
1452
+
1453
+ response.raise_for_status()
1454
+ data = response.json()
1455
+
1456
+ if data.get("status") == "failed":
1457
+ raise RunError(data.get("error", "Unknown error"), run_id=run_id)
1458
+
1459
+ return data.get("output", {})
1460
+
1461
+
1462
+ class RunError(Exception):
1463
+ """Raised when a component run fails on AGNT5.
1464
+
1465
+ Attributes:
1466
+ message: Error message describing what went wrong
1467
+ run_id: The unique run ID associated with this execution (if available)
1468
+ """
1469
+
1470
+ def __init__(self, message: str, run_id: Optional[str] = None):
1471
+ super().__init__(message)
1472
+ self.run_id = run_id
1473
+ self.message = message
1474
+
1475
+ def __str__(self):
1476
+ if self.run_id:
1477
+ return f"{self.message} (run_id: {self.run_id})"
1478
+ return self.message