dao-ai 0.0.28__py3-none-any.whl → 0.1.2__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 (63) hide show
  1. dao_ai/__init__.py +29 -0
  2. dao_ai/agent_as_code.py +2 -5
  3. dao_ai/cli.py +245 -40
  4. dao_ai/config.py +1491 -370
  5. dao_ai/genie/__init__.py +38 -0
  6. dao_ai/genie/cache/__init__.py +43 -0
  7. dao_ai/genie/cache/base.py +72 -0
  8. dao_ai/genie/cache/core.py +79 -0
  9. dao_ai/genie/cache/lru.py +347 -0
  10. dao_ai/genie/cache/semantic.py +970 -0
  11. dao_ai/genie/core.py +35 -0
  12. dao_ai/graph.py +27 -253
  13. dao_ai/hooks/__init__.py +9 -6
  14. dao_ai/hooks/core.py +27 -195
  15. dao_ai/logging.py +56 -0
  16. dao_ai/memory/__init__.py +10 -0
  17. dao_ai/memory/core.py +65 -30
  18. dao_ai/memory/databricks.py +402 -0
  19. dao_ai/memory/postgres.py +79 -38
  20. dao_ai/messages.py +6 -4
  21. dao_ai/middleware/__init__.py +125 -0
  22. dao_ai/middleware/assertions.py +806 -0
  23. dao_ai/middleware/base.py +50 -0
  24. dao_ai/middleware/core.py +67 -0
  25. dao_ai/middleware/guardrails.py +420 -0
  26. dao_ai/middleware/human_in_the_loop.py +232 -0
  27. dao_ai/middleware/message_validation.py +586 -0
  28. dao_ai/middleware/summarization.py +197 -0
  29. dao_ai/models.py +1306 -114
  30. dao_ai/nodes.py +245 -159
  31. dao_ai/optimization.py +674 -0
  32. dao_ai/orchestration/__init__.py +52 -0
  33. dao_ai/orchestration/core.py +294 -0
  34. dao_ai/orchestration/supervisor.py +278 -0
  35. dao_ai/orchestration/swarm.py +271 -0
  36. dao_ai/prompts.py +128 -31
  37. dao_ai/providers/databricks.py +573 -601
  38. dao_ai/state.py +157 -21
  39. dao_ai/tools/__init__.py +13 -5
  40. dao_ai/tools/agent.py +1 -3
  41. dao_ai/tools/core.py +64 -11
  42. dao_ai/tools/email.py +232 -0
  43. dao_ai/tools/genie.py +144 -294
  44. dao_ai/tools/mcp.py +223 -155
  45. dao_ai/tools/memory.py +50 -0
  46. dao_ai/tools/python.py +9 -14
  47. dao_ai/tools/search.py +14 -0
  48. dao_ai/tools/slack.py +22 -10
  49. dao_ai/tools/sql.py +202 -0
  50. dao_ai/tools/time.py +30 -7
  51. dao_ai/tools/unity_catalog.py +165 -88
  52. dao_ai/tools/vector_search.py +331 -221
  53. dao_ai/utils.py +166 -20
  54. dao_ai-0.1.2.dist-info/METADATA +455 -0
  55. dao_ai-0.1.2.dist-info/RECORD +64 -0
  56. dao_ai/chat_models.py +0 -204
  57. dao_ai/guardrails.py +0 -112
  58. dao_ai/tools/human_in_the_loop.py +0 -100
  59. dao_ai-0.0.28.dist-info/METADATA +0 -1168
  60. dao_ai-0.0.28.dist-info/RECORD +0 -41
  61. {dao_ai-0.0.28.dist-info → dao_ai-0.1.2.dist-info}/WHEEL +0 -0
  62. {dao_ai-0.0.28.dist-info → dao_ai-0.1.2.dist-info}/entry_points.txt +0 -0
  63. {dao_ai-0.0.28.dist-info → dao_ai-0.1.2.dist-info}/licenses/LICENSE +0 -0
