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.

Files changed (69) hide show
  1. daita/__init__.py +208 -0
  2. daita/agents/__init__.py +33 -0
  3. daita/agents/base.py +722 -0
  4. daita/agents/substrate.py +895 -0
  5. daita/cli/__init__.py +145 -0
  6. daita/cli/__main__.py +7 -0
  7. daita/cli/ascii_art.py +44 -0
  8. daita/cli/core/__init__.py +0 -0
  9. daita/cli/core/create.py +254 -0
  10. daita/cli/core/deploy.py +473 -0
  11. daita/cli/core/deployments.py +309 -0
  12. daita/cli/core/import_detector.py +219 -0
  13. daita/cli/core/init.py +382 -0
  14. daita/cli/core/logs.py +239 -0
  15. daita/cli/core/managed_deploy.py +709 -0
  16. daita/cli/core/run.py +648 -0
  17. daita/cli/core/status.py +421 -0
  18. daita/cli/core/test.py +239 -0
  19. daita/cli/core/webhooks.py +172 -0
  20. daita/cli/main.py +588 -0
  21. daita/cli/utils.py +541 -0
  22. daita/config/__init__.py +62 -0
  23. daita/config/base.py +159 -0
  24. daita/config/settings.py +184 -0
  25. daita/core/__init__.py +262 -0
  26. daita/core/decision_tracing.py +701 -0
  27. daita/core/exceptions.py +480 -0
  28. daita/core/focus.py +251 -0
  29. daita/core/interfaces.py +76 -0
  30. daita/core/plugin_tracing.py +550 -0
  31. daita/core/relay.py +695 -0
  32. daita/core/reliability.py +381 -0
  33. daita/core/scaling.py +444 -0
  34. daita/core/tools.py +402 -0
  35. daita/core/tracing.py +770 -0
  36. daita/core/workflow.py +1084 -0
  37. daita/display/__init__.py +1 -0
  38. daita/display/console.py +160 -0
  39. daita/execution/__init__.py +58 -0
  40. daita/execution/client.py +856 -0
  41. daita/execution/exceptions.py +92 -0
  42. daita/execution/models.py +317 -0
  43. daita/llm/__init__.py +60 -0
  44. daita/llm/anthropic.py +166 -0
  45. daita/llm/base.py +373 -0
  46. daita/llm/factory.py +101 -0
  47. daita/llm/gemini.py +152 -0
  48. daita/llm/grok.py +114 -0
  49. daita/llm/mock.py +135 -0
  50. daita/llm/openai.py +109 -0
  51. daita/plugins/__init__.py +141 -0
  52. daita/plugins/base.py +37 -0
  53. daita/plugins/base_db.py +167 -0
  54. daita/plugins/elasticsearch.py +844 -0
  55. daita/plugins/mcp.py +481 -0
  56. daita/plugins/mongodb.py +510 -0
  57. daita/plugins/mysql.py +351 -0
  58. daita/plugins/postgresql.py +331 -0
  59. daita/plugins/redis_messaging.py +500 -0
  60. daita/plugins/rest.py +529 -0
  61. daita/plugins/s3.py +761 -0
  62. daita/plugins/slack.py +729 -0
  63. daita/utils/__init__.py +18 -0
  64. daita_agents-0.1.0.dist-info/METADATA +350 -0
  65. daita_agents-0.1.0.dist-info/RECORD +69 -0
  66. daita_agents-0.1.0.dist-info/WHEEL +5 -0
  67. daita_agents-0.1.0.dist-info/entry_points.txt +2 -0
  68. daita_agents-0.1.0.dist-info/licenses/LICENSE +56 -0
  69. 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