basion-agent 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. basion_agent/__init__.py +62 -0
  2. basion_agent/agent.py +360 -0
  3. basion_agent/agent_state_client.py +149 -0
  4. basion_agent/app.py +502 -0
  5. basion_agent/artifact.py +58 -0
  6. basion_agent/attachment_client.py +153 -0
  7. basion_agent/checkpoint_client.py +169 -0
  8. basion_agent/checkpointer.py +16 -0
  9. basion_agent/cli.py +139 -0
  10. basion_agent/conversation.py +103 -0
  11. basion_agent/conversation_client.py +86 -0
  12. basion_agent/conversation_message.py +48 -0
  13. basion_agent/exceptions.py +36 -0
  14. basion_agent/extensions/__init__.py +1 -0
  15. basion_agent/extensions/langgraph.py +526 -0
  16. basion_agent/extensions/pydantic_ai.py +180 -0
  17. basion_agent/gateway_client.py +531 -0
  18. basion_agent/gateway_pb2.py +73 -0
  19. basion_agent/gateway_pb2_grpc.py +101 -0
  20. basion_agent/heartbeat.py +84 -0
  21. basion_agent/loki_handler.py +355 -0
  22. basion_agent/memory.py +73 -0
  23. basion_agent/memory_client.py +155 -0
  24. basion_agent/message.py +333 -0
  25. basion_agent/py.typed +0 -0
  26. basion_agent/streamer.py +184 -0
  27. basion_agent/structural/__init__.py +6 -0
  28. basion_agent/structural/artifact.py +94 -0
  29. basion_agent/structural/base.py +71 -0
  30. basion_agent/structural/stepper.py +125 -0
  31. basion_agent/structural/surface.py +90 -0
  32. basion_agent/structural/text_block.py +96 -0
  33. basion_agent/tools/__init__.py +19 -0
  34. basion_agent/tools/container.py +46 -0
  35. basion_agent/tools/knowledge_graph.py +306 -0
  36. basion_agent-0.4.0.dist-info/METADATA +880 -0
  37. basion_agent-0.4.0.dist-info/RECORD +41 -0
  38. basion_agent-0.4.0.dist-info/WHEEL +5 -0
  39. basion_agent-0.4.0.dist-info/entry_points.txt +2 -0
  40. basion_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
  41. basion_agent-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,62 @@
