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