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