daita-agents 0.1.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.
Potentially problematic release.
This version of daita-agents might be problematic. Click here for more details.
- daita/__init__.py +208 -0
- daita/agents/__init__.py +33 -0
- daita/agents/base.py +722 -0
- daita/agents/substrate.py +895 -0
- daita/cli/__init__.py +145 -0
- daita/cli/__main__.py +7 -0
- daita/cli/ascii_art.py +44 -0
- daita/cli/core/__init__.py +0 -0
- daita/cli/core/create.py +254 -0
- daita/cli/core/deploy.py +473 -0
- daita/cli/core/deployments.py +309 -0
- daita/cli/core/import_detector.py +219 -0
- daita/cli/core/init.py +382 -0
- daita/cli/core/logs.py +239 -0
- daita/cli/core/managed_deploy.py +709 -0
- daita/cli/core/run.py +648 -0
- daita/cli/core/status.py +421 -0
- daita/cli/core/test.py +239 -0
- daita/cli/core/webhooks.py +172 -0
- daita/cli/main.py +588 -0
- daita/cli/utils.py +541 -0
- daita/config/__init__.py +62 -0
- daita/config/base.py +159 -0
- daita/config/settings.py +184 -0
- daita/core/__init__.py +262 -0
- daita/core/decision_tracing.py +701 -0
- daita/core/exceptions.py +480 -0
- daita/core/focus.py +251 -0
- daita/core/interfaces.py +76 -0
- daita/core/plugin_tracing.py +550 -0
- daita/core/relay.py +695 -0
- daita/core/reliability.py +381 -0
- daita/core/scaling.py +444 -0
- daita/core/tools.py +402 -0
- daita/core/tracing.py +770 -0
- daita/core/workflow.py +1084 -0
- daita/display/__init__.py +1 -0
- daita/display/console.py +160 -0
- daita/execution/__init__.py +58 -0
- daita/execution/client.py +856 -0
- daita/execution/exceptions.py +92 -0
- daita/execution/models.py +317 -0
- daita/llm/__init__.py +60 -0
- daita/llm/anthropic.py +166 -0
- daita/llm/base.py +373 -0
- daita/llm/factory.py +101 -0
- daita/llm/gemini.py +152 -0
- daita/llm/grok.py +114 -0
- daita/llm/mock.py +135 -0
- daita/llm/openai.py +109 -0
- daita/plugins/__init__.py +141 -0
- daita/plugins/base.py +37 -0
- daita/plugins/base_db.py +167 -0
- daita/plugins/elasticsearch.py +844 -0
- daita/plugins/mcp.py +481 -0
- daita/plugins/mongodb.py +510 -0
- daita/plugins/mysql.py +351 -0
- daita/plugins/postgresql.py +331 -0
- daita/plugins/redis_messaging.py +500 -0
- daita/plugins/rest.py +529 -0
- daita/plugins/s3.py +761 -0
- daita/plugins/slack.py +729 -0
- daita/utils/__init__.py +18 -0
- daita_agents-0.1.0.dist-info/METADATA +350 -0
- daita_agents-0.1.0.dist-info/RECORD +69 -0
- daita_agents-0.1.0.dist-info/WHEEL +5 -0
- daita_agents-0.1.0.dist-info/entry_points.txt +2 -0
- daita_agents-0.1.0.dist-info/licenses/LICENSE +56 -0
- daita_agents-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Substrate Agent - The foundational agent for Daita Agents.
|
|
3
|
+
|
|
4
|
+
This agent provides a blank slate that users can build upon to create
|
|
5
|
+
custom agents for any task, with simplified error handling and retry capabilities.
|
|
6
|
+
All operations are automatically traced without any configuration required.
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Dict, Any, Optional, List, Union, Callable
|
|
13
|
+
|
|
14
|
+
from ..config.base import AgentConfig, AgentType
|
|
15
|
+
from ..core.interfaces import LLMProvider
|
|
16
|
+
from ..core.exceptions import (
|
|
17
|
+
DaitaError, AgentError, LLMError, PluginError,
|
|
18
|
+
ValidationError, InvalidDataError, NotFoundError
|
|
19
|
+
)
|
|
20
|
+
from .base import BaseAgent
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Import unified plugin access
|
|
25
|
+
from ..plugins import PluginAccess
|
|
26
|
+
from ..llm.factory import create_llm_provider
|
|
27
|
+
from ..config.settings import settings
|
|
28
|
+
from ..core.tools import AgentTool, ToolRegistry
|
|
29
|
+
|
|
30
|
+
class SubstrateAgent(BaseAgent):
|
|
31
|
+
"""
|
|
32
|
+
The foundational agent with unified prompt system and handler-based task execution.
|
|
33
|
+
|
|
34
|
+
SubstrateAgent uses a two-tier prompt system for composable AI behavior:
|
|
35
|
+
- Agent-level prompt (WHO the agent is) - set at creation
|
|
36
|
+
- Call-level instructions (WHAT to do) - set per process() call
|
|
37
|
+
|
|
38
|
+
All operations are automatically traced without any configuration required.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
name: Agent name
|
|
42
|
+
prompt: Agent identity and default behavior. Can be:
|
|
43
|
+
- str: Single prompt for all tasks
|
|
44
|
+
- dict: Task-specific prompts {"analyze": "...", "transform": "..."}
|
|
45
|
+
focus: Column/field filtering for data operations
|
|
46
|
+
handlers: Custom task handlers {task_name: handler_function}
|
|
47
|
+
tools: Plugin instances or AgentTool objects
|
|
48
|
+
llm_provider: LLM provider name or instance
|
|
49
|
+
model: Model name to use
|
|
50
|
+
api_key: API key for LLM provider (auto-detected if not provided)
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
Basic agent:
|
|
54
|
+
>>> agent = SubstrateAgent(name="processor")
|
|
55
|
+
>>> result = await agent.process("analyze", data)
|
|
56
|
+
|
|
57
|
+
Agent with identity:
|
|
58
|
+
>>> agent = SubstrateAgent(
|
|
59
|
+
... name="validator",
|
|
60
|
+
... prompt="You are a data quality expert. Always return JSON."
|
|
61
|
+
... )
|
|
62
|
+
>>> result = await agent.process("analyze", data)
|
|
63
|
+
|
|
64
|
+
Agent with call-specific instructions:
|
|
65
|
+
>>> agent = SubstrateAgent(prompt="You validate data quality.")
|
|
66
|
+
>>> result = await agent.process(
|
|
67
|
+
... "analyze",
|
|
68
|
+
... data,
|
|
69
|
+
... context={"instructions": "Focus on email validation"}
|
|
70
|
+
... )
|
|
71
|
+
|
|
72
|
+
Task-specific prompts:
|
|
73
|
+
>>> agent = SubstrateAgent(
|
|
74
|
+
... prompt={
|
|
75
|
+
... "analyze": "You are a data analyst.",
|
|
76
|
+
... "transform": "You are a data transformer."
|
|
77
|
+
... }
|
|
78
|
+
... )
|
|
79
|
+
|
|
80
|
+
Composition (agent identity + specific instructions):
|
|
81
|
+
>>> agent = SubstrateAgent(
|
|
82
|
+
... prompt="You are a data quality expert."
|
|
83
|
+
... )
|
|
84
|
+
>>> result = await agent.process(
|
|
85
|
+
... "analyze",
|
|
86
|
+
... batch_data,
|
|
87
|
+
... context={"instructions": "This is legacy data - be careful"}
|
|
88
|
+
... )
|
|
89
|
+
>>> # LLM receives both the agent prompt AND the specific instructions
|
|
90
|
+
|
|
91
|
+
Data operations example:
|
|
92
|
+
>>> validator = SubstrateAgent(
|
|
93
|
+
... name="ETL Validator",
|
|
94
|
+
... prompt="You validate data quality for ETL pipelines."
|
|
95
|
+
... )
|
|
96
|
+
>>> result = await validator.process(
|
|
97
|
+
... "analyze",
|
|
98
|
+
... staging_data,
|
|
99
|
+
... context={"instructions": "Validate against production schema"}
|
|
100
|
+
... )
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# Class-level defaults for smart constructor
|
|
104
|
+
_default_llm_provider = "openai"
|
|
105
|
+
_default_model = "gpt-4"
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def configure_defaults(cls, **kwargs):
|
|
109
|
+
"""Set global defaults for all SubstrateAgent instances."""
|
|
110
|
+
for key, value in kwargs.items():
|
|
111
|
+
setattr(cls, f'_default_{key}', value)
|
|
112
|
+
|
|
113
|
+
def __new__(cls, name=None, **kwargs):
|
|
114
|
+
"""Smart constructor with auto-configuration."""
|
|
115
|
+
# Auto-configuration from environment and defaults
|
|
116
|
+
if not kwargs.get('llm_provider'):
|
|
117
|
+
kwargs['llm_provider'] = getattr(cls, '_default_llm_provider', 'openai')
|
|
118
|
+
if not kwargs.get('model'):
|
|
119
|
+
kwargs['model'] = getattr(cls, '_default_model', 'gpt-4')
|
|
120
|
+
if not kwargs.get('api_key'):
|
|
121
|
+
provider = kwargs.get('llm_provider', 'openai')
|
|
122
|
+
# Only try to get API key if provider is a string (not an object)
|
|
123
|
+
if isinstance(provider, str):
|
|
124
|
+
kwargs['api_key'] = settings.get_llm_api_key(provider)
|
|
125
|
+
|
|
126
|
+
return super().__new__(cls)
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
name: Optional[str] = None,
|
|
131
|
+
llm_provider: Optional[Union[str, LLMProvider]] = None,
|
|
132
|
+
model: Optional[str] = None,
|
|
133
|
+
api_key: Optional[str] = None,
|
|
134
|
+
config: Optional[AgentConfig] = None,
|
|
135
|
+
agent_id: Optional[str] = None,
|
|
136
|
+
prompt: Optional[Union[str, Dict[str, str]]] = None,
|
|
137
|
+
focus: Optional[Union[List[str], str, Dict[str, Any]]] = None,
|
|
138
|
+
handlers: Optional[Dict[str, Callable]] = None,
|
|
139
|
+
relay: Optional[str] = None,
|
|
140
|
+
mcp: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
|
|
141
|
+
display_reasoning: bool = False,
|
|
142
|
+
**kwargs
|
|
143
|
+
):
|
|
144
|
+
"""
|
|
145
|
+
Initialize the Substrate Agent with smart constructor pattern.
|
|
146
|
+
|
|
147
|
+
This constructor auto-creates LLM providers and provides sensible
|
|
148
|
+
defaults while preserving all functionality.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
name: Agent name (required for direct instantiation)
|
|
152
|
+
llm_provider: LLM provider name ("openai", "anthropic") or instance
|
|
153
|
+
model: Model name ("gpt-4", "claude-3-sonnet-20240229")
|
|
154
|
+
api_key: API key for LLM provider (auto-detected from env if not provided)
|
|
155
|
+
config: Agent configuration (auto-generated if not provided)
|
|
156
|
+
agent_id: Unique identifier for the agent
|
|
157
|
+
prompt: Custom prompt or prompt templates
|
|
158
|
+
focus: Default focus configuration for data processing
|
|
159
|
+
handlers: Dictionary of task handlers {task_name: handler_function}
|
|
160
|
+
relay: Name of relay channel for publishing results
|
|
161
|
+
mcp: MCP server(s) for tool integration - single dict or list of dicts
|
|
162
|
+
display_reasoning: Enable minimal decision display in console
|
|
163
|
+
**kwargs: Additional configuration options (can include 'tools' parameter)
|
|
164
|
+
"""
|
|
165
|
+
# Auto-create LLM provider if needed
|
|
166
|
+
if isinstance(llm_provider, str) or llm_provider is None:
|
|
167
|
+
provider_name = llm_provider or self._default_llm_provider
|
|
168
|
+
model_name = model or self._default_model
|
|
169
|
+
api_key_to_use = api_key or settings.get_llm_api_key(provider_name)
|
|
170
|
+
|
|
171
|
+
if api_key_to_use:
|
|
172
|
+
llm_provider = create_llm_provider(
|
|
173
|
+
provider=provider_name,
|
|
174
|
+
model=model_name,
|
|
175
|
+
api_key=api_key_to_use,
|
|
176
|
+
agent_id=agent_id
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
logger.warning(f"No API key found for {provider_name}. LLM functionality will be disabled.")
|
|
180
|
+
llm_provider = None
|
|
181
|
+
# Create default config if none provided
|
|
182
|
+
if config is None:
|
|
183
|
+
config = AgentConfig(
|
|
184
|
+
name=name or "Substrate Agent",
|
|
185
|
+
type=AgentType.SUBSTRATE,
|
|
186
|
+
**kwargs
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Initialize base agent (which handles automatic tracing)
|
|
190
|
+
super().__init__(config, llm_provider, agent_id, name)
|
|
191
|
+
|
|
192
|
+
# Store customization options
|
|
193
|
+
self.prompt = prompt
|
|
194
|
+
self.default_focus = focus
|
|
195
|
+
self.relay = relay
|
|
196
|
+
|
|
197
|
+
# Decision display setup
|
|
198
|
+
self.display_reasoning = display_reasoning
|
|
199
|
+
self._decision_display = None
|
|
200
|
+
|
|
201
|
+
if display_reasoning:
|
|
202
|
+
self._setup_decision_display()
|
|
203
|
+
|
|
204
|
+
# Tool management (unified system)
|
|
205
|
+
self.tool_registry = ToolRegistry()
|
|
206
|
+
self.tool_sources = kwargs.get('tools', []) # Plugins, AgentTool instances, or callables
|
|
207
|
+
self._tools_setup = False
|
|
208
|
+
|
|
209
|
+
# MCP server integration
|
|
210
|
+
self.mcp_registry = None
|
|
211
|
+
self.mcp_tools = []
|
|
212
|
+
if mcp is not None:
|
|
213
|
+
# Normalize to list
|
|
214
|
+
mcp_servers = [mcp] if isinstance(mcp, dict) else mcp
|
|
215
|
+
self._mcp_server_configs = mcp_servers
|
|
216
|
+
# MCP setup happens lazily on first use to avoid blocking init
|
|
217
|
+
else:
|
|
218
|
+
self._mcp_server_configs = []
|
|
219
|
+
|
|
220
|
+
# Task handlers - users can define custom behavior
|
|
221
|
+
self.handlers: Dict[str, Callable] = handlers or {}
|
|
222
|
+
|
|
223
|
+
# Built-in handlers for common operations
|
|
224
|
+
self._register_builtin_handlers()
|
|
225
|
+
|
|
226
|
+
# Plugin access for direct plugin usage
|
|
227
|
+
self.plugins = PluginAccess()
|
|
228
|
+
|
|
229
|
+
logger.debug(f"Substrate Agent {self.name} initialized with {len(self.handlers)} handlers")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _setup_decision_display(self):
|
|
233
|
+
"""Setup minimal decision display for local development."""
|
|
234
|
+
try:
|
|
235
|
+
from ..display.console import create_console_decision_display
|
|
236
|
+
from ..core.decision_tracing import register_agent_decision_stream
|
|
237
|
+
|
|
238
|
+
# Create display
|
|
239
|
+
self._decision_display = create_console_decision_display(
|
|
240
|
+
agent_name=self.name,
|
|
241
|
+
agent_id=self.agent_id
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Register with decision streaming system
|
|
245
|
+
register_agent_decision_stream(
|
|
246
|
+
agent_id=self.agent_id,
|
|
247
|
+
callback=self._decision_display.handle_event
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
logger.debug(f"Decision display enabled for agent {self.name}")
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.warning(f"Failed to setup decision display: {e}")
|
|
254
|
+
self.display_reasoning = False
|
|
255
|
+
self._decision_display = None
|
|
256
|
+
|
|
257
|
+
async def _setup_mcp_tools(self):
|
|
258
|
+
"""
|
|
259
|
+
Setup MCP servers and discover available tools.
|
|
260
|
+
|
|
261
|
+
This is called lazily on first agent.process() to avoid blocking
|
|
262
|
+
agent initialization with MCP server connections.
|
|
263
|
+
"""
|
|
264
|
+
if self.mcp_registry is not None:
|
|
265
|
+
# Already setup
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if not self._mcp_server_configs:
|
|
269
|
+
# No MCP servers configured
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
from ..plugins.mcp import MCPServer, MCPToolRegistry
|
|
274
|
+
|
|
275
|
+
logger.info(f"Setting up {len(self._mcp_server_configs)} MCP server(s) for {self.name}")
|
|
276
|
+
|
|
277
|
+
# Create registry
|
|
278
|
+
self.mcp_registry = MCPToolRegistry()
|
|
279
|
+
|
|
280
|
+
# Connect to each server and register tools
|
|
281
|
+
for server_config in self._mcp_server_configs:
|
|
282
|
+
server = MCPServer(
|
|
283
|
+
command=server_config.get("command"),
|
|
284
|
+
args=server_config.get("args", []),
|
|
285
|
+
env=server_config.get("env", {}),
|
|
286
|
+
server_name=server_config.get("name")
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Add to registry (automatically connects and discovers tools)
|
|
290
|
+
await self.mcp_registry.add_server(server)
|
|
291
|
+
|
|
292
|
+
# Get all tools from registry
|
|
293
|
+
self.mcp_tools = self.mcp_registry.get_all_tools()
|
|
294
|
+
|
|
295
|
+
logger.info(f"MCP setup complete: {self.mcp_registry.tool_count} tools from {self.mcp_registry.server_count} server(s)")
|
|
296
|
+
|
|
297
|
+
except ImportError:
|
|
298
|
+
logger.error(
|
|
299
|
+
"MCP SDK not installed. Install with: pip install mcp\n"
|
|
300
|
+
"See: https://github.com/modelcontextprotocol/python-sdk"
|
|
301
|
+
)
|
|
302
|
+
raise
|
|
303
|
+
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.error(f"Failed to setup MCP servers: {str(e)}")
|
|
306
|
+
raise
|
|
307
|
+
|
|
308
|
+
async def _setup_tools(self):
|
|
309
|
+
"""
|
|
310
|
+
Discover and register tools from all sources.
|
|
311
|
+
|
|
312
|
+
Called lazily on first process() call to avoid blocking initialization.
|
|
313
|
+
Sources can be:
|
|
314
|
+
- Plugin instances with get_tools() method
|
|
315
|
+
- AgentTool instances directly
|
|
316
|
+
- MCP server configurations
|
|
317
|
+
"""
|
|
318
|
+
if self._tools_setup:
|
|
319
|
+
return # Already setup
|
|
320
|
+
|
|
321
|
+
# 1. Setup MCP tools first
|
|
322
|
+
if self._mcp_server_configs and self.mcp_registry is None:
|
|
323
|
+
await self._setup_mcp_tools()
|
|
324
|
+
# Convert MCP tools to AgentTool format
|
|
325
|
+
for mcp_tool in self.mcp_tools:
|
|
326
|
+
agent_tool = AgentTool.from_mcp_tool(mcp_tool, self.mcp_registry)
|
|
327
|
+
self.tool_registry.register(agent_tool)
|
|
328
|
+
|
|
329
|
+
# 2. Register plugin tools
|
|
330
|
+
for source in self.tool_sources:
|
|
331
|
+
if isinstance(source, AgentTool):
|
|
332
|
+
# Direct AgentTool registration
|
|
333
|
+
self.tool_registry.register(source)
|
|
334
|
+
logger.debug(f"Registered tool: {source.name}")
|
|
335
|
+
|
|
336
|
+
elif hasattr(source, 'get_tools'):
|
|
337
|
+
# Plugin with get_tools() method
|
|
338
|
+
plugin_tools = source.get_tools()
|
|
339
|
+
if plugin_tools:
|
|
340
|
+
self.tool_registry.register_many(plugin_tools)
|
|
341
|
+
logger.info(
|
|
342
|
+
f"Registered {len(plugin_tools)} tools from "
|
|
343
|
+
f"{source.__class__.__name__}"
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
logger.warning(
|
|
347
|
+
f"Invalid tool source: {source}. "
|
|
348
|
+
f"Expected AgentTool or plugin with get_tools() method."
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
self._tools_setup = True
|
|
352
|
+
logger.info(
|
|
353
|
+
f"Agent {self.name} initialized with {self.tool_registry.tool_count} tools"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
async def _call_llm(
|
|
357
|
+
self,
|
|
358
|
+
task: str,
|
|
359
|
+
data: Any,
|
|
360
|
+
context: Dict[str, Any],
|
|
361
|
+
default_system_prompt: Optional[str] = None
|
|
362
|
+
) -> str:
|
|
363
|
+
"""
|
|
364
|
+
Centralized LLM gateway - ALL LLM calls go through here.
|
|
365
|
+
|
|
366
|
+
Composes: agent prompt + context instructions + data
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
task: Task name (for task-specific prompts)
|
|
370
|
+
data: Input data to process
|
|
371
|
+
context: Call-level context with optional 'instructions'
|
|
372
|
+
default_system_prompt: Fallback if no agent prompt configured
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
LLM response string
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
AgentError: If no LLM configured
|
|
379
|
+
"""
|
|
380
|
+
if not self.llm:
|
|
381
|
+
raise AgentError(f"No LLM provider configured for task: {task}")
|
|
382
|
+
|
|
383
|
+
# 1. Build system message (agent identity/behavior)
|
|
384
|
+
system_prompt = None
|
|
385
|
+
if self.prompt:
|
|
386
|
+
if isinstance(self.prompt, dict):
|
|
387
|
+
# Task-specific prompt
|
|
388
|
+
system_prompt = self.prompt.get(task, self.prompt.get('default'))
|
|
389
|
+
else:
|
|
390
|
+
# Single prompt for all tasks
|
|
391
|
+
system_prompt = str(self.prompt)
|
|
392
|
+
|
|
393
|
+
# Use default if no agent prompt
|
|
394
|
+
if not system_prompt and default_system_prompt:
|
|
395
|
+
system_prompt = default_system_prompt
|
|
396
|
+
|
|
397
|
+
# 2. Build user message (instructions + data)
|
|
398
|
+
user_parts = []
|
|
399
|
+
|
|
400
|
+
# Add call-specific instructions
|
|
401
|
+
instructions = context.get('instructions')
|
|
402
|
+
if instructions:
|
|
403
|
+
user_parts.append(str(instructions))
|
|
404
|
+
|
|
405
|
+
# Add data
|
|
406
|
+
if data is not None:
|
|
407
|
+
# Smart data formatting
|
|
408
|
+
if isinstance(data, str):
|
|
409
|
+
data_str = data
|
|
410
|
+
elif isinstance(data, (list, dict)):
|
|
411
|
+
import json
|
|
412
|
+
try:
|
|
413
|
+
data_str = json.dumps(data, indent=2, default=str)
|
|
414
|
+
except:
|
|
415
|
+
data_str = str(data)
|
|
416
|
+
else:
|
|
417
|
+
data_str = str(data)
|
|
418
|
+
|
|
419
|
+
# Truncate very long data
|
|
420
|
+
if len(data_str) > 10000:
|
|
421
|
+
data_str = data_str[:10000] + "\n... (truncated)"
|
|
422
|
+
|
|
423
|
+
user_parts.append(f"Data:\n{data_str}")
|
|
424
|
+
|
|
425
|
+
user_message = "\n\n".join(user_parts) if user_parts else ""
|
|
426
|
+
|
|
427
|
+
# 3. Call LLM with proper structure
|
|
428
|
+
# Check if provider supports system messages (most do)
|
|
429
|
+
if hasattr(self.llm, 'generate_with_system'):
|
|
430
|
+
response = await self.llm.generate_with_system(
|
|
431
|
+
system=system_prompt,
|
|
432
|
+
user=user_message
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
# Fallback: concatenate into single prompt
|
|
436
|
+
if system_prompt:
|
|
437
|
+
full_prompt = f"{system_prompt}\n\n{user_message}"
|
|
438
|
+
else:
|
|
439
|
+
full_prompt = user_message
|
|
440
|
+
response = await self.llm.generate(full_prompt)
|
|
441
|
+
|
|
442
|
+
return response
|
|
443
|
+
|
|
444
|
+
def _register_builtin_handlers(self):
|
|
445
|
+
"""Register built-in handlers for common tasks."""
|
|
446
|
+
self.handlers.update({
|
|
447
|
+
'analyze': self._handle_analyze,
|
|
448
|
+
'transform': self._handle_transform,
|
|
449
|
+
'process_data': self._handle_process_data,
|
|
450
|
+
'custom': self._handle_custom,
|
|
451
|
+
'relay_message': self._handle_relay_message,
|
|
452
|
+
'llm_query': self._handle_llm_query
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
async def _process_once(
|
|
456
|
+
self,
|
|
457
|
+
task: str,
|
|
458
|
+
data: Any,
|
|
459
|
+
context: Dict[str, Any],
|
|
460
|
+
attempt: int,
|
|
461
|
+
max_attempts: int
|
|
462
|
+
) -> Dict[str, Any]:
|
|
463
|
+
"""
|
|
464
|
+
Execute the task once using the SubstrateAgent's handler system.
|
|
465
|
+
|
|
466
|
+
This override provides the SubstrateAgent's specific task routing logic
|
|
467
|
+
while letting the parent handle retry and tracing automatically.
|
|
468
|
+
"""
|
|
469
|
+
# Setup all tools (lazy initialization - includes MCP and plugin tools)
|
|
470
|
+
await self._setup_tools()
|
|
471
|
+
|
|
472
|
+
# Apply focus if specified
|
|
473
|
+
focus_to_use = context.get('focus') or self.default_focus
|
|
474
|
+
if focus_to_use and data is not None:
|
|
475
|
+
try:
|
|
476
|
+
from ..core.focus import apply_focus
|
|
477
|
+
data = apply_focus(data, focus_to_use)
|
|
478
|
+
except Exception as e:
|
|
479
|
+
logger.warning(f"Focus application failed: {str(e)}")
|
|
480
|
+
# Continue with original data if focus fails
|
|
481
|
+
|
|
482
|
+
# Find the appropriate handler
|
|
483
|
+
handler = None
|
|
484
|
+
if task in self.handlers:
|
|
485
|
+
handler = self.handlers[task]
|
|
486
|
+
elif self.llm:
|
|
487
|
+
# Fall back to LLM query if no specific handler found
|
|
488
|
+
handler = self._handle_llm_query
|
|
489
|
+
else:
|
|
490
|
+
# No handler and no LLM available - use default behavior
|
|
491
|
+
return self._handle_default(data, context, self)
|
|
492
|
+
|
|
493
|
+
# Execute the handler
|
|
494
|
+
try:
|
|
495
|
+
if asyncio.iscoroutinefunction(handler):
|
|
496
|
+
result = await handler(data, context, self)
|
|
497
|
+
else:
|
|
498
|
+
result = handler(data, context, self)
|
|
499
|
+
|
|
500
|
+
# Ensure result is a dictionary
|
|
501
|
+
if not isinstance(result, dict):
|
|
502
|
+
result = {"handler_result": result}
|
|
503
|
+
|
|
504
|
+
# Add metadata
|
|
505
|
+
result.update({
|
|
506
|
+
"task": task,
|
|
507
|
+
"agent_id": self.agent_id,
|
|
508
|
+
"agent_name": self.name,
|
|
509
|
+
"attempt": attempt,
|
|
510
|
+
"handler_used": handler.__name__ if hasattr(handler, '__name__') else str(handler),
|
|
511
|
+
"timestamp": datetime.utcnow().isoformat()
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
# Publish to relay if configured and result is successful
|
|
515
|
+
if self.relay:
|
|
516
|
+
await self._publish_to_relay(result, context)
|
|
517
|
+
|
|
518
|
+
return result
|
|
519
|
+
|
|
520
|
+
except Exception as e:
|
|
521
|
+
logger.error(f"Handler execution failed in {self.name}: {str(e)}")
|
|
522
|
+
raise
|
|
523
|
+
|
|
524
|
+
# Built-in Handlers
|
|
525
|
+
|
|
526
|
+
async def _handle_llm_query(self, data: Any, context: Dict[str, Any], agent) -> Dict[str, Any]:
|
|
527
|
+
"""Handle direct LLM queries using unified gateway."""
|
|
528
|
+
if not self.llm:
|
|
529
|
+
raise AgentError("No LLM provider configured for LLM query")
|
|
530
|
+
|
|
531
|
+
# Use gateway for consistent behavior
|
|
532
|
+
response = await self._call_llm(
|
|
533
|
+
task=context.get('task', 'llm_query'),
|
|
534
|
+
data=data,
|
|
535
|
+
context=context,
|
|
536
|
+
default_system_prompt="You are a helpful AI assistant. Process the user's request."
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
"agent_response": response,
|
|
541
|
+
"llm_provider": self.llm.provider_name if hasattr(self.llm, 'provider_name') else 'unknown'
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async def _llm_with_tools(self, prompt: str, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
545
|
+
"""
|
|
546
|
+
Handle LLM query with MCP tool calling support.
|
|
547
|
+
|
|
548
|
+
This implements the tool calling flow:
|
|
549
|
+
1. Send prompt with available tools to LLM
|
|
550
|
+
2. If LLM requests tool call, execute it via MCP
|
|
551
|
+
3. Return tool result to LLM for final response
|
|
552
|
+
4. Return final response to user
|
|
553
|
+
"""
|
|
554
|
+
# Convert MCP tools to LLM function format
|
|
555
|
+
tool_definitions = [tool.to_llm_function() for tool in self.mcp_tools]
|
|
556
|
+
|
|
557
|
+
logger.debug(f"LLM query with {len(tool_definitions)} MCP tools available")
|
|
558
|
+
|
|
559
|
+
# For now, we'll do a simple implementation that appends tool info to the prompt
|
|
560
|
+
# Full function calling integration requires LLM provider updates
|
|
561
|
+
# This is MVP: tools are described, LLM can reference them in response
|
|
562
|
+
|
|
563
|
+
# Build tool descriptions for prompt
|
|
564
|
+
tool_descriptions = "\n\nAvailable tools:\n"
|
|
565
|
+
for tool in self.mcp_tools:
|
|
566
|
+
tool_descriptions += f"- {tool.name}: {tool.description}\n"
|
|
567
|
+
|
|
568
|
+
enhanced_prompt = f"{prompt}{tool_descriptions}"
|
|
569
|
+
|
|
570
|
+
response = await self.llm.generate(enhanced_prompt)
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
"agent_response": response,
|
|
574
|
+
"prompt_used": enhanced_prompt,
|
|
575
|
+
"mcp_tools_available": [t.name for t in self.mcp_tools],
|
|
576
|
+
"llm_provider": self.llm.provider_name if hasattr(self.llm, 'provider_name') else 'unknown'
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async def call_mcp_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
|
580
|
+
"""
|
|
581
|
+
Manually call an MCP tool.
|
|
582
|
+
|
|
583
|
+
This allows users to explicitly call MCP tools from their handlers
|
|
584
|
+
or for testing purposes.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
tool_name: Name of the MCP tool to call
|
|
588
|
+
arguments: Arguments for the tool
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Tool execution result
|
|
592
|
+
|
|
593
|
+
Example:
|
|
594
|
+
```python
|
|
595
|
+
result = await agent.call_mcp_tool("read_file", {"path": "/data/file.txt"})
|
|
596
|
+
```
|
|
597
|
+
"""
|
|
598
|
+
if not self.mcp_registry:
|
|
599
|
+
raise RuntimeError("No MCP servers configured. Add mcp parameter to SubstrateAgent.")
|
|
600
|
+
|
|
601
|
+
return await self.mcp_registry.call_tool(tool_name, arguments)
|
|
602
|
+
|
|
603
|
+
async def _handle_analyze(self, data: Any, context: Dict[str, Any], agent) -> Dict[str, Any]:
|
|
604
|
+
"""Handle analysis tasks with unified prompt system."""
|
|
605
|
+
try:
|
|
606
|
+
# Basic data type analysis (non-LLM)
|
|
607
|
+
analysis_result = {
|
|
608
|
+
"data_type": type(data).__name__,
|
|
609
|
+
"data_size": len(data) if hasattr(data, '__len__') else None,
|
|
610
|
+
"keys": list(data.keys()) if isinstance(data, dict) else None,
|
|
611
|
+
"sample": str(data)[:200] if data else None
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
# LLM analysis using gateway
|
|
615
|
+
if self.llm:
|
|
616
|
+
llm_analysis = await self._call_llm(
|
|
617
|
+
task="analyze",
|
|
618
|
+
data=data,
|
|
619
|
+
context=context,
|
|
620
|
+
default_system_prompt="You are a data analyst. Analyze the provided data and provide detailed insights."
|
|
621
|
+
)
|
|
622
|
+
analysis_result["llm_analysis"] = llm_analysis
|
|
623
|
+
|
|
624
|
+
return analysis_result
|
|
625
|
+
|
|
626
|
+
except Exception as e:
|
|
627
|
+
logger.error(f"Analysis failed: {str(e)}")
|
|
628
|
+
return {
|
|
629
|
+
"analysis_error": str(e),
|
|
630
|
+
"data_type": type(data).__name__,
|
|
631
|
+
"message": "Analysis completed with errors"
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async def _handle_transform(self, data: Any, context: Dict[str, Any], agent) -> Dict[str, Any]:
|
|
635
|
+
"""Handle transformation tasks with LLM support."""
|
|
636
|
+
try:
|
|
637
|
+
# Use LLM if available
|
|
638
|
+
if self.llm:
|
|
639
|
+
transformed = await self._call_llm(
|
|
640
|
+
task="transform",
|
|
641
|
+
data=data,
|
|
642
|
+
context=context,
|
|
643
|
+
default_system_prompt="You are a data transformer. Transform the data according to instructions."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
"transformed": transformed,
|
|
648
|
+
"transformation_type": "llm_guided",
|
|
649
|
+
"original_type": type(data).__name__
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
# Fallback: basic transformations if no LLM
|
|
653
|
+
if isinstance(data, dict):
|
|
654
|
+
transformed = {k: str(v).upper() if isinstance(v, str) else v for k, v in data.items()}
|
|
655
|
+
transformation_type = "dict_string_uppercase"
|
|
656
|
+
elif isinstance(data, list):
|
|
657
|
+
transformed = [str(item).title() if isinstance(item, str) else item for item in data]
|
|
658
|
+
transformation_type = "list_string_title"
|
|
659
|
+
elif isinstance(data, str):
|
|
660
|
+
transformed = data.title()
|
|
661
|
+
transformation_type = "string_title"
|
|
662
|
+
else:
|
|
663
|
+
transformed = str(data)
|
|
664
|
+
transformation_type = "to_string"
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
"transformed": transformed,
|
|
668
|
+
"transformation_type": transformation_type,
|
|
669
|
+
"original_type": type(data).__name__
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
except Exception as e:
|
|
673
|
+
logger.error(f"Transformation failed: {str(e)}")
|
|
674
|
+
return {
|
|
675
|
+
"transformation_error": str(e),
|
|
676
|
+
"original_data": data,
|
|
677
|
+
"message": "Transformation completed with errors"
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async def _handle_process_data(self, data: Any, context: Dict[str, Any], agent) -> Dict[str, Any]:
|
|
681
|
+
"""Handle generic data processing with optional LLM."""
|
|
682
|
+
result = {
|
|
683
|
+
'status': 'success',
|
|
684
|
+
'message': 'Data processed',
|
|
685
|
+
'input_type': type(data).__name__,
|
|
686
|
+
'processed_at': datetime.utcnow().isoformat(),
|
|
687
|
+
'task': 'process_data'
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
# Add LLM processing if available
|
|
691
|
+
if self.llm:
|
|
692
|
+
processed = await self._call_llm(
|
|
693
|
+
task="process_data",
|
|
694
|
+
data=data,
|
|
695
|
+
context=context,
|
|
696
|
+
default_system_prompt="Process this data as requested."
|
|
697
|
+
)
|
|
698
|
+
result['llm_processed'] = processed
|
|
699
|
+
|
|
700
|
+
return result
|
|
701
|
+
|
|
702
|
+
async def _handle_custom(self, data: Any, context: Dict[str, Any], agent) -> Dict[str, Any]:
|
|
703
|
+
"""Handle custom tasks."""
|
|
704
|
+
return {
|
|
705
|
+
'status': 'success',
|
|
706
|
+
'message': 'Custom task executed',
|
|
707
|
+
'data_received': data is not None,
|
|
708
|
+
'context_keys': list(context.keys()),
|
|
709
|
+
'task': 'custom'
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async def _handle_relay_message(self, data: Any, context: Dict[str, Any], agent) -> Dict[str, Any]:
|
|
713
|
+
"""Handle incoming relay messages from other agents."""
|
|
714
|
+
return {
|
|
715
|
+
'status': 'success',
|
|
716
|
+
'message': 'Received relay message',
|
|
717
|
+
'source_agent': context.get('source_agent', 'unknown'),
|
|
718
|
+
'data_preview': str(data)[:200] if data else None,
|
|
719
|
+
'task': 'relay_message'
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
def _handle_default(self, data: Any, context: Dict[str, Any], agent) -> Dict[str, Any]:
|
|
723
|
+
"""Default handler when no specific handler is found."""
|
|
724
|
+
return {
|
|
725
|
+
'status': 'success',
|
|
726
|
+
'message': f'Default handler executed for task: {context.get("task", "unknown")}',
|
|
727
|
+
'data_type': type(data).__name__,
|
|
728
|
+
'data_received': data is not None,
|
|
729
|
+
'task': context.get('task', 'unknown')
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
# User customization methods
|
|
733
|
+
|
|
734
|
+
def add_handler(self, task: str, handler: Callable):
|
|
735
|
+
"""Add a custom task handler."""
|
|
736
|
+
if not task:
|
|
737
|
+
raise ValidationError("Task name cannot be empty")
|
|
738
|
+
if not callable(handler):
|
|
739
|
+
raise ValidationError("Handler must be callable")
|
|
740
|
+
|
|
741
|
+
self.handlers[task] = handler
|
|
742
|
+
logger.debug(f"Added handler for task: {task}")
|
|
743
|
+
|
|
744
|
+
def remove_handler(self, task: str):
|
|
745
|
+
"""Remove a task handler."""
|
|
746
|
+
if task in self.handlers:
|
|
747
|
+
del self.handlers[task]
|
|
748
|
+
logger.debug(f"Removed handler for task: {task}")
|
|
749
|
+
|
|
750
|
+
def add_plugin(self, plugin: Any):
|
|
751
|
+
"""
|
|
752
|
+
Add a plugin to the agent's tool sources.
|
|
753
|
+
|
|
754
|
+
The plugin's tools will be registered on next tool setup.
|
|
755
|
+
"""
|
|
756
|
+
self.tool_sources.append(plugin)
|
|
757
|
+
logger.debug(f"Added plugin: {plugin.__class__.__name__}")
|
|
758
|
+
|
|
759
|
+
def register_tool(self, tool: AgentTool) -> None:
|
|
760
|
+
"""
|
|
761
|
+
Register a single tool manually.
|
|
762
|
+
|
|
763
|
+
Useful for adding custom tools after agent initialization.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
tool: AgentTool instance to register
|
|
767
|
+
|
|
768
|
+
Example:
|
|
769
|
+
```python
|
|
770
|
+
agent = SubstrateAgent(name="my_agent")
|
|
771
|
+
|
|
772
|
+
custom_tool = AgentTool.from_function(my_custom_function)
|
|
773
|
+
agent.register_tool(custom_tool)
|
|
774
|
+
```
|
|
775
|
+
"""
|
|
776
|
+
self.tool_registry.register(tool)
|
|
777
|
+
|
|
778
|
+
def register_tools(self, tools: List[AgentTool]) -> None:
|
|
779
|
+
"""
|
|
780
|
+
Register multiple tools manually.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
tools: List of AgentTool instances
|
|
784
|
+
"""
|
|
785
|
+
self.tool_registry.register_many(tools)
|
|
786
|
+
|
|
787
|
+
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any:
|
|
788
|
+
"""
|
|
789
|
+
Execute a tool by name.
|
|
790
|
+
|
|
791
|
+
Provides manual tool execution for testing or custom handlers.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
name: Tool name
|
|
795
|
+
arguments: Tool arguments dict
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
Tool execution result
|
|
799
|
+
|
|
800
|
+
Example:
|
|
801
|
+
```python
|
|
802
|
+
result = await agent.call_tool("query_database", {"sql": "SELECT 1"})
|
|
803
|
+
```
|
|
804
|
+
"""
|
|
805
|
+
await self._setup_tools()
|
|
806
|
+
return await self.tool_registry.execute(name, arguments)
|
|
807
|
+
|
|
808
|
+
@property
|
|
809
|
+
def available_tools(self) -> List[AgentTool]:
|
|
810
|
+
"""
|
|
811
|
+
Get list of all available tools.
|
|
812
|
+
|
|
813
|
+
Returns:
|
|
814
|
+
List of AgentTool instances
|
|
815
|
+
"""
|
|
816
|
+
return self.tool_registry.tools.copy()
|
|
817
|
+
|
|
818
|
+
@property
|
|
819
|
+
def tool_names(self) -> List[str]:
|
|
820
|
+
"""Get list of all tool names"""
|
|
821
|
+
return self.tool_registry.tool_names
|
|
822
|
+
|
|
823
|
+
async def stop(self) -> None:
|
|
824
|
+
"""Stop agent and clean up all resources including MCP connections."""
|
|
825
|
+
# Clean up MCP connections first
|
|
826
|
+
if self.mcp_registry:
|
|
827
|
+
try:
|
|
828
|
+
await self.mcp_registry.disconnect_all()
|
|
829
|
+
logger.info(f"Cleaned up MCP connections for agent {self.name}")
|
|
830
|
+
except Exception as e:
|
|
831
|
+
logger.warning(f"Error cleaning up MCP connections: {e}")
|
|
832
|
+
|
|
833
|
+
# Call parent stop for standard cleanup
|
|
834
|
+
await super().stop()
|
|
835
|
+
|
|
836
|
+
def get_token_usage(self) -> Dict[str, int]:
|
|
837
|
+
"""
|
|
838
|
+
Get token usage for this agent using automatic tracing.
|
|
839
|
+
|
|
840
|
+
Returns comprehensive token statistics from the unified tracing system.
|
|
841
|
+
"""
|
|
842
|
+
if not self.llm or not hasattr(self.llm, 'get_token_stats'):
|
|
843
|
+
# Fallback for agents without LLM or tracing
|
|
844
|
+
return {
|
|
845
|
+
'total_tokens': 0,
|
|
846
|
+
'prompt_tokens': 0,
|
|
847
|
+
'completion_tokens': 0,
|
|
848
|
+
'requests': 0
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return self.llm.get_token_stats()
|
|
852
|
+
|
|
853
|
+
async def _publish_to_relay(self, result: Dict[str, Any], context: Dict[str, Any]):
|
|
854
|
+
"""Publish result to relay channel."""
|
|
855
|
+
try:
|
|
856
|
+
from ..core.relay import publish
|
|
857
|
+
|
|
858
|
+
await publish(
|
|
859
|
+
channel=self.relay,
|
|
860
|
+
agent_response=result,
|
|
861
|
+
publisher=self.name
|
|
862
|
+
)
|
|
863
|
+
logger.debug(f"Published result to relay channel: {self.relay}")
|
|
864
|
+
except Exception as e:
|
|
865
|
+
logger.warning(f"Failed to publish to relay channel {self.relay}: {str(e)}")
|
|
866
|
+
# Don't re-raise - relay failures shouldn't break main processing
|
|
867
|
+
|
|
868
|
+
@property
|
|
869
|
+
def health(self) -> Dict[str, Any]:
|
|
870
|
+
"""Enhanced health information for SubstrateAgent."""
|
|
871
|
+
base_health = super().health
|
|
872
|
+
|
|
873
|
+
# Add SubstrateAgent-specific health info
|
|
874
|
+
base_health.update({
|
|
875
|
+
'handlers': {
|
|
876
|
+
'count': len(self.handlers),
|
|
877
|
+
'builtin': len([h for h in self.handlers.keys() if not h.startswith('_')]),
|
|
878
|
+
'custom': len([h for h in self.handlers.keys() if h.startswith('_')])
|
|
879
|
+
},
|
|
880
|
+
'tools': {
|
|
881
|
+
'count': self.tool_registry.tool_count,
|
|
882
|
+
'setup': self._tools_setup,
|
|
883
|
+
'names': self.tool_registry.tool_names if self._tools_setup else []
|
|
884
|
+
},
|
|
885
|
+
'relay': {
|
|
886
|
+
'enabled': self.relay is not None,
|
|
887
|
+
'channel': self.relay
|
|
888
|
+
},
|
|
889
|
+
'llm': {
|
|
890
|
+
'available': self.llm is not None,
|
|
891
|
+
'provider': self.llm.provider_name if self.llm and hasattr(self.llm, 'provider_name') else None
|
|
892
|
+
}
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
return base_health
|