1
+ """Basion AI Agent SDK - Synchronous Python framework for building AI agents."""
2
+
3
+ __version__ = "0.4.0"
4
+
5
+ from .app import BasionAgentApp
6
+ from .agent import Agent
7
+ from .message import Message
8
+ from .conversation import Conversation
9
+ from .conversation_client import ConversationClient
10
+ from .conversation_message import ConversationMessage
11
+ from .checkpoint_client import CheckpointClient
12
+ from .memory import Memory
13
+ from .memory_client import (
14
+ MemoryClient,
15
+ MemoryMessage,
16
+ MemorySearchResult,
17
+ UserSummary,
18
+ )
19
+ from .attachment_client import AttachmentClient, AttachmentInfo
20
+ from .streamer import Streamer
21
+ from .structural import Artifact, Surface, TextBlock, Stepper
22
+ from .loki_handler import LokiLogHandler
23
+ from .tools import Tools, KnowledgeGraphTool
24
+ from .exceptions import (
25
+ BasionAgentError,
26
+ RegistrationError,
27
+ KafkaError,
28
+ ConfigurationError,
29
+ APIException,
30
+ HeartbeatError,
31
+ )
32
+
33
+ __all__ = [
34
+ "BasionAgentApp",
35
+ "Agent",
36
+ "Message",
37
+ "Conversation",
38
+ "ConversationClient",
39
+ "ConversationMessage",
40
+ "CheckpointClient",
41
+ "Memory",
42
+ "MemoryClient",
43
+ "MemoryMessage",
44
+ "MemorySearchResult",
45
+ "UserSummary",
46
+ "AttachmentClient",
47
+ "AttachmentInfo",
48
+ "Streamer",
49
+ "Artifact",
50
+ "Surface",
51
+ "TextBlock",
52
+ "Stepper",
53
+ "LokiLogHandler",
54
+ "Tools",
55
+ "KnowledgeGraphTool",
56
+ "BasionAgentError",
57
+ "RegistrationError",
58
+ "KafkaError",
59
+ "ConfigurationError",
60
+ "APIException",
61
+ "HeartbeatError",
62
+ ]
basion_agent/agent.py ADDED
@@ -0,0 +1,360 @@
1
+ """Core agent class for handling messages."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ from typing import Callable, Optional, Dict, Any, List, Tuple, Set
7
+
8
+ from .gateway_client import GatewayClient, KafkaMessage
9
+ from .heartbeat import HeartbeatManager
10
+ from .message import Message
11
+ from .conversation_client import ConversationClient
12
+ from .memory_client import MemoryClient
13
+ from .attachment_client import AttachmentClient
14
+ from .streamer import Streamer
15
+ from .exceptions import ConfigurationError
16
+ from .tools.container import Tools
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class SenderFilter:
22
+ """Filter for matching message senders."""
23
+
24
+ def __init__(self, senders: Optional[List[str]] = None):
25
+ self.include: Set[str] = set()
26
+ self.exclude: Set[str] = set()
27
+ self.match_all = senders is None or len(senders) == 0
28
+
29
+ if senders:
30
+ for s in senders:
31
+ if s.startswith("~"):
32
+ self.exclude.add(s[1:])
33
+ else:
34
+ self.include.add(s)
35
+
36
+ def matches(self, sender: str) -> bool:
37
+ """Check if sender matches this filter."""
38
+ if self.match_all:
39
+ return True
40
+ if sender in self.exclude:
41
+ return False
42
+ if self.include and sender not in self.include:
43
+ return False
44
+ return True
45
+
46
+
47
+ class Agent:
48
+ """Core agent class that handles messaging via the Agent Gateway."""
49
+
50
+ def __init__(
51
+ self,
52
+ name: str,
53
+ gateway_client: GatewayClient,
54
+ agent_data: Dict[str, Any],
55
+ heartbeat_interval: int = 60,
56
+ conversation_client: Optional[ConversationClient] = None,
57
+ memory_client: Optional[MemoryClient] = None,
58
+ attachment_client: Optional[AttachmentClient] = None,
59
+ error_message_template: str = "I encountered an error while processing your message. Please try again or contact support if the issue persists.",
60
+ ):
61
+ self.name = name
62
+ self.gateway_client = gateway_client
63
+ self.agent_data = agent_data
64
+ self.conversation_client = conversation_client
65
+ self.memory_client = memory_client
66
+ self.attachment_client = attachment_client
67
+ self.heartbeat_interval = heartbeat_interval
68
+
69
+ # Error handling configuration
70
+ self.error_message_template: str = error_message_template
71
+ self.send_error_responses: bool = True
72
+
73
+ self.heartbeat_manager: Optional[HeartbeatManager] = None
74
+ self._message_handlers: List[Tuple[SenderFilter, Callable]] = []
75
+ self._running = False
76
+ self._initialized = False
77
+ self._tools: Optional[Tools] = None
78
+
79
+ logger.info(f"Agent '{name}' created")
80
+
81
+ @property
82
+ def tools(self) -> Tools:
83
+ """
84
+ Access to tools (knowledge graph, etc.).
85
+
86
+ Example:
87
+ kg = agent.tools.knowledge_graph
88
+ diseases = await kg.search_diseases(name="Huntington")
89
+ """
90
+ if self._tools is None:
91
+ self._tools = Tools(self.gateway_client)
92
+ return self._tools
93
+
94
+ def _initialize_with_gateway(self):
95
+ """Initialize agent after gateway connection is established."""
96
+ if self._initialized:
97
+ return
98
+
99
+ # Start heartbeat via gateway proxy
100
+ self.heartbeat_manager = HeartbeatManager(
101
+ gateway_client=self.gateway_client,
102
+ agent_name=self.name,
103
+ interval=self.heartbeat_interval,
104
+ )
105
+ self.heartbeat_manager.start()
106
+
107
+ self._initialized = True
108
+ logger.info(f"Agent '{self.name}' initialized with gateway")
109
+
110
+ def on_message(self, handler_or_senders=None, *, senders: Optional[List[str]] = None):
111
+ """Register message handler with optional sender filter.
112
+
113
+ Supports both:
114
+ @agent.on_message # catch-all
115
+ @agent.on_message(senders=["user"]) # filtered
116
+
117
+ Args:
118
+ senders: List of sender names to match. Prefix with ~ to exclude.
119
+ Examples: ["user"], ["agent-1", "agent-2"], ["~user"]
120
+ """
121
+ def decorator(handler: Callable[[Message, str], None]):
122
+ sender_filter = SenderFilter(senders)
123
+ self._message_handlers.append((sender_filter, handler))
124
+ logger.info(f"Handler registered for agent '{self.name}' with senders={senders}")
125
+ return handler
126
+
127
+ # Called as @on_message (no parens) - handler_or_senders is the function
128
+ if callable(handler_or_senders):
129
+ return decorator(handler_or_senders)
130
+
131
+ # Called as @on_message(senders=[...]) - return decorator
132
+ return decorator
133
+
134
+ def _get_sender(self, headers: Dict[str, str]) -> str:
135
+ """Determine sender from headers."""
136
+ return headers.get("from_", "user")
137
+
138
+ def _find_matching_handler(self, sender: str) -> Optional[Callable]:
139
+ """Find first handler that matches the sender."""
140
+ for sender_filter, handler in self._message_handlers:
141
+ if sender_filter.matches(sender):
142
+ return handler
143
+ return None
144
+
145
+ def _send_error_response(
146
+ self,
147
+ message: Message,
148
+ error: Exception,
149
+ handler_name: Optional[str] = None
150
+ ) -> None:
151
+ """Send error response to user when handler fails (sync - deprecated).
152
+
153
+ Fail-safe design: wraps everything in try-except to prevent
154
+ cascading failures if error response itself fails.
155
+
156
+ Args:
157
+ message: The original message that caused the error
158
+ error: The exception that was caught
159
+ handler_name: Optional name of the handler that failed
160
+ """
161
+ if not self.send_error_responses:
162
+ return
163
+
164
+ try:
165
+ # Log detailed error for debugging with structured fields
166
+ handler_info = f" in handler '{handler_name}'" if handler_name else ""
167
+ logger.error(
168
+ f"Handler error{handler_info} for agent '{self.name}': {error}",
169
+ exc_info=True,
170
+ extra={
171
+ "agent_name": self.name,
172
+ "conversation_id": message.conversation_id,
173
+ "error_type": type(error).__name__,
174
+ }
175
+ )
176
+
177
+ # Create streamer with original message context
178
+ streamer = self.streamer(
179
+ original_message=message,
180
+ send_to="user"
181
+ )
182
+
183
+ # Send error message (stream is sync, but we can't await here)
184
+ streamer.stream(self.error_message_template)
185
+ # Note: Can't use async context manager in sync method
186
+
187
+ logger.info(
188
+ f"Error response sent to user for conversation {message.conversation_id}"
189
+ )
190
+
191
+ except Exception as send_error:
192
+ # Fail-safe: if sending error response fails, just log it
193
+ logger.error(
194
+ f"Failed to send error response: {send_error}",
195
+ exc_info=True,
196
+ extra={
197
+ "agent_name": self.name,
198
+ "conversation_id": message.conversation_id,
199
+ "original_error": str(error),
200
+ }
201
+ )
202
+
203
+ async def _send_error_response_async(
204
+ self,
205
+ message: Message,
206
+ error: Exception,
207
+ handler_name: Optional[str] = None
208
+ ) -> None:
209
+ """Send error response to user when handler fails (async).
210
+
211
+ Fail-safe design: wraps everything in try-except to prevent
212
+ cascading failures if error response itself fails.
213
+
214
+ Args:
215
+ message: The original message that caused the error
216
+ error: The exception that was caught
217
+ handler_name: Optional name of the handler that failed
218
+ """
219
+ if not self.send_error_responses:
220
+ return
221
+
222
+ try:
223
+ # Log detailed error for debugging with structured fields
224
+ handler_info = f" in handler '{handler_name}'" if handler_name else ""
225
+ logger.error(
226
+ f"Handler error{handler_info} for agent '{self.name}': {error}",
227
+ exc_info=True,
228
+ extra={
229
+ "agent_name": self.name,
230
+ "conversation_id": message.conversation_id,
231
+ "error_type": type(error).__name__,
232
+ }
233
+ )
234
+
235
+ # Create streamer with original message context
236
+ streamer = self.streamer(
237
+ original_message=message,
238
+ send_to="user"
239
+ )
240
+
241
+ # Send error message using async context manager pattern
242
+ async with streamer as s:
243
+ s.stream(self.error_message_template)
244
+
245
+ logger.info(
246
+ f"Error response sent to user for conversation {message.conversation_id}"
247
+ )
248
+
249
+ except Exception as send_error:
250
+ # Fail-safe: if sending error response fails, just log it
251
+ logger.error(
252
+ f"Failed to send error response: {send_error}",
253
+ exc_info=True,
254
+ extra={
255
+ "agent_name": self.name,
256
+ "conversation_id": message.conversation_id,
257
+ "original_error": str(error),
258
+ }
259
+ )
260
+
261
+ async def _handle_message_async(self, kafka_msg: KafkaMessage):
262
+ """Handle incoming Kafka message asynchronously."""
263
+ message = None
264
+ handler = None
265
+
266
+ try:
267
+ headers = kafka_msg.headers
268
+ body = kafka_msg.body if isinstance(kafka_msg.body, dict) else {}
269
+
270
+ sender = self._get_sender(headers)
271
+ handler = self._find_matching_handler(sender)
272
+
273
+ if handler is None:
274
+ logger.warning(f"No handler matched for sender: {sender}")
275
+ return
276
+
277
+ # Create message before handler execution
278
+ message = Message.from_kafka_message(
279
+ body, headers,
280
+ conversation_client=self.conversation_client,
281
+ memory_client=self.memory_client,
282
+ attachment_client=self.attachment_client,
283
+ agent_name=self.name,
284
+ )
285
+
286
+ # Execute handler based on whether it's async or sync
287
+ if inspect.iscoroutinefunction(handler):
288
+ # Async handler - await it directly
289
+ await handler(message, sender)
290
+ else:
291
+ # Sync handler - run in thread pool for backward compatibility
292
+ loop = asyncio.get_event_loop()
293
+ await loop.run_in_executor(None, handler, message, sender)
294
+
295
+ except Exception as e:
296
+ # Handler execution failed - send error response to user
297
+ # Only send if we successfully created the message
298
+ if message is not None:
299
+ handler_name = handler.__name__ if handler and hasattr(handler, '__name__') else None
300
+ await self._send_error_response_async(message, e, handler_name)
301
+ else:
302
+ # Message creation failed - just log (can't respond without context)
303
+ logger.error(
304
+ f"Error processing Kafka message before handler execution: {e}",
305
+ exc_info=True,
306
+ extra={"agent_name": self.name}
307
+ )
308
+
309
+ def start_consuming(self):
310
+ """Mark agent as ready to consume messages.
311
+
312
+ Note: Actual consumption is handled by the gateway client's consume loop.
313
+ This method just sets the agent as running and waits for shutdown.
314
+ """
315
+ if not self._message_handlers:
316
+ raise ConfigurationError("No message handlers registered.")
317
+
318
+ if not self._initialized:
319
+ raise ConfigurationError("Agent not initialized. Call _initialize_with_gateway first.")
320
+
321
+ logger.info(f"Agent '{self.name}' ready to consume messages")
322
+ self._running = True
323
+
324
+ # The gateway client handles all message consumption in a single thread.
325
+ # We just keep this thread alive until shutdown.
326
+ import time
327
+ while self._running:
328
+ time.sleep(1.0)
329
+
330
+ def streamer(
331
+ self,
332
+ original_message: Message,
333
+ awaiting: bool = False,
334
+ send_to: str = "user",
335
+ ) -> Streamer:
336
+ """Create a new Streamer for streaming responses.
337
+
338
+ Args:
339
+ original_message: The message being responded to
340
+ awaiting: If True (and using context manager), sets awaiting_route on finish
341
+ send_to: Target for the response - "user" (default) or another agent name
342
+ """
343
+ return Streamer(
344
+ agent_name=self.name,
345
+ original_message=original_message,
346
+ gateway_client=self.gateway_client,
347
+ conversation_client=self.conversation_client,
348
+ awaiting=awaiting,
349
+ send_to=send_to,
350
+ )
351
+
352
+ def shutdown(self):
353
+ """Shutdown agent gracefully."""
354
+ logger.info(f"Shutting down agent '{self.name}'...")
355
+ self._running = False
356
+
357
+ if self.heartbeat_manager:
358
+ self.heartbeat_manager.stop()
359
+
360
+ logger.info(f"Agent '{self.name}' shutdown complete")
@@ -0,0 +1,149 @@
1
+ """HTTP client for agent state storage API."""
2
+
3
+ import logging
4
+ from typing import Dict, Any, Optional
5
+ import aiohttp
6
+
7
+ from .exceptions import APIException
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class AgentStateClient:
13
+ """Async HTTP client for agent-state API.
14
+
15
+ This client communicates with the conversation-store's agent-state endpoints
16
+ to store and retrieve framework-specific state data.
17
+
18
+ Example:
19
+ ```python
20
+ client = AgentStateClient("http://gateway/s/conversation-store")
21
+
22
+ # Store state
23
+ await client.put("conv-123", "pydantic_ai", {"messages": [...]})
24
+
25
+ # Get state
26
+ state = await client.get("conv-123", "pydantic_ai")
27
+
28
+ # Delete state
29
+ await client.delete("conv-123", "pydantic_ai")
30
+ ```
31
+ """
32
+
33
+ def __init__(self, base_url: str):
34
+ """Initialize agent state client.
35
+
36
+ Args:
37
+ base_url: Base URL for the agent-state API
38
+ (e.g., http://gateway/s/conversation-store)
39
+ """
40
+ self.base_url = base_url.rstrip("/")
41
+ self._session: Optional[aiohttp.ClientSession] = None
42
+
43
+ async def _get_session(self) -> aiohttp.ClientSession:
44
+ """Get or create aiohttp session."""
45
+ if self._session is None or self._session.closed:
46
+ timeout = aiohttp.ClientTimeout(total=30.0)
47
+ self._session = aiohttp.ClientSession(timeout=timeout)
48
+ return self._session
49
+
50
+ async def close(self):
51
+ """Close the aiohttp session."""
52
+ if self._session and not self._session.closed:
53
+ await self._session.close()
54
+
55
+ async def put(
56
+ self,
57
+ conversation_id: str,
58
+ namespace: str,
59
+ state: Dict[str, Any],
60
+ ) -> Dict[str, Any]:
61
+ """Store or update agent state.
62
+
63
+ Args:
64
+ conversation_id: Conversation identifier
65
+ namespace: Framework namespace (e.g., 'pydantic_ai')
66
+ state: State data to store (will be serialized as JSON)
67
+
68
+ Returns:
69
+ Response with conversation_id, namespace, and created flag
70
+ """
71
+ url = f"{self.base_url}/agent-state/{conversation_id}"
72
+ payload = {
73
+ "namespace": namespace,
74
+ "state": state,
75
+ }
76
+
77
+ session = await self._get_session()
78
+ try:
79
+ async with session.put(url, json=payload) as response:
80
+ if response.status >= 400:
81
+ error_text = await response.text()
82
+ raise APIException(
83
+ f"Failed to store agent state: {response.status} - {error_text}"
84
+ )
85
+ return await response.json()
86
+ except aiohttp.ClientError as e:
87
+ raise APIException(f"Failed to store agent state: {e}")
88
+
89
+ async def get(
90
+ self,
91
+ conversation_id: str,
92
+ namespace: str,
93
+ ) -> Optional[Dict[str, Any]]:
94
+ """Get agent state.
95
+
96
+ Args:
97
+ conversation_id: Conversation identifier
98
+ namespace: Framework namespace (e.g., 'pydantic_ai')
99
+
100
+ Returns:
101
+ AgentStateResponse dict or None if not found
102
+ """
103
+ url = f"{self.base_url}/agent-state/{conversation_id}"
104
+ params = {"namespace": namespace}
105
+
106
+ session = await self._get_session()
107
+ try:
108
+ async with session.get(url, params=params) as response:
109
+ if response.status == 404:
110
+ return None
111
+ if response.status >= 400:
112
+ error_text = await response.text()
113
+ raise APIException(
114
+ f"Failed to get agent state: {response.status} - {error_text}"
115
+ )
116
+ return await response.json()
117
+ except aiohttp.ClientError as e:
118
+ raise APIException(f"Failed to get agent state: {e}")
119
+
120
+ async def delete(
121
+ self,
122
+ conversation_id: str,
123
+ namespace: Optional[str] = None,
124
+ ) -> Dict[str, Any]:
125
+ """Delete agent state.
126
+
127
+ Args:
128
+ conversation_id: Conversation identifier
129
+ namespace: Framework namespace (optional, deletes all if not provided)
130
+
131
+ Returns:
132
+ Response with conversation_id, namespace, and deleted count
133
+ """
134
+ url = f"{self.base_url}/agent-state/{conversation_id}"
135
+ params = {}
136
+ if namespace:
137
+ params["namespace"] = namespace
138
+
139
+ session = await self._get_session()
140
+ try:
141
+ async with session.delete(url, params=params) as response:
142
+ if response.status >= 400:
143
+ error_text = await response.text()
144
+ raise APIException(
145
+ f"Failed to delete agent state: {response.status} - {error_text}"
146
+ )
147
+ return await response.json()
148
+ except aiohttp.ClientError as e:
149
+ raise APIException(f"Failed to delete agent state: {e}")