agnt5 0.2.8a2__cp310-abi3-macosx_10_12_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,724 @@
1
+ """AGNT5 Client SDK for invoking components."""
2
+
3
+ import json
4
+ from typing import Any, Dict, Optional
5
+ from urllib.parse import urljoin
6
+
7
+ import httpx
8
+
9
+
10
+ class Client:
11
+ """Client for invoking AGNT5 components.
12
+
13
+ This client provides a simple interface for calling functions, workflows,
14
+ and other components deployed on AGNT5.
15
+
16
+ Example:
17
+ ```python
18
+ from agnt5 import Client
19
+
20
+ client = Client("http://localhost:34181")
21
+ result = client.run("greet", {"name": "Alice"})
22
+ print(result) # {"message": "Hello, Alice!"}
23
+ ```
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ gateway_url: str = "http://localhost:34181",
29
+ timeout: float = 30.0,
30
+ ):
31
+ """Initialize the AGNT5 client.
32
+
33
+ Args:
34
+ gateway_url: Base URL of the AGNT5 gateway (default: http://localhost:34181)
35
+ timeout: Request timeout in seconds (default: 30.0)
36
+ """
37
+ self.gateway_url = gateway_url.rstrip("/")
38
+ self.timeout = timeout
39
+ self._client = httpx.Client(timeout=timeout)
40
+
41
+ def run(
42
+ self,
43
+ component: str,
44
+ input_data: Optional[Dict[str, Any]] = None,
45
+ component_type: str = "function",
46
+ ) -> Dict[str, Any]:
47
+ """Execute a component synchronously and wait for the result.
48
+
49
+ This is a blocking call that waits for the component to complete execution.
50
+
51
+ Args:
52
+ component: Name of the component to execute
53
+ input_data: Input data for the component (will be sent as JSON body)
54
+ component_type: Type of component - "function", "workflow", "agent", "tool" (default: "function")
55
+
56
+ Returns:
57
+ Dictionary containing the component's output
58
+
59
+ Raises:
60
+ RunError: If the component execution fails
61
+ httpx.HTTPError: If the HTTP request fails
62
+
63
+ Example:
64
+ ```python
65
+ # Simple function call (default)
66
+ result = client.run("greet", {"name": "Alice"})
67
+
68
+ # Workflow execution (explicit)
69
+ result = client.run("order_fulfillment", {"order_id": "123"}, component_type="workflow")
70
+
71
+ # No input data
72
+ result = client.run("get_status")
73
+ ```
74
+ """
75
+ if input_data is None:
76
+ input_data = {}
77
+
78
+ # Build URL with component type
79
+ url = urljoin(self.gateway_url + "/", f"v1/run/{component_type}/{component}")
80
+
81
+ # Make request
82
+ response = self._client.post(
83
+ url,
84
+ json=input_data,
85
+ headers={"Content-Type": "application/json"},
86
+ )
87
+
88
+ # Handle errors
89
+ if response.status_code == 404:
90
+ try:
91
+ error_data = response.json()
92
+ raise RunError(
93
+ error_data.get("error", "Component not found"),
94
+ run_id=error_data.get("runId"),
95
+ )
96
+ except ValueError:
97
+ # JSON parsing failed
98
+ raise RunError(f"Component '{component}' not found")
99
+
100
+ if response.status_code == 503:
101
+ error_data = response.json()
102
+ raise RunError(
103
+ f"Service unavailable: {error_data.get('error', 'Unknown error')}",
104
+ run_id=error_data.get("runId"),
105
+ )
106
+
107
+ if response.status_code == 504:
108
+ error_data = response.json()
109
+ raise RunError(
110
+ "Execution timeout",
111
+ run_id=error_data.get("runId"),
112
+ )
113
+
114
+ # Handle 500 errors with our RunResponse format
115
+ if response.status_code == 500:
116
+ try:
117
+ error_data = response.json()
118
+ raise RunError(
119
+ error_data.get("error", "Unknown error"),
120
+ run_id=error_data.get("runId"),
121
+ )
122
+ except ValueError:
123
+ # JSON parsing failed, fall through to raise_for_status
124
+ response.raise_for_status()
125
+ else:
126
+ # For other error codes, use standard HTTP error handling
127
+ response.raise_for_status()
128
+
129
+ # Parse response
130
+ data = response.json()
131
+
132
+ # Check execution status
133
+ if data.get("status") == "failed":
134
+ raise RunError(
135
+ data.get("error", "Unknown error"),
136
+ run_id=data.get("runId"),
137
+ )
138
+
139
+ # Return output
140
+ return data.get("output", {})
141
+
142
+ def submit(
143
+ self,
144
+ component: str,
145
+ input_data: Optional[Dict[str, Any]] = None,
146
+ component_type: str = "function",
147
+ ) -> str:
148
+ """Submit a component for async execution and return immediately.
149
+
150
+ This is a non-blocking call that returns a run ID immediately.
151
+ Use get_status() to check progress and get_result() to retrieve the output.
152
+
153
+ Args:
154
+ component: Name of the component to execute
155
+ input_data: Input data for the component (will be sent as JSON body)
156
+ component_type: Type of component - "function", "workflow", "agent", "tool" (default: "function")
157
+
158
+ Returns:
159
+ String containing the run ID
160
+
161
+ Raises:
162
+ httpx.HTTPError: If the HTTP request fails
163
+
164
+ Example:
165
+ ```python
166
+ # Submit async function (default)
167
+ run_id = client.submit("process_video", {"url": "https://..."})
168
+ print(f"Submitted: {run_id}")
169
+
170
+ # Submit workflow
171
+ run_id = client.submit("order_fulfillment", {"order_id": "123"}, component_type="workflow")
172
+
173
+ # Check status later
174
+ status = client.get_status(run_id)
175
+ if status["status"] == "completed":
176
+ result = client.get_result(run_id)
177
+ ```
178
+ """
179
+ if input_data is None:
180
+ input_data = {}
181
+
182
+ # Build URL with component type
183
+ url = urljoin(self.gateway_url + "/", f"v1/submit/{component_type}/{component}")
184
+
185
+ # Make request
186
+ response = self._client.post(
187
+ url,
188
+ json=input_data,
189
+ headers={"Content-Type": "application/json"},
190
+ )
191
+
192
+ # Handle errors
193
+ response.raise_for_status()
194
+
195
+ # Parse response and extract run ID
196
+ data = response.json()
197
+ return data.get("runId", "")
198
+
199
+ def get_status(self, run_id: str) -> Dict[str, Any]:
200
+ """Get the current status of a run.
201
+
202
+ Args:
203
+ run_id: The run ID returned from submit()
204
+
205
+ Returns:
206
+ Dictionary containing status information:
207
+ {
208
+ "runId": "...",
209
+ "status": "pending|running|completed|failed|cancelled",
210
+ "submittedAt": 1234567890,
211
+ "startedAt": 1234567891, // optional
212
+ "completedAt": 1234567892 // optional
213
+ }
214
+
215
+ Raises:
216
+ httpx.HTTPError: If the HTTP request fails
217
+
218
+ Example:
219
+ ```python
220
+ status = client.get_status(run_id)
221
+ print(f"Status: {status['status']}")
222
+ ```
223
+ """
224
+ url = urljoin(self.gateway_url + "/", f"v1/status/{run_id}")
225
+
226
+ response = self._client.get(url)
227
+ response.raise_for_status()
228
+
229
+ return response.json()
230
+
231
+ def get_result(self, run_id: str) -> Dict[str, Any]:
232
+ """Get the result of a completed run.
233
+
234
+ This will raise an error if the run is not yet complete.
235
+
236
+ Args:
237
+ run_id: The run ID returned from submit()
238
+
239
+ Returns:
240
+ Dictionary containing the component's output
241
+
242
+ Raises:
243
+ RunError: If the run failed or is not yet complete
244
+ httpx.HTTPError: If the HTTP request fails
245
+
246
+ Example:
247
+ ```python
248
+ try:
249
+ result = client.get_result(run_id)
250
+ print(result)
251
+ except RunError as e:
252
+ if "not complete" in str(e):
253
+ print("Run is still in progress")
254
+ else:
255
+ print(f"Run failed: {e}")
256
+ ```
257
+ """
258
+ url = urljoin(self.gateway_url + "/", f"v1/result/{run_id}")
259
+
260
+ response = self._client.get(url)
261
+
262
+ # Handle 404 - run not complete or not found
263
+ if response.status_code == 404:
264
+ error_data = response.json()
265
+ error_msg = error_data.get("error", "Run not found or not complete")
266
+ current_status = error_data.get("status", "unknown")
267
+ raise RunError(f"{error_msg} (status: {current_status})", run_id=run_id)
268
+
269
+ # Handle other errors
270
+ response.raise_for_status()
271
+
272
+ # Parse response
273
+ data = response.json()
274
+
275
+ # Check if run failed
276
+ if data.get("status") == "failed":
277
+ raise RunError(
278
+ data.get("error", "Unknown error"),
279
+ run_id=run_id,
280
+ )
281
+
282
+ # Return output
283
+ return data.get("output", {})
284
+
285
+ def wait_for_result(
286
+ self,
287
+ run_id: str,
288
+ timeout: float = 300.0,
289
+ poll_interval: float = 1.0,
290
+ ) -> Dict[str, Any]:
291
+ """Wait for a run to complete and return the result.
292
+
293
+ This polls the status endpoint until the run completes or times out.
294
+
295
+ Args:
296
+ run_id: The run ID returned from submit()
297
+ timeout: Maximum time to wait in seconds (default: 300)
298
+ poll_interval: How often to check status in seconds (default: 1.0)
299
+
300
+ Returns:
301
+ Dictionary containing the component's output
302
+
303
+ Raises:
304
+ RunError: If the run fails or times out
305
+ httpx.HTTPError: If the HTTP request fails
306
+
307
+ Example:
308
+ ```python
309
+ # Submit and wait for result
310
+ run_id = client.submit("long_task", {"data": "..."})
311
+ try:
312
+ result = client.wait_for_result(run_id, timeout=600)
313
+ print(result)
314
+ except RunError as e:
315
+ print(f"Failed: {e}")
316
+ ```
317
+ """
318
+ import time
319
+
320
+ start_time = time.time()
321
+
322
+ while True:
323
+ # Check timeout
324
+ elapsed = time.time() - start_time
325
+ if elapsed >= timeout:
326
+ raise RunError(
327
+ f"Timeout waiting for run to complete after {timeout}s",
328
+ run_id=run_id,
329
+ )
330
+
331
+ # Get current status
332
+ status = self.get_status(run_id)
333
+ current_status = status.get("status", "")
334
+
335
+ # Check if complete
336
+ if current_status in ("completed", "failed", "cancelled"):
337
+ # Get result (will raise if failed)
338
+ return self.get_result(run_id)
339
+
340
+ # Wait before next poll
341
+ time.sleep(poll_interval)
342
+
343
+ def stream(
344
+ self,
345
+ component: str,
346
+ input_data: Optional[Dict[str, Any]] = None,
347
+ ):
348
+ """Stream responses from a component using Server-Sent Events (SSE).
349
+
350
+ This method yields chunks as they arrive from the component.
351
+ Perfect for LLM token streaming and incremental responses.
352
+
353
+ Args:
354
+ component: Name of the component to execute
355
+ input_data: Input data for the component (will be sent as JSON body)
356
+
357
+ Yields:
358
+ String chunks as they arrive from the component
359
+
360
+ Raises:
361
+ RunError: If the component execution fails
362
+ httpx.HTTPError: If the HTTP request fails
363
+
364
+ Example:
365
+ ```python
366
+ # Stream LLM tokens
367
+ for chunk in client.stream("generate_text", {"prompt": "Write a story"}):
368
+ print(chunk, end="", flush=True)
369
+ ```
370
+ """
371
+ if input_data is None:
372
+ input_data = {}
373
+
374
+ # Build URL
375
+ url = urljoin(self.gateway_url + "/", f"v1/stream/{component}")
376
+
377
+ # Use streaming request
378
+ with self._client.stream(
379
+ "POST",
380
+ url,
381
+ json=input_data,
382
+ headers={"Content-Type": "application/json"},
383
+ timeout=300.0, # 5 minute timeout for streaming
384
+ ) as response:
385
+ # Check for errors
386
+ if response.status_code != 200:
387
+ # For streaming responses, we can't read the full text
388
+ # Just raise an HTTP error
389
+ raise RunError(
390
+ f"HTTP {response.status_code}: Streaming request failed",
391
+ run_id=None,
392
+ )
393
+
394
+ # Parse SSE stream
395
+ for line in response.iter_lines():
396
+ line = line.strip()
397
+
398
+ # Skip empty lines and comments
399
+ if not line or line.startswith(":"):
400
+ continue
401
+
402
+ # Parse SSE format: "data: {...}"
403
+ if line.startswith("data: "):
404
+ data_str = line[6:] # Remove "data: " prefix
405
+
406
+ try:
407
+ data = json.loads(data_str)
408
+
409
+ # Check for completion
410
+ if data.get("done"):
411
+ return
412
+
413
+ # Check for error
414
+ if "error" in data:
415
+ raise RunError(
416
+ data.get("error"),
417
+ run_id=data.get("runId"),
418
+ )
419
+
420
+ # Yield chunk
421
+ if "chunk" in data:
422
+ yield data["chunk"]
423
+
424
+ except json.JSONDecodeError:
425
+ # Skip malformed JSON
426
+ continue
427
+
428
+ def entity(self, entity_type: str, key: str) -> "EntityProxy":
429
+ """Get a proxy for calling methods on a durable entity.
430
+
431
+ This provides a fluent API for entity method invocations with key-based routing.
432
+
433
+ Args:
434
+ entity_type: The entity class name (e.g., "Counter", "ShoppingCart")
435
+ key: The entity instance key (e.g., "user-123", "cart-alice")
436
+
437
+ Returns:
438
+ EntityProxy that allows method calls on the entity
439
+
440
+ Example:
441
+ ```python
442
+ # Call entity method
443
+ result = client.entity("Counter", "user-123").increment(amount=5)
444
+ print(result) # 5
445
+
446
+ # Shopping cart
447
+ result = client.entity("ShoppingCart", "user-alice").add_item(
448
+ item_id="item-123",
449
+ quantity=2,
450
+ price=29.99
451
+ )
452
+ ```
453
+ """
454
+ return EntityProxy(self, entity_type, key)
455
+
456
+ def session(self, session_type: str, key: str) -> "SessionProxy":
457
+ """Get a proxy for a session entity (OpenAI/ADK-style API).
458
+
459
+ This is a convenience wrapper around entity() specifically for SessionEntity subclasses,
460
+ providing a familiar API for developers coming from OpenAI Agents SDK or Google ADK.
461
+
462
+ Args:
463
+ session_type: The session entity class name (e.g., "Conversation", "ChatSession")
464
+ key: The session instance key (typically user ID or session ID)
465
+
466
+ Returns:
467
+ SessionProxy that provides session-specific methods
468
+
469
+ Example:
470
+ ```python
471
+ # Create a conversation session
472
+ session = client.session("Conversation", "user-alice")
473
+
474
+ # Chat with the session
475
+ response = session.chat("Hello! How are you?")
476
+ print(response)
477
+
478
+ # Get conversation history
479
+ history = session.get_history()
480
+ for msg in history:
481
+ print(f"{msg['role']}: {msg['content']}")
482
+ ```
483
+ """
484
+ return SessionProxy(self, session_type, key)
485
+
486
+ def close(self):
487
+ """Close the underlying HTTP client."""
488
+ self._client.close()
489
+
490
+ def __enter__(self):
491
+ """Context manager entry."""
492
+ return self
493
+
494
+ def __exit__(self, exc_type, exc_val, exc_tb):
495
+ """Context manager exit."""
496
+ self.close()
497
+
498
+
499
+ class EntityProxy:
500
+ """Proxy for calling methods on a durable entity instance.
501
+
502
+ This class enables fluent method calls on entities using Python's
503
+ attribute access. Any method call is translated to an HTTP request
504
+ to /entity/:type/:key/:method.
505
+
506
+ Example:
507
+ ```python
508
+ counter = client.entity("Counter", "user-123")
509
+ result = counter.increment(amount=5) # Calls /entity/Counter/user-123/increment
510
+ ```
511
+ """
512
+
513
+ def __init__(self, client: "Client", entity_type: str, key: str):
514
+ """Initialize entity proxy.
515
+
516
+ Args:
517
+ client: The AGNT5 client instance
518
+ entity_type: The entity class name
519
+ key: The entity instance key
520
+ """
521
+ self._client = client
522
+ self._entity_type = entity_type
523
+ self._key = key
524
+
525
+ def __getattr__(self, method_name: str):
526
+ """Dynamic method lookup that creates entity method callers.
527
+
528
+ Args:
529
+ method_name: The entity method to call
530
+
531
+ Returns:
532
+ Callable that executes the entity method
533
+ """
534
+
535
+ def method_caller(*args, **kwargs) -> Any:
536
+ """Call an entity method with the given parameters.
537
+
538
+ Args:
539
+ *args: Positional arguments (not recommended, use kwargs)
540
+ **kwargs: Method parameters as keyword arguments
541
+
542
+ Returns:
543
+ The method's return value
544
+
545
+ Raises:
546
+ RunError: If the method execution fails
547
+ ValueError: If both positional and keyword arguments are provided
548
+ """
549
+ # Convert positional args to kwargs if provided
550
+ if args and kwargs:
551
+ raise ValueError(
552
+ f"Cannot mix positional and keyword arguments when calling entity method '{method_name}'. "
553
+ "Please use keyword arguments only."
554
+ )
555
+
556
+ # If positional args provided, we can't convert them without knowing parameter names
557
+ # Raise helpful error
558
+ if args:
559
+ raise ValueError(
560
+ f"Entity method '{method_name}' requires keyword arguments, but got {len(args)} positional arguments. "
561
+ f"Example: .{method_name}(param1=value1, param2=value2)"
562
+ )
563
+
564
+ # Build URL: /v1/entity/:entityType/:key/:method
565
+ url = urljoin(
566
+ self._client.gateway_url + "/",
567
+ f"v1/entity/{self._entity_type}/{self._key}/{method_name}",
568
+ )
569
+
570
+ # Make request with method parameters as JSON body
571
+ response = self._client._client.post(
572
+ url,
573
+ json=kwargs,
574
+ headers={"Content-Type": "application/json"},
575
+ )
576
+
577
+ # Handle errors
578
+ if response.status_code == 504:
579
+ error_data = response.json()
580
+ raise RunError(
581
+ "Execution timeout",
582
+ run_id=error_data.get("run_id"),
583
+ )
584
+
585
+ if response.status_code == 500:
586
+ try:
587
+ error_data = response.json()
588
+ raise RunError(
589
+ error_data.get("error", "Unknown error"),
590
+ run_id=error_data.get("run_id"),
591
+ )
592
+ except ValueError:
593
+ response.raise_for_status()
594
+ else:
595
+ response.raise_for_status()
596
+
597
+ # Parse response
598
+ data = response.json()
599
+
600
+ # Check execution status
601
+ if data.get("status") == "failed":
602
+ raise RunError(
603
+ data.get("error", "Unknown error"),
604
+ run_id=data.get("run_id"),
605
+ )
606
+
607
+ # Return output
608
+ return data.get("output")
609
+
610
+ return method_caller
611
+
612
+
613
+ class SessionProxy(EntityProxy):
614
+ """Proxy for session entities with conversation-specific helper methods.
615
+
616
+ This extends EntityProxy to provide familiar APIs for session-based
617
+ conversations, similar to OpenAI Agents SDK and Google ADK.
618
+
619
+ Example:
620
+ ```python
621
+ # Create a session
622
+ session = client.session("Conversation", "user-alice")
623
+
624
+ # Chat
625
+ response = session.chat("Tell me about AI")
626
+
627
+ # Get history
628
+ history = session.get_history()
629
+ ```
630
+ """
631
+
632
+ def chat(self, message: str, **kwargs) -> str:
633
+ """Send a message to the conversation session.
634
+
635
+ This is a convenience method that calls the `chat` method on the
636
+ underlying SessionEntity and returns just the response text.
637
+
638
+ Args:
639
+ message: The user's message
640
+ **kwargs: Additional parameters to pass to the chat method
641
+
642
+ Returns:
643
+ The assistant's response as a string
644
+
645
+ Example:
646
+ ```python
647
+ response = session.chat("What is the weather today?")
648
+ print(response)
649
+ ```
650
+ """
651
+ # Call the chat method via the entity proxy
652
+ result = self.__getattr__("chat")(message=message, **kwargs)
653
+
654
+ # SessionEntity.chat() returns a dict with 'response' key
655
+ if isinstance(result, dict) and "response" in result:
656
+ return result["response"]
657
+
658
+ # If it's already a string, return as-is
659
+ return str(result)
660
+
661
+ def get_history(self) -> list:
662
+ """Get the conversation history for this session.
663
+
664
+ Returns:
665
+ List of message dictionaries with 'role' and 'content' keys
666
+
667
+ Example:
668
+ ```python
669
+ history = session.get_history()
670
+ for msg in history:
671
+ print(f"{msg['role']}: {msg['content']}")
672
+ ```
673
+ """
674
+ return self.__getattr__("get_history")()
675
+
676
+ def add_message(self, role: str, content: str) -> dict:
677
+ """Add a message to the conversation history.
678
+
679
+ Args:
680
+ role: Message role ('user', 'assistant', or 'system')
681
+ content: Message content
682
+
683
+ Returns:
684
+ Dictionary confirming the message was added
685
+
686
+ Example:
687
+ ```python
688
+ session.add_message("system", "You are a helpful assistant")
689
+ session.add_message("user", "Hello!")
690
+ ```
691
+ """
692
+ return self.__getattr__("add_message")(role=role, content=content)
693
+
694
+ def clear_history(self) -> dict:
695
+ """Clear the conversation history for this session.
696
+
697
+ Returns:
698
+ Dictionary confirming the history was cleared
699
+
700
+ Example:
701
+ ```python
702
+ session.clear_history()
703
+ ```
704
+ """
705
+ return self.__getattr__("clear_history")()
706
+
707
+
708
+ class RunError(Exception):
709
+ """Raised when a component run fails on AGNT5.
710
+
711
+ Attributes:
712
+ message: Error message describing what went wrong
713
+ run_id: The unique run ID associated with this execution (if available)
714
+ """
715
+
716
+ def __init__(self, message: str, run_id: Optional[str] = None):
717
+ super().__init__(message)
718
+ self.run_id = run_id
719
+ self.message = message
720
+
721
+ def __str__(self):
722
+ if self.run_id:
723
+ return f"{self.message} (run_id: {self.run_id})"
724
+ return self.message