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.
march_agent/cli.py ADDED
@@ -0,0 +1,139 @@
1
+ """CLI interface for March AI Agent framework.
2
+
3
+ Inspired by uvicorn's simple pattern: march-agent run main:app
4
+ """
5
+
6
+ import sys
7
+ import argparse
8
+ import importlib
9
+ from pathlib import Path
10
+
11
+
12
+ def import_from_string(import_str: str):
13
+ """
14
+ Import an attribute from a module using uvicorn-style string.
15
+
16
+ Examples:
17
+ "main:app" → imports 'app' from 'main' module
18
+ "main:setup" → imports 'setup' from 'main' module
19
+ "main" → imports 'main' module and looks for 'app' attribute
20
+
21
+ Args:
22
+ import_str: Import string in format "module:attribute" or "module"
23
+
24
+ Returns:
25
+ tuple: (module, attribute_name, attribute_value)
26
+ """
27
+ # Add current directory to path
28
+ sys.path.insert(0, str(Path.cwd()))
29
+
30
+ # Parse import string
31
+ if ":" in import_str:
32
+ module_str, attr_str = import_str.split(":", 1)
33
+ else:
34
+ module_str = import_str
35
+ attr_str = "app" # Default to 'app' like uvicorn
36
+
37
+ # Import module
38
+ try:
39
+ module = importlib.import_module(module_str)
40
+ except ImportError as e:
41
+ raise ImportError(f"Could not import module '{module_str}': {e}")
42
+
43
+ # Get attribute
44
+ if not hasattr(module, attr_str):
45
+ raise AttributeError(
46
+ f"Module '{module_str}' has no attribute '{attr_str}'. "
47
+ f"Please ensure you have '{attr_str} = MarchAgentApp(...)' in your file."
48
+ )
49
+
50
+ attr = getattr(module, attr_str)
51
+ return module, attr_str, attr
52
+
53
+
54
+ def run_agent(import_str: str) -> None:
55
+ """
56
+ Load and run an agent using uvicorn-style import string.
57
+
58
+ Args:
59
+ import_str: Import string like "main:app" or "main"
60
+ """
61
+ from march_agent import MarchAgentApp
62
+
63
+ print(f"Loading agent from: {import_str}")
64
+
65
+ # Import the app
66
+ module, attr_name, app = import_from_string(import_str)
67
+
68
+ # Validate it's a MarchAgentApp
69
+ if not isinstance(app, MarchAgentApp):
70
+ raise TypeError(
71
+ f"Attribute '{attr_name}' must be a MarchAgentApp instance, "
72
+ f"got {type(app).__name__} instead."
73
+ )
74
+
75
+ print(f"Running {attr_name}.run()...")
76
+ app.run()
77
+
78
+
79
+ def main() -> None:
80
+ """Main CLI entry point."""
81
+ parser = argparse.ArgumentParser(
82
+ prog="march-agent",
83
+ description="March AI Agent CLI - Run AI agents with uvicorn-style imports",
84
+ formatter_class=argparse.RawDescriptionHelpFormatter,
85
+ epilog="""
86
+ Examples:
87
+ march-agent run main:app Run 'app' from 'main' module (recommended)
88
+ march-agent run main Run 'app' from 'main' module (defaults to :app)
89
+ march-agent run myagent:application Run 'application' from 'myagent' module
90
+
91
+ Your agent file should have:
92
+ app = MarchAgentApp(...)
93
+
94
+ @app.agent(name="...", about="...", document="...")
95
+ def my_agent(message):
96
+ # Handle message
97
+ """,
98
+ )
99
+
100
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
101
+
102
+ # run command
103
+ run_parser = subparsers.add_parser(
104
+ "run",
105
+ help="Run an agent",
106
+ description="Load and run a March AI agent using module:attribute syntax",
107
+ )
108
+ run_parser.add_argument(
109
+ "app",
110
+ help="Import path in format 'module:attribute' (e.g., main:app)",
111
+ )
112
+
113
+ # version command
114
+ version_parser = subparsers.add_parser(
115
+ "version",
116
+ help="Show version information",
117
+ )
118
+
119
+ args = parser.parse_args()
120
+
121
+ # Handle commands
122
+ if args.command == "run":
123
+ try:
124
+ run_agent(args.app)
125
+ except Exception as e:
126
+ print(f"Error: {e}", file=sys.stderr)
127
+ sys.exit(1)
128
+
129
+ elif args.command == "version":
130
+ from march_agent import __version__
131
+ print(f"march-agent version {__version__}")
132
+
133
+ else:
134
+ parser.print_help()
135
+ sys.exit(1)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
@@ -0,0 +1,103 @@
1
+ """Conversation context for accessing message history."""
2
+
3
+ import asyncio
4
+ from typing import List, Dict, Any, Optional
5
+
6
+ from .conversation_client import ConversationClient
7
+ from .conversation_message import ConversationMessage
8
+
9
+
10
+ class Conversation:
11
+ """Lazy-loaded conversation context (async)."""
12
+
13
+ def __init__(
14
+ self,
15
+ conversation_id: str,
16
+ client: ConversationClient,
17
+ agent_name: Optional[str] = None,
18
+ ):
19
+ self.id = conversation_id
20
+ self._client = client
21
+ self._agent_name = agent_name
22
+
23
+ async def get_history(
24
+ self,
25
+ role: Optional[str] = None,
26
+ limit: int = 100,
27
+ offset: int = 0,
28
+ ) -> List[ConversationMessage]:
29
+ """Fetch conversation message history.
30
+
31
+ Args:
32
+ role: Optional filter by role ('user', 'assistant', 'system')
33
+ limit: Maximum messages to fetch
34
+ offset: Pagination offset
35
+
36
+ Returns:
37
+ List of ConversationMessage objects
38
+ """
39
+ messages = await self._client.get_messages(
40
+ conversation_id=self.id, role=role, limit=limit, offset=offset
41
+ )
42
+ return ConversationMessage.from_list(messages)
43
+
44
+ async def get_metadata(self) -> Dict[str, Any]:
45
+ """Fetch conversation metadata."""
46
+ return await self._client.get_conversation(self.id)
47
+
48
+ async def get_agent_history(
49
+ self,
50
+ agent_name: Optional[str] = None,
51
+ role: Optional[str] = None,
52
+ limit: int = 100,
53
+ offset: int = 0,
54
+ ) -> List[ConversationMessage]:
55
+ """Fetch messages where agent was sender or recipient.
56
+
57
+ Makes two API calls concurrently to get both sent and received messages,
58
+ then merges and sorts by sequence number.
59
+
60
+ Args:
61
+ agent_name: The agent name to filter by. Defaults to the current agent
62
+ if not specified (requires agent_name to be set during Conversation init).
63
+ role: Optional role filter ('user', 'assistant', 'system')
64
+ limit: Maximum messages to fetch per query
65
+ offset: Pagination offset
66
+
67
+ Returns:
68
+ List of ConversationMessage where from_==agent_name OR to_==agent_name
69
+
70
+ Raises:
71
+ ValueError: If agent_name is not provided and no default is set.
72
+ """
73
+ # Use provided agent_name or fall back to the default from init
74
+ agent_name = agent_name or self._agent_name
75
+ if not agent_name:
76
+ raise ValueError(
77
+ "agent_name must be provided either as argument or set during Conversation initialization"
78
+ )
79
+ # Run both queries concurrently
80
+ sent_task = self._client.get_messages(
81
+ conversation_id=self.id,
82
+ role=role,
83
+ from_=agent_name,
84
+ limit=limit,
85
+ offset=offset,
86
+ )
87
+
88
+ received_task = self._client.get_messages(
89
+ conversation_id=self.id,
90
+ role=role,
91
+ to_=agent_name,
92
+ limit=limit,
93
+ offset=offset,
94
+ )
95
+
96
+ sent, received = await asyncio.gather(sent_task, received_task)
97
+
98
+ # Merge and dedupe (in case from_==to_)
99
+ all_msgs = {msg["id"]: msg for msg in sent + received}
100
+
101
+ # Sort by sequence_number and convert to typed messages
102
+ sorted_msgs = sorted(all_msgs.values(), key=lambda m: m.get("sequence_number", 0))
103
+ return ConversationMessage.from_list(sorted_msgs)
@@ -0,0 +1,86 @@
1
+ """Conversation store client for fetching conversation history."""
2
+
3
+ import logging
4
+ from typing import List, Dict, Any, Optional
5
+ import aiohttp
6
+
7
+ from .exceptions import APIException
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ConversationClient:
13
+ """Client for interacting with conversation-store API (async)."""
14
+
15
+ def __init__(self, base_url: str):
16
+ self.base_url = base_url.rstrip("/")
17
+ self._session: Optional[aiohttp.ClientSession] = None
18
+
19
+ async def _get_session(self) -> aiohttp.ClientSession:
20
+ """Get or create aiohttp session."""
21
+ if self._session is None or self._session.closed:
22
+ timeout = aiohttp.ClientTimeout(total=10.0)
23
+ self._session = aiohttp.ClientSession(timeout=timeout)
24
+ return self._session
25
+
26
+ async def close(self):
27
+ """Close the aiohttp session."""
28
+ if self._session and not self._session.closed:
29
+ await self._session.close()
30
+
31
+ async def get_conversation(self, conversation_id: str) -> Dict[str, Any]:
32
+ """Get conversation metadata."""
33
+ url = f"{self.base_url}/conversations/{conversation_id}"
34
+ session = await self._get_session()
35
+ try:
36
+ async with session.get(url) as response:
37
+ if response.status == 404:
38
+ raise APIException(f"Conversation {conversation_id} not found")
39
+ response.raise_for_status()
40
+ return await response.json()
41
+ except aiohttp.ClientError as e:
42
+ raise APIException(f"Failed to fetch conversation: {e}")
43
+
44
+ async def get_messages(
45
+ self,
46
+ conversation_id: str,
47
+ role: Optional[str] = None,
48
+ from_: Optional[str] = None,
49
+ to_: Optional[str] = None,
50
+ limit: int = 100,
51
+ offset: int = 0,
52
+ ) -> List[Dict[str, Any]]:
53
+ """Get messages from a conversation."""
54
+ url = f"{self.base_url}/conversations/{conversation_id}/messages"
55
+ params = {"limit": min(limit, 1000), "offset": offset}
56
+ if role:
57
+ params["role"] = role
58
+ if from_:
59
+ params["from"] = from_
60
+ if to_:
61
+ params["to"] = to_
62
+
63
+ session = await self._get_session()
64
+ try:
65
+ async with session.get(url, params=params) as response:
66
+ if response.status == 404:
67
+ raise APIException(f"Conversation {conversation_id} not found")
68
+ response.raise_for_status()
69
+ return await response.json()
70
+ except aiohttp.ClientError as e:
71
+ raise APIException(f"Failed to fetch messages: {e}")
72
+
73
+ async def update_conversation(
74
+ self,
75
+ conversation_id: str,
76
+ data: Dict[str, Any],
77
+ ) -> Dict[str, Any]:
78
+ """Update conversation fields (PATCH)."""
79
+ url = f"{self.base_url}/conversations/{conversation_id}"
80
+ session = await self._get_session()
81
+ try:
82
+ async with session.patch(url, json=data) as response:
83
+ response.raise_for_status()
84
+ return await response.json()
85
+ except aiohttp.ClientError as e:
86
+ raise APIException(f"Failed to update conversation: {e}")
@@ -0,0 +1,48 @@
1
+ """Typed message from conversation-store."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Dict, Any, Optional, List
5
+
6
+
7
+ @dataclass
8
+ class ConversationMessage:
9
+ """A message from conversation-store.
10
+
11
+ Represents a stored message in a conversation with full metadata.
12
+ """
13
+
14
+ id: str
15
+ conversation_id: str
16
+ role: str # "user", "assistant", "system"
17
+ content: str
18
+ sequence_number: int
19
+ from_: Optional[str] = None # Sender (agent name or "user")
20
+ to_: Optional[str] = None # Recipient
21
+ metadata: Optional[Dict[str, Any]] = None
22
+ schema: Optional[Dict[str, Any]] = None
23
+ response_schema: Optional[Dict[str, Any]] = None
24
+ created_at: Optional[str] = None
25
+ updated_at: Optional[str] = None
26
+
27
+ @classmethod
28
+ def from_dict(cls, data: Dict[str, Any]) -> "ConversationMessage":
29
+ """Create from conversation-store API response."""
30
+ return cls(
31
+ id=str(data.get("id", "")),
32
+ conversation_id=str(data.get("conversation_id", "")),
33
+ role=data.get("role", ""),
34
+ content=data.get("content", ""),
35
+ sequence_number=data.get("sequence_number", 0),
36
+ from_=data.get("from_"),
37
+ to_=data.get("to_"),
38
+ metadata=data.get("metadata"),
39
+ schema=data.get("schema"),
40
+ response_schema=data.get("response_schema"),
41
+ created_at=data.get("created_at"),
42
+ updated_at=data.get("updated_at"),
43
+ )
44
+
45
+ @classmethod
46
+ def from_list(cls, data: List[Dict[str, Any]]) -> List["ConversationMessage"]:
47
+ """Create list from conversation-store API response."""
48
+ return [cls.from_dict(item) for item in data]
@@ -0,0 +1,36 @@
1
+ """Custom exceptions for March AI Agent framework."""
2
+
3
+
4
+ class MarchAgentError(Exception):
5
+ """Base exception for all March Agent errors."""
6
+ pass
7
+
8
+
9
+ class RegistrationError(MarchAgentError):
10
+ """Raised when agent registration fails."""
11
+ pass
12
+
13
+
14
+ class KafkaError(MarchAgentError):
15
+ """Raised when Kafka operations fail."""
16
+ pass
17
+
18
+
19
+ class HeartbeatError(MarchAgentError):
20
+ """Raised when heartbeat operations fail."""
21
+ pass
22
+
23
+
24
+ class MessageHandlerError(MarchAgentError):
25
+ """Raised when message handler execution fails."""
26
+ pass
27
+
28
+
29
+ class ConfigurationError(MarchAgentError):
30
+ """Raised when configuration is invalid."""
31
+ pass
32
+
33
+
34
+ class APIException(MarchAgentError):
35
+ """Raised when conversation-store API requests fail."""
36
+ pass
@@ -0,0 +1 @@
1
+ # Extensions package for march_agent