dao_ai/state.py CHANGED
@@ -1,41 +1,177 @@
1
- from langchain_core.messages import AnyMessage
1
+ """
2
+ State definitions for DAO AI agents.
3
+
4
+ This module defines the state schemas used by DAO AI agents,
5
+ compatible with both LangChain v1's create_agent and LangGraph's StateGraph.
6
+
7
+ State Schema:
8
+ - AgentState: Primary state schema for all agent operations
9
+ - Context: Runtime context passed via ToolRuntime[Context] or Runtime[Context]
10
+ - GenieSpaceState: Per-space state for Genie conversations
11
+ - SessionState: Accumulated state that flows between requests
12
+ """
13
+
14
+ from datetime import datetime
15
+ from typing import Any, Optional
16
+
2
17
  from langgraph.graph import MessagesState
3
- from langgraph.managed import RemainingSteps
4
- from pydantic import BaseModel
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+ from typing_extensions import NotRequired
5
20
 
6
21
 
7
- class IncomingState(MessagesState): ...
22
+ class GenieSpaceState(BaseModel):
23
+ """State for a single Genie space/conversation.
8
24
 
25
+ This tracks the conversation state and metadata for a Genie space,
26
+ allowing multi-turn conversations and caching information to be preserved.
27
+ """
28
+
29
+ conversation_id: str = Field(description="Genie conversation ID for this space")
30
+ cache_hit: bool = Field(
31
+ default=False, description="Whether the last query was a cache hit"
32
+ )
33
+ cache_key: Optional[str] = Field(default=None, description="Cache key if cached")
34
+ follow_up_questions: list[str] = Field(
35
+ default_factory=list, description="Suggested follow-up questions from Genie"
36
+ )
37
+ last_query: Optional[str] = Field(
38
+ default=None, description="The last query sent to Genie"
39
+ )
40
+ last_query_time: Optional[datetime] = Field(
41
+ default=None, description="When the last query was made"
42
+ )
9
43
 
10
- class OutgoingState(MessagesState):
11
- is_valid: bool
12
- message_error: str
13
44
 
45
+ class GenieState(BaseModel):
46
+ """State for all Genie spaces.
14
47
 
15
- class SharedState(MessagesState):
48
+ Maps space_id to GenieSpaceState for each Genie space the user has interacted with.
16
49
  """
17
- State representation for the DAO AI agent conversation workflow.
18
50
 
19
- Extends LangGraph's MessagesState to maintain the conversation history while
20
- adding additional state fields specific to the DAO domain. This state is
21
- passed between nodes in the agent graph and modified during execution.
51
+ spaces: dict[str, GenieSpaceState] = Field(
52
+ default_factory=dict, description="Map of space_id to space state"
53
+ )
54
+
55
+ def get_conversation_id(self, space_id: str) -> Optional[str]:
56
+ """Get conversation ID for a space, if it exists."""
57
+ if space_id in self.spaces:
58
+ return self.spaces[space_id].conversation_id
59
+ return None
60
+
61
+ def update_space(
62
+ self,
63
+ space_id: str,
64
+ conversation_id: str,
65
+ cache_hit: bool = False,
66
+ cache_key: Optional[str] = None,
67
+ follow_up_questions: Optional[list[str]] = None,
68
+ last_query: Optional[str] = None,
69
+ ) -> None:
70
+ """Update or create state for a Genie space."""
71
+ self.spaces[space_id] = GenieSpaceState(
72
+ conversation_id=conversation_id,
73
+ cache_hit=cache_hit,
74
+ cache_key=cache_key,
75
+ follow_up_questions=follow_up_questions or [],
76
+ last_query=last_query,
77
+ last_query_time=datetime.now() if last_query else None,
78
+ )
79
+
80
+
81
+ class SessionState(BaseModel):
82
+ """Accumulated state that flows between requests.
83
+
84
+ This is the "paste from previous output" portion of the request.
85
+ Users can copy the session from custom_outputs and paste it back
86
+ as custom_inputs.session to restore state.
22
87
  """
