march-agent 0.1.1__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.
@@ -0,0 +1,52 @@
1
+ """March AI Agent SDK - Synchronous Python framework for building AI agents."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ from .app import MarchAgentApp
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 .streamer import Streamer
20
+ from .artifact import Artifact, ArtifactType
21
+ from .exceptions import (
22
+ MarchAgentError,
23
+ RegistrationError,
24
+ KafkaError,
25
+ ConfigurationError,
26
+ APIException,
27
+ HeartbeatError,
28
+ )
29
+
30
+ __all__ = [
31
+ "MarchAgentApp",
32
+ "Agent",
33
+ "Message",
34
+ "Conversation",
35
+ "ConversationClient",
36
+ "ConversationMessage",
37
+ "CheckpointClient",
38
+ "Memory",
39
+ "MemoryClient",
40
+ "MemoryMessage",
41
+ "MemorySearchResult",
42
+ "UserSummary",
43
+ "Streamer",
44
+ "Artifact",
45
+ "ArtifactType",
46
+ "MarchAgentError",
47
+ "RegistrationError",
48
+ "KafkaError",
49
+ "ConfigurationError",
50
+ "APIException",
51
+ "HeartbeatError",
52
+ ]
march_agent/agent.py ADDED
@@ -0,0 +1,341 @@
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 .streamer import Streamer
14
+ from .exceptions import ConfigurationError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class SenderFilter:
20
+ """Filter for matching message senders."""
21
+
22
+ def __init__(self, senders: Optional[List[str]] = None):
23
+ self.include: Set[str] = set()
24
+ self.exclude: Set[str] = set()
25
+ self.match_all = senders is None or len(senders) == 0
26
+
27
+ if senders:
28
+ for s in senders:
29
+ if s.startswith("~"):
30
+ self.exclude.add(s[1:])
31
+ else:
32
+ self.include.add(s)
33
+
34
+ def matches(self, sender: str) -> bool:
35
+ """Check if sender matches this filter."""
36
+ if self.match_all:
37
+ return True
38
+ if sender in self.exclude:
39
+ return False
40
+ if self.include and sender not in self.include:
41
+ return False
42
+ return True
43
+
44
+
45
+ class Agent:
46
+ """Core agent class that handles messaging via the Agent Gateway."""
47
+
48
+ def __init__(
49
+ self,
50
+ name: str,
51
+ gateway_client: GatewayClient,
52
+ agent_data: Dict[str, Any],
53
+ heartbeat_interval: int = 60,
54
+ conversation_client: Optional[ConversationClient] = None,
55
+ memory_client: Optional[MemoryClient] = None,
56
+ error_message_template: str = "I encountered an error while processing your message. Please try again or contact support if the issue persists.",
57
+ ):
58
+ self.name = name
59
+ self.gateway_client = gateway_client
60
+ self.agent_data = agent_data
61
+ self.conversation_client = conversation_client
62
+ self.memory_client = memory_client
63
+ self.heartbeat_interval = heartbeat_interval
64
+
65
+ # Error handling configuration
66
+ self.error_message_template: str = error_message_template
67
+ self.send_error_responses: bool = True
68
+
69
+ self.heartbeat_manager: Optional[HeartbeatManager] = None
70
+ self._message_handlers: List[Tuple[SenderFilter, Callable]] = []
71
+ self._running = False
72
+ self._initialized = False
73
+
74
+ logger.info(f"Agent '{name}' created")
75
+
76
+ def _initialize_with_gateway(self):
77
+ """Initialize agent after gateway connection is established."""
78
+ if self._initialized:
79
+ return
80
+
81
+ # Start heartbeat via gateway proxy
82
+ self.heartbeat_manager = HeartbeatManager(
83
+ gateway_client=self.gateway_client,
84
+ agent_name=self.name,
85
+ interval=self.heartbeat_interval,
86
+ )
87
+ self.heartbeat_manager.start()
88
+
89
+ self._initialized = True
90
+ logger.info(f"Agent '{self.name}' initialized with gateway")
91
+
92
+ def on_message(self, handler_or_senders=None, *, senders: Optional[List[str]] = None):
93
+ """Register message handler with optional sender filter.
94
+
95
+ Supports both:
96
+ @agent.on_message # catch-all
97
+ @agent.on_message(senders=["user"]) # filtered
98
+
99
+ Args:
100
+ senders: List of sender names to match. Prefix with ~ to exclude.
101
+ Examples: ["user"], ["agent-1", "agent-2"], ["~user"]
102
+ """
103
+ def decorator(handler: Callable[[Message, str], None]):
104
+ sender_filter = SenderFilter(senders)
105
+ self._message_handlers.append((sender_filter, handler))
106
+ logger.info(f"Handler registered for agent '{self.name}' with senders={senders}")
107
+ return handler
108
+
109
+ # Called as @on_message (no parens) - handler_or_senders is the function
110
+ if callable(handler_or_senders):
111
+ return decorator(handler_or_senders)
112
+
113
+ # Called as @on_message(senders=[...]) - return decorator
114
+ return decorator
115
+
116
+ def _get_sender(self, headers: Dict[str, str]) -> str:
117
+ """Determine sender from headers."""
118
+ return headers.get("from_", "user")
119
+
120
+ def _find_matching_handler(self, sender: str) -> Optional[Callable]:
121
+ """Find first handler that matches the sender."""
122
+ for sender_filter, handler in self._message_handlers:
123
+ if sender_filter.matches(sender):
124
+ return handler
125
+ return None
126
+
127
+ def _send_error_response(
128
+ self,
129
+ message: Message,
130
+ error: Exception,
131
+ handler_name: Optional[str] = None
132
+ ) -> None:
133
+ """Send error response to user when handler fails (sync - deprecated).
134
+
135
+ Fail-safe design: wraps everything in try-except to prevent
136
+ cascading failures if error response itself fails.
137
+
138
+ Args:
139
+ message: The original message that caused the error
140
+ error: The exception that was caught
141
+ handler_name: Optional name of the handler that failed
142
+ """
143
+ if not self.send_error_responses:
144
+ return
145
+
146
+ try:
147
+ # Log detailed error for debugging with structured fields
148
+ handler_info = f" in handler '{handler_name}'" if handler_name else ""
149
+ logger.error(
150
+ f"Handler error{handler_info} for agent '{self.name}': {error}",
151
+ exc_info=True,
152
+ extra={
153
+ "agent_name": self.name,
154
+ "conversation_id": message.conversation_id,
155
+ "error_type": type(error).__name__,
156
+ }
157
+ )
158
+
159
+ # Create streamer with original message context
160
+ streamer = self.streamer(
161
+ original_message=message,
162
+ send_to="user"
163
+ )
164
+
165
+ # Send error message (stream is sync, but we can't await here)
166
+ streamer.stream(self.error_message_template)
167
+ # Note: Can't use async context manager in sync method
168
+
169
+ logger.info(
170
+ f"Error response sent to user for conversation {message.conversation_id}"
171
+ )
172
+
173
+ except Exception as send_error:
174
+ # Fail-safe: if sending error response fails, just log it
175
+ logger.error(
176
+ f"Failed to send error response: {send_error}",
177
+ exc_info=True,
178
+ extra={
179
+ "agent_name": self.name,
180
+ "conversation_id": message.conversation_id,
181
+ "original_error": str(error),
182
+ }
183
+ )
184
+
185
+ async def _send_error_response_async(
186
+ self,
187
+ message: Message,
188
+ error: Exception,
189
+ handler_name: Optional[str] = None
190
+ ) -> None:
191
+ """Send error response to user when handler fails (async).
192
+
193
+ Fail-safe design: wraps everything in try-except to prevent
194
+ cascading failures if error response itself fails.
195
+
196
+ Args:
197
+ message: The original message that caused the error
198
+ error: The exception that was caught
199
+ handler_name: Optional name of the handler that failed
200
+ """
201
+ if not self.send_error_responses:
202
+ return
203
+
204
+ try:
205
+ # Log detailed error for debugging with structured fields
206
+ handler_info = f" in handler '{handler_name}'" if handler_name else ""
207
+ logger.error(
208
+ f"Handler error{handler_info} for agent '{self.name}': {error}",
209
+ exc_info=True,
210
+ extra={
211
+ "agent_name": self.name,
212
+ "conversation_id": message.conversation_id,
213
+ "error_type": type(error).__name__,
214
+ }
215
+ )
216
+
217
+ # Create streamer with original message context
218
+ streamer = self.streamer(
219
+ original_message=message,
220
+ send_to="user"
221
+ )
222
+
223
+ # Send error message using async context manager pattern
224
+ async with streamer as s:
225
+ s.stream(self.error_message_template)
226
+
227
+ logger.info(
228
+ f"Error response sent to user for conversation {message.conversation_id}"
229
+ )
230
+
231
+ except Exception as send_error:
232
+ # Fail-safe: if sending error response fails, just log it
233
+ logger.error(
234
+ f"Failed to send error response: {send_error}",
235
+ exc_info=True,
236
+ extra={
237
+ "agent_name": self.name,
238
+ "conversation_id": message.conversation_id,
239
+ "original_error": str(error),
240
+ }
241
+ )
242
+
243
+ async def _handle_message_async(self, kafka_msg: KafkaMessage):
244
+ """Handle incoming Kafka message asynchronously."""
245
+ message = None
246
+ handler = None
247
+
248
+ try:
249
+ headers = kafka_msg.headers
250
+ body = kafka_msg.body if isinstance(kafka_msg.body, dict) else {}
251
+
252
+ sender = self._get_sender(headers)
253
+ handler = self._find_matching_handler(sender)
254
+
255
+ if handler is None:
256
+ logger.warning(f"No handler matched for sender: {sender}")
257
+ return
258
+
259
+ # Create message before handler execution
260
+ message = Message.from_kafka_message(
261
+ body, headers,
262
+ conversation_client=self.conversation_client,
263
+ memory_client=self.memory_client,
264
+ agent_name=self.name,
265
+ )
266
+
267
+ # Execute handler based on whether it's async or sync
268
+ if inspect.iscoroutinefunction(handler):
269
+ # Async handler - await it directly
270
+ await handler(message, sender)
271
+ else:
272
+ # Sync handler - run in thread pool for backward compatibility
273
+ loop = asyncio.get_event_loop()
274
+ await loop.run_in_executor(None, handler, message, sender)
275
+
276
+ except Exception as e:
277
+ # Handler execution failed - send error response to user
278
+ # Only send if we successfully created the message
279
+ if message is not None:
280
+ handler_name = handler.__name__ if handler and hasattr(handler, '__name__') else None
281
+ await self._send_error_response_async(message, e, handler_name)
282
+ else:
283
+ # Message creation failed - just log (can't respond without context)
284
+ logger.error(
285
+ f"Error processing Kafka message before handler execution: {e}",
286
+ exc_info=True,
287
+ extra={"agent_name": self.name}
288
+ )
289
+
290
+ def start_consuming(self):
291
+ """Mark agent as ready to consume messages.
292
+
293
+ Note: Actual consumption is handled by the gateway client's consume loop.
294
+ This method just sets the agent as running and waits for shutdown.
295
+ """
296
+ if not self._message_handlers:
297
+ raise ConfigurationError("No message handlers registered.")
298
+
299
+ if not self._initialized:
300
+ raise ConfigurationError("Agent not initialized. Call _initialize_with_gateway first.")
301
+
302
+ logger.info(f"Agent '{self.name}' ready to consume messages")
303
+ self._running = True
304
+
305
+ # The gateway client handles all message consumption in a single thread.
306
+ # We just keep this thread alive until shutdown.
307
+ import time
308
+ while self._running:
309
+ time.sleep(1.0)
310
+
311
+ def streamer(
312
+ self,
313
+ original_message: Message,
314
+ awaiting: bool = False,
315
+ send_to: str = "user",
316
+ ) -> Streamer:
317
+ """Create a new Streamer for streaming responses.
318
+
319
+ Args:
320
+ original_message: The message being responded to
321
+ awaiting: If True (and using context manager), sets awaiting_route on finish
322
+ send_to: Target for the response - "user" (default) or another agent name
323
+ """
324
+ return Streamer(
325
+ agent_name=self.name,
326
+ original_message=original_message,
327
+ gateway_client=self.gateway_client,
328
+ conversation_client=self.conversation_client,
329
+ awaiting=awaiting,
330
+ send_to=send_to,
331
+ )
332
+
333
+ def shutdown(self):
334
+ """Shutdown agent gracefully."""
335
+ logger.info(f"Shutting down agent '{self.name}'...")
336
+ self._running = False
337
+
338
+ if self.heartbeat_manager:
339
+ self.heartbeat_manager.stop()
340
+
341
+ 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}")