23
88
 
24
- context: str # short term/long term memory
89
+ genie: GenieState = Field(
90
+ default_factory=GenieState, description="Genie conversation state per space"
91
+ )
92
+
93
+ # Future: Add other stateful tool state here
94
+ # other_tool_state: OtherToolState = Field(default_factory=OtherToolState)
95
+
25
96
 
26
- active_agent: str # langgraph-swarm
27
- remaining_steps: RemainingSteps # langgraph-supervisor
97
+ class AgentState(MessagesState, total=False):
98
+ """
99
+ Primary state schema for DAO AI agents.
100
+
101
+ Extends MessagesState to include the messages channel with proper
102
+ add_messages reducer, plus additional fields for DAO AI functionality.
28
103
 
29
- summarized_messages: list[AnyMessage]
104
+ Used for:
105
+ - state_schema in create_agent calls
106
+ - state_schema in StateGraph for orchestration
107
+ - Type parameter in ToolRuntime[Context, AgentState]
108
+ - Type parameter in AgentMiddleware[AgentState, Context]
109
+ - API input/output contracts
30
110
 
31
- is_valid: bool # message validation node
32
- message_error: str
111
+ Fields:
112
+ messages: Conversation history with add_messages reducer (from MessagesState)
113
+ context: Short/long term memory context
114
+ active_agent: Name of currently active agent in multi-agent workflows
115
+ is_valid: Message validation status
116
+ message_error: Error message if validation failed
117
+ session: Accumulated session state (genie conversations, etc.)
118
+ structured_response: Structured output from response_format (populated by LangChain)
119
+ """
33
120
 
34
- # A mapping of genie space_id to conversation_id
35
- genie_conversation_ids: dict[str, str] # Genie
121
+ context: NotRequired[str]
122
+ active_agent: NotRequired[str]
123
+ is_valid: NotRequired[bool]
124
+ message_error: NotRequired[str]
125
+ session: NotRequired[SessionState]
126
+ structured_response: NotRequired[Any]
36
127
 
37
128
 
38
129
  class Context(BaseModel):
130
+ """
131
+ Runtime context for DAO AI agents.
132
+
133
+ This is passed to tools and middleware via the runtime parameter.
134
+ Access via ToolRuntime[Context] in tools or Runtime[Context] in middleware.
135
+
136
+ Additional fields beyond user_id and thread_id can be added dynamically
137
+ and will be available as top-level attributes on the context object.
138
+ These fields are:
139
+ - Used as template parameters in prompts (all fields are applied)
140
+ - Validated by middleware (check for specific fields like "store_num")
141
+ - Accessible as direct attributes (e.g., context.store_num)
142
+
143
+ Example:
144
+ @tool
145
+ def my_tool(runtime: ToolRuntime[Context]) -> str:
146
+ user_id = runtime.context.user_id
147
+ store_num = runtime.context.store_num # Direct attribute access
148
+ return f"Hello, {user_id} at store {store_num}!"
149
+
150
+ class MyMiddleware(AgentMiddleware[AgentState, Context]):
151
+ def before_model(
152
+ self,
153
+ state: AgentState,
154
+ runtime: Runtime[Context]
155
+ ) -> dict[str, Any] | None:
156
+ user_id = runtime.context.user_id
157
+ store_num = getattr(runtime.context, "store_num", None)
158
+ return None
159
+ """
160
+
161
+ model_config = ConfigDict(
162
+ extra="allow"
163
+ ) # Allow extra fields as top-level attributes
164
+
39
165
  user_id: str | None = None
40
166
  thread_id: str | None = None
41
- store_num: int | None = None
167
+
168
+ @classmethod
169
+ def from_runnable_config(cls, config: dict[str, Any]) -> "Context":
170
+ """
171
+ Create Context from LangChain RunnableConfig.
172
+
173
+ This method is called by LangChain when context_schema is provided to create_agent.
174
+ It extracts the 'configurable' dict from the config and uses it to instantiate Context.
175
+ """
176
+ configurable = config.get("configurable", {})
177
+ return cls(**configurable)
dao_ai/tools/__init__.py CHANGED
@@ -1,13 +1,15 @@
1
+ from dao_ai.genie.cache import LRUCacheService, SemanticCacheService
1
2
  from dao_ai.hooks.core import create_hooks
2
3
  from dao_ai.tools.agent import create_agent_endpoint_tool
3
- from dao_ai.tools.core import (
4
- create_tools,
5
- search_tool,
6
- )
4
+ from dao_ai.tools.core import create_tools, say_hello_tool
5
+ from dao_ai.tools.email import create_send_email_tool
7
6
  from dao_ai.tools.genie import create_genie_tool
8
7
  from dao_ai.tools.mcp import create_mcp_tools
8
+ from dao_ai.tools.memory import create_search_memory_tool
9
9
  from dao_ai.tools.python import create_factory_tool, create_python_tool
10
+ from dao_ai.tools.search import create_search_tool
10
11
  from dao_ai.tools.slack import create_send_slack_message_tool
12
+ from dao_ai.tools.sql import create_execute_statement_tool
11
13
  from dao_ai.tools.time import (
12
14
  add_time_tool,
13
15
  current_time_tool,
@@ -23,11 +25,15 @@ from dao_ai.tools.vector_search import create_vector_search_tool
23
25
  __all__ = [
24
26
  "add_time_tool",
25
27
  "create_agent_endpoint_tool",
28
+ "create_execute_statement_tool",
26
29
  "create_factory_tool",
27
30
  "create_genie_tool",
28
31
  "create_hooks",
29
32
  "create_mcp_tools",
30
33
  "create_python_tool",
34
+ "create_search_memory_tool",
35
+ "create_search_tool",
36
+ "create_send_email_tool",
31
37
  "create_send_slack_message_tool",
32
38
  "create_tools",
33
39
  "create_uc_tools",
@@ -35,7 +41,9 @@ __all__ = [
35
41
  "current_time_tool",
36
42
  "format_time_tool",
37
43
  "is_business_hours_tool",
38
- "search_tool",
44
+ "LRUCacheService",
45
+ "say_hello_tool",
46
+ "SemanticCacheService",
39
47
  "time_difference_tool",
40
48
  "time_in_timezone_tool",
41
49
  "time_until_tool",
dao_ai/tools/agent.py CHANGED
@@ -14,9 +14,7 @@ def create_agent_endpoint_tool(
14
14
  name: Optional[str] = None,
15
15
  description: Optional[str] = None,
16
16
  ) -> Callable[..., Any]:
17
- logger.debug(
18
- f"Creating agent endpoint tool with name: {name} and description: {description}"
19
- )
17
+ logger.debug("Creating agent endpoint tool", name=name, description=description)
20
18
 
21
19
  default_description: str = dedent("""
22
20
  This tool allows you to interact with a language model endpoint to answer questions.
dao_ai/tools/core.py CHANGED
@@ -1,7 +1,19 @@
1
+ """
2
+ Core tool creation infrastructure for DAO AI.
3
+
4
+ This module provides the foundational tool creation and registration system:
5
+ - Tool registry for caching created tools
6
+ - Factory function for creating tools from configuration
7
+ - Example tools demonstrating runtime context usage
8
+
9
+ This is "core" because it contains the essential infrastructure that all
10
+ tool creation flows through, not because it contains all tools.
11
+ """
12
+
1
13
  from collections import OrderedDict
2
14
  from typing import Sequence
3
15
 
4
- from langchain_community.tools import DuckDuckGoSearchRun
16
+ from langchain.tools import ToolRuntime, tool
5
17
  from langchain_core.runnables.base import RunnableLike
6
18
  from loguru import logger
7
19
 
@@ -10,7 +22,9 @@ from dao_ai.config import (
10
22
  ToolModel,
11
23
  )
12
24
  from dao_ai.hooks.core import create_hooks
25
+ from dao_ai.state import Context
13
26
 
27
+ # Module-level tool registry for caching created tools
14
28
  tool_registry: dict[str, Sequence[RunnableLike]] = {}
15
29
 
16
30
 
@@ -22,7 +36,7 @@ def create_tools(tool_models: Sequence[ToolModel]) -> Sequence[RunnableLike]:
22
36
  Each tool is created according to its type and parameters defined in the configuration.
23
37
 
24
38
  Args:
25
- tool_configs: A sequence of dictionaries containing tool configurations
39
+ tool_models: A sequence of ToolModel configurations
26
40
 
27
41
  Returns:
28
42
  A sequence of BaseTool objects created from the provided configurations
@@ -33,27 +47,66 @@ def create_tools(tool_models: Sequence[ToolModel]) -> Sequence[RunnableLike]:
33
47
  for tool_config in tool_models:
34
48
  name: str = tool_config.name
35
49
  if name in tools:
36
- logger.warning(f"Tools already registered for: {name}, skipping creation.")
50
+ logger.warning("Tools already registered, skipping", tool_name=name)
37
51
  continue
38
- registered_tools: Sequence[RunnableLike] = tool_registry.get(name)
52
+ registered_tools: Sequence[RunnableLike] | None = tool_registry.get(name)
39
53
  if registered_tools is None:
40
- logger.debug(f"Creating tools for: {name}...")
54
+ logger.trace("Creating tools", tool_name=name)
41
55
  function: AnyTool = tool_config.function
42
56
  registered_tools = create_hooks(function)
43
- logger.debug(f"Registering tools for: {tool_config}")
57
+ logger.trace("Registering tools", tool_name=name)
44
58
  tool_registry[name] = registered_tools
45
59
  else:
46
- logger.debug(f"Tools already registered for: {name}")
60
+ logger.trace("Tools already registered", tool_name=name)
47
61
 
48
62
  tools[name] = registered_tools
49
63
 
50
64
  all_tools: Sequence[RunnableLike] = [
51
65
  t for tool_list in tools.values() for t in tool_list
52
66
  ]
53
- logger.debug(f"Created tools: {all_tools}")
67
+ logger.debug("Tools created", tools_count=len(all_tools))
54
68
  return all_tools
55
69
 
56
70
 
57
- def search_tool() -> RunnableLike:
58
- logger.debug("search_tool")
59
- return DuckDuckGoSearchRun(output_format="list")
71
+ # =============================================================================
72
+ # Example Tools
73
+ # =============================================================================
74
+ # The following tools serve as examples and are included here because they
75
+ # demonstrate core patterns (like ToolRuntime usage) rather than because they
76
+ # are fundamental infrastructure. They're simple enough to colocate with the
77
+ # core tool creation logic.
78
+
79
+
80
+ @tool
81
+ def say_hello_tool(
82
+ name: str | None = None,
83
+ runtime: ToolRuntime[Context] = None,
84
+ ) -> str:
85
+ """
86
+ Say hello to someone by name.
87
+
88
+ This is an example tool demonstrating how to use ToolRuntime to access
89
+ runtime context (like user_id) within a tool.
90
+
91
+ If no name is provided, uses the user_id from the runtime context.
92
+
93
+ Args:
94
+ name: Optional name of the person to greet. If not provided,
95
+ uses user_id from context.
96
+ runtime: Runtime context (automatically injected, not provided by user)
97
+
98
+ Returns:
99
+ A greeting string
100
+ """
101
+ # Use provided name, or fall back to user_id from context
102
+ if name is None:
103
+ if runtime and runtime.context:
104
+ user_id: str | None = runtime.context.user_id
105
+ if user_id:
106
+ name = user_id
107
+ else:
108
+ name = "there" # Default fallback
109
+ else:
110
+ name = "there" # Default fallback
111
+
112
+ return f"Hello, {name}!"
dao_ai/tools/email.py ADDED
@@ -0,0 +1,232 @@
1
+ """Email tool for sending emails via SMTP."""
2
+
3
+ import smtplib
4
+ from email.mime.multipart import MIMEMultipart
5
+ from email.mime.text import MIMEText
6
+ from typing import Any, Callable, Optional
7
+
8
+ from langchain_core.tools import tool
9
+ from loguru import logger
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from dao_ai.config import AnyVariable, value_of
13
+
14
+
15
+ class SMTPConfigModel(BaseModel):
16
+ """Configuration model for SMTP email settings."""
17
+
18
+ model_config = ConfigDict(use_enum_values=True, extra="forbid")
19
+
20
+ host: AnyVariable = Field(
21
+ default="smtp.gmail.com",
22
+ description="SMTP server hostname",
23
+ )
24
+ port: AnyVariable = Field(
25
+ default=587,
26
+ description="SMTP server port",
27
+ )
28
+ username: AnyVariable = Field(
29
+ description="SMTP username for authentication",
30
+ )
31
+ password: AnyVariable = Field(
32
+ description="SMTP password for authentication",
33
+ )
34
+ sender_email: Optional[AnyVariable] = Field(
35
+ default=None,
36
+ description="Email address to use as sender (defaults to username)",
37
+ )
38
+ use_tls: bool = Field(
39
+ default=True,
40
+ description="Whether to use TLS encryption",
41
+ )
42
+
43
+
44
+ def create_send_email_tool(
45
+ smtp_config: SMTPConfigModel | dict[str, Any],
46
+ name: Optional[str] = None,
47
+ description: Optional[str] = None,
48
+ ) -> Callable[[str, str, str, Optional[str]], str]:
49
+ """
50
+ Create a tool that sends emails via SMTP.
51
+
52
+ This factory function creates a tool for sending emails with configurable SMTP settings.
53
+ All configuration values support AnyVariable types, allowing use of environment variables,
54
+ secrets, and composite variables.
55
+
56
+ Args:
57
+ smtp_config: SMTP configuration (SMTPConfigModel or dict). Supports:
58
+ - host: SMTP server hostname (supports variables/secrets)
59
+ - port: SMTP server port (supports variables/secrets)
60
+ - username: SMTP username (supports variables/secrets)
61
+ - password: SMTP password (supports variables/secrets)
62
+ - sender_email: Sender email address, defaults to username (supports variables/secrets)
63
+ - use_tls: Whether to use TLS encryption (default: True)
64
+ name: Custom tool name (default: 'send_email')
65
+ description: Custom tool description
66
+
67
+ Returns:
68
+ A tool function that sends emails via SMTP
69
+
70
+ Example:
71
+ Basic usage with environment variables:
72
+ ```yaml
73
+ tools:
74
+ send_email:
75
+ name: send_email
76
+ function:
77
+ type: factory
78
+ name: dao_ai.tools.email.create_send_email_tool
79
+ args:
80
+ smtp_config:
81
+ host: smtp.gmail.com
82
+ port: 587
83
+ username: ${SMTP_USER}
84
+ password: ${SMTP_PASSWORD}
85
+ sender_email: bot@example.com
86
+ ```
87
+
88
+ With secrets:
89
+ ```yaml
90
+ tools:
91
+ send_email:
92
+ name: send_email
93
+ function:
94
+ type: factory
95
+ name: dao_ai.tools.email.create_send_email_tool
96
+ args:
97
+ smtp_config:
98
+ host: smtp.gmail.com
99
+ port: 587
100
+ username:
101
+ type: secret
102
+ scope: email
103
+ key: smtp_user
104
+ password:
105
+ type: secret
106
+ scope: email
107
+ key: smtp_password
108
+ ```
109
+ """
110
+ logger.debug(
111
+ "Creating send_email_tool",
112
+ config_type=type(smtp_config).__name__,
113
+ tool_name=name,
114
+ )
115
+
116
+ # Convert dict to SMTPConfigModel if needed
117
+ if isinstance(smtp_config, dict):
118
+ smtp_config = SMTPConfigModel(**smtp_config)
119
+
120
+ # Resolve all variable values
121
+ host: str = value_of(smtp_config.host)
122
+ port: int = int(value_of(smtp_config.port))
123
+ username: str = value_of(smtp_config.username)
124
+ password: str = value_of(smtp_config.password)
125
+ sender_email: str = (
126
+ value_of(smtp_config.sender_email) if smtp_config.sender_email else username
127
+ )
128
+ use_tls: bool = smtp_config.use_tls
129
+
130
+ logger.info(
131
+ "SMTP configuration resolved",
132
+ host=host,
133
+ port=port,
134
+ sender=sender_email,
135
+ use_tls=use_tls,
136
+ password_set=bool(password),
137
+ )
138
+
139
+ if name is None:
140
+ name = "send_email"
141
+ if description is None:
142
+ description = "Send an email to a recipient with subject and body content"
143
+
144
+ logger.debug("Creating email tool with decorator", tool_name=name)
145
+
146
+ @tool(
147
+ name_or_callable=name,
148
+ description=description,
149
+ )
150
+ def send_email(
151
+ to: str,
152
+ subject: str,
153
+ body: str,
154
+ cc: Optional[str] = None,
155
+ ) -> str:
156
+ """
157
+ Send an email via SMTP.
158
+
159
+ Args:
160
+ to: Recipient email address
161
+ subject: Email subject line
162
+ body: Email body content (plain text)
163
+ cc: Optional CC recipients (comma-separated email addresses)
164
+
165
+ Returns:
166
+ str: Success or error message
167
+ """
168
+ logger.info(
169
+ "Sending email", to=to, subject=subject, body_length=len(body), cc=cc
170
+ )
171
+
172
+ try:
173
+ # Create message
174
+ msg = MIMEMultipart()
175
+ msg["From"] = sender_email
176
+ msg["To"] = to
177
+ msg["Subject"] = subject
178
+
179
+ if cc:
180
+ msg["Cc"] = cc
181
+
182
+ # Attach body as plain text
183
+ msg.attach(MIMEText(body, "plain"))
184
+
185
+ # Send email
186
+ logger.debug("Connecting to SMTP server", host=host, port=port)
187
+ with smtplib.SMTP(host, port) as server:
188
+ if use_tls:
189
+ logger.trace("Upgrading to TLS")
190
+ server.starttls()
191
+
192
+ logger.trace("Authenticating", username=username)
193
+ server.login(username, password)
194
+
195
+ # Build recipient list
196
+ recipients = [to]
197
+ if cc:
198
+ cc_addresses = [addr.strip() for addr in cc.split(",")]
199
+ recipients.extend(cc_addresses)
200
+
201
+ logger.debug("Sending message", recipients_count=len(recipients))
202
+ server.send_message(msg)
203
+
204
+ success_msg = f"✓ Email sent successfully to {to}"
205
+ if cc:
206
+ success_msg += f" (cc: {cc})"
207
+
208
+ logger.success("Email sent successfully", to=to, cc=cc)
209
+ return success_msg
210
+
211
+ except smtplib.SMTPAuthenticationError as e:
212
+ error_msg = f"✗ SMTP authentication failed: {str(e)}"
213
+ logger.error(
214
+ "SMTP authentication failed",
215
+ server=f"{host}:{port}",
216
+ username=username,
217
+ error=str(e),
218
+ )
219
+ return error_msg
220
+ except smtplib.SMTPException as e:
221
+ error_msg = f"✗ SMTP error: {str(e)}"
222
+ logger.error("SMTP error", server=f"{host}:{port}", error=str(e))
223
+ return error_msg
224
+ except Exception as e:
225
+ error_msg = f"✗ Failed to send email: {str(e)}"
226
+ logger.error(
227
+ "Failed to send email", error_type=type(e).__name__, error=str(e)
228
+ )
229
+ return error_msg
230
+
231
+ logger.success("Email tool created", tool_name=name)
232
+ return send_email