hanzo-mcp 0.8.1__py3-none-any.whl → 0.8.3__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 hanzo-mcp might be problematic. Click here for more details.

@@ -514,6 +514,17 @@ def _merge_config(
514
514
 
515
515
  merged = deep_merge(base_dict, config_dict)
516
516
 
517
+ # Backwards/forwards compatibility: support a structured "tools" section
518
+ # where each tool can define { enabled: bool, ...options } and map it to
519
+ # the existing enabled_tools/disabled_tools layout.
520
+ tools_cfg = merged.get("tools", {})
521
+ if isinstance(tools_cfg, dict):
522
+ enabled_tools = dict(merged.get("enabled_tools", {}))
523
+ for tool_name, tool_data in tools_cfg.items():
524
+ if isinstance(tool_data, dict) and "enabled" in tool_data:
525
+ enabled_tools[tool_name] = bool(tool_data.get("enabled"))
526
+ merged["enabled_tools"] = enabled_tools
527
+
517
528
  # Reconstruct the settings object
518
529
  mcp_servers = {}
519
530
  for name, server_data in merged.get("mcp_servers", {}).items():
@@ -0,0 +1,521 @@
1
+ """Base Agent - Unified foundation for all AI agent implementations.
2
+
3
+ This module provides the single base class for all agent operations,
4
+ following DRY principles and ensuring consistent behavior across all agents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import asyncio
11
+ import logging
12
+ from abc import ABC, abstractmethod
13
+ from typing import Any, Dict, List, Optional, Protocol, TypeVar, Generic, runtime_checkable
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from .model_registry import registry, ModelConfig
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ # Type variables for generic context
25
+ TContext = TypeVar("TContext")
26
+ TResult = TypeVar("TResult")
27
+
28
+
29
+ @runtime_checkable
30
+ class AgentContext(Protocol):
31
+ """Protocol for agent execution context."""
32
+
33
+ async def log(self, message: str, level: str = "info") -> None:
34
+ """Log a message."""
35
+ pass
36
+
37
+ async def progress(self, message: str, percentage: Optional[float] = None) -> None:
38
+ """Report progress."""
39
+ pass
40
+
41
+
42
+ @dataclass
43
+ class AgentConfig:
44
+ """Configuration for agent execution."""
45
+
46
+ model: str = "claude-3-5-sonnet-20241022"
47
+ timeout: int = 300
48
+ max_retries: int = 3
49
+ working_dir: Optional[Path] = None
50
+ environment: Dict[str, str] = field(default_factory=dict)
51
+ stream_output: bool = False
52
+ use_worktree: bool = False
53
+
54
+ def __post_init__(self) -> None:
55
+ """Resolve model name and validate configuration."""
56
+ self.model = registry.resolve(self.model)
57
+ if self.working_dir and not isinstance(self.working_dir, Path):
58
+ self.working_dir = Path(self.working_dir)
59
+
60
+
61
+ @dataclass
62
+ class AgentResult:
63
+ """Result from agent execution."""
64
+
65
+ success: bool
66
+ output: Optional[str] = None
67
+ error: Optional[str] = None
68
+ duration: Optional[float] = None
69
+ metadata: Dict[str, Any] = field(default_factory=dict)
70
+
71
+ @property
72
+ def content(self) -> str:
73
+ """Get the primary content (output or error)."""
74
+ return self.output if self.success else (self.error or "Unknown error")
75
+
76
+
77
+ class BaseAgent(ABC, Generic[TContext]):
78
+ """Base class for all AI agents.
79
+
80
+ This is the single foundation for all agent implementations,
81
+ ensuring consistent behavior and eliminating code duplication.
82
+ """
83
+
84
+ def __init__(self, config: Optional[AgentConfig] = None) -> None:
85
+ """Initialize agent with configuration.
86
+
87
+ Args:
88
+ config: Agent configuration
89
+ """
90
+ self.config = config or AgentConfig()
91
+ self._start_time: Optional[datetime] = None
92
+ self._end_time: Optional[datetime] = None
93
+
94
+ @property
95
+ @abstractmethod
96
+ def name(self) -> str:
97
+ """Agent name."""
98
+ pass
99
+
100
+ @property
101
+ @abstractmethod
102
+ def description(self) -> str:
103
+ """Agent description."""
104
+ pass
105
+
106
+ async def execute(
107
+ self,
108
+ prompt: str,
109
+ context: Optional[TContext] = None,
110
+ **kwargs: Any,
111
+ ) -> AgentResult:
112
+ """Execute agent with prompt.
113
+
114
+ Args:
115
+ prompt: The prompt or task
116
+ context: Execution context
117
+ **kwargs: Additional parameters
118
+
119
+ Returns:
120
+ Agent execution result
121
+ """
122
+ self._start_time = datetime.now()
123
+
124
+ try:
125
+ # Setup environment
126
+ env = self._prepare_environment()
127
+
128
+ # Log start
129
+ if context and isinstance(context, AgentContext):
130
+ await context.log(f"Starting {self.name} with model {self.config.model}")
131
+
132
+ # Execute with retries
133
+ result = await self._execute_with_retries(prompt, context, env, **kwargs)
134
+
135
+ # Calculate duration
136
+ self._end_time = datetime.now()
137
+ duration = (self._end_time - self._start_time).total_seconds()
138
+
139
+ return AgentResult(
140
+ success=True,
141
+ output=result,
142
+ duration=duration,
143
+ metadata={"model": self.config.model, "agent": self.name},
144
+ )
145
+
146
+ except Exception as e:
147
+ self._end_time = datetime.now()
148
+ duration = (self._end_time - self._start_time).total_seconds() if self._start_time else None
149
+
150
+ logger.error(f"Agent {self.name} failed: {e}")
151
+
152
+ return AgentResult(
153
+ success=False,
154
+ error=str(e),
155
+ duration=duration,
156
+ metadata={"model": self.config.model, "agent": self.name},
157
+ )
158
+
159
+ def _prepare_environment(self) -> Dict[str, str]:
160
+ """Prepare environment variables for execution.
161
+
162
+ Returns:
163
+ Environment variables dictionary
164
+ """
165
+ env = os.environ.copy()
166
+
167
+ # Add model-specific API key
168
+ model_config = registry.get(self.config.model)
169
+ if model_config and model_config.api_key_env:
170
+ key_var = model_config.api_key_env
171
+ if key_var in os.environ:
172
+ env[key_var] = os.environ[key_var]
173
+
174
+ # Add Hanzo unified auth
175
+ if "HANZO_API_KEY" in os.environ:
176
+ env["HANZO_API_KEY"] = os.environ["HANZO_API_KEY"]
177
+
178
+ # Add custom environment
179
+ env.update(self.config.environment)
180
+
181
+ return env
182
+
183
+ async def _execute_with_retries(
184
+ self,
185
+ prompt: str,
186
+ context: Optional[TContext],
187
+ env: Dict[str, str],
188
+ **kwargs: Any,
189
+ ) -> str:
190
+ """Execute with retry logic.
191
+
192
+ Args:
193
+ prompt: The prompt
194
+ context: Execution context
195
+ env: Environment variables
196
+ **kwargs: Additional parameters
197
+
198
+ Returns:
199
+ Execution output
200
+
201
+ Raises:
202
+ Exception: If all retries fail
203
+ """
204
+ last_error = None
205
+
206
+ for attempt in range(self.config.max_retries):
207
+ try:
208
+ # Call the implementation
209
+ result = await self._execute_impl(prompt, context, env, **kwargs)
210
+ return result
211
+
212
+ except asyncio.TimeoutError:
213
+ last_error = f"Timeout after {self.config.timeout} seconds"
214
+ if context and isinstance(context, AgentContext):
215
+ await context.log(f"Attempt {attempt + 1} timed out", "warning")
216
+
217
+ except Exception as e:
218
+ last_error = str(e)
219
+ if context and isinstance(context, AgentContext):
220
+ await context.log(f"Attempt {attempt + 1} failed: {e}", "warning")
221
+
222
+ # Don't retry on certain errors
223
+ if "unauthorized" in str(e).lower() or "forbidden" in str(e).lower():
224
+ raise
225
+
226
+ # Wait before retry (exponential backoff)
227
+ if attempt < self.config.max_retries - 1:
228
+ await asyncio.sleep(2 ** attempt)
229
+
230
+ raise Exception(f"All {self.config.max_retries} attempts failed. Last error: {last_error}")
231
+
232
+ @abstractmethod
233
+ async def _execute_impl(
234
+ self,
235
+ prompt: str,
236
+ context: Optional[TContext],
237
+ env: Dict[str, str],
238
+ **kwargs: Any,
239
+ ) -> str:
240
+ """Implementation-specific execution.
241
+
242
+ Args:
243
+ prompt: The prompt
244
+ context: Execution context
245
+ env: Environment variables
246
+ **kwargs: Additional parameters
247
+
248
+ Returns:
249
+ Execution output
250
+ """
251
+ pass
252
+
253
+
254
+ class CLIAgent(BaseAgent[TContext]):
255
+ """Base class for CLI-based agents."""
256
+
257
+ @property
258
+ @abstractmethod
259
+ def cli_command(self) -> str:
260
+ """CLI command to execute."""
261
+ pass
262
+
263
+ def build_command(self, prompt: str, **kwargs: Any) -> List[str]:
264
+ """Build the CLI command.
265
+
266
+ Args:
267
+ prompt: The prompt
268
+ **kwargs: Additional parameters
269
+
270
+ Returns:
271
+ Command arguments list
272
+ """
273
+ command = [self.cli_command]
274
+
275
+ # Add model if specified
276
+ model_config = registry.get(self.config.model)
277
+ if model_config:
278
+ command.extend(["--model", model_config.full_name])
279
+
280
+ # Add prompt
281
+ command.append(prompt)
282
+
283
+ return command
284
+
285
+ async def _execute_impl(
286
+ self,
287
+ prompt: str,
288
+ context: Optional[TContext],
289
+ env: Dict[str, str],
290
+ **kwargs: Any,
291
+ ) -> str:
292
+ """Execute CLI command.
293
+
294
+ Args:
295
+ prompt: The prompt
296
+ context: Execution context
297
+ env: Environment variables
298
+ **kwargs: Additional parameters
299
+
300
+ Returns:
301
+ Command output
302
+ """
303
+ command = self.build_command(prompt, **kwargs)
304
+
305
+ # Determine if we need stdin
306
+ needs_stdin = self.cli_command in ["claude", "cline"]
307
+
308
+ # Execute command
309
+ process = await asyncio.create_subprocess_exec(
310
+ *command,
311
+ stdin=asyncio.subprocess.PIPE if needs_stdin else None,
312
+ stdout=asyncio.subprocess.PIPE,
313
+ stderr=asyncio.subprocess.PIPE,
314
+ cwd=str(self.config.working_dir) if self.config.working_dir else None,
315
+ env=env,
316
+ )
317
+
318
+ # Handle timeout
319
+ try:
320
+ stdout, stderr = await asyncio.wait_for(
321
+ process.communicate(prompt.encode() if needs_stdin else None),
322
+ timeout=self.config.timeout,
323
+ )
324
+ except asyncio.TimeoutError:
325
+ process.kill()
326
+ raise asyncio.TimeoutError(f"Command timed out after {self.config.timeout} seconds")
327
+
328
+ # Check for errors
329
+ if process.returncode != 0:
330
+ error_msg = stderr.decode() if stderr else "Command failed"
331
+ raise Exception(error_msg)
332
+
333
+ return stdout.decode()
334
+
335
+
336
+ class APIAgent(BaseAgent[TContext]):
337
+ """Base class for API-based agents."""
338
+
339
+ async def _execute_impl(
340
+ self,
341
+ prompt: str,
342
+ context: Optional[TContext],
343
+ env: Dict[str, str],
344
+ **kwargs: Any,
345
+ ) -> str:
346
+ """Execute via API.
347
+
348
+ Args:
349
+ prompt: The prompt
350
+ context: Execution context
351
+ env: Environment variables
352
+ **kwargs: Additional parameters
353
+
354
+ Returns:
355
+ API response
356
+ """
357
+ # This would be implemented by specific API agents
358
+ # using the appropriate client library
359
+ raise NotImplementedError("API agents must implement _execute_impl")
360
+
361
+
362
+ class AgentOrchestrator:
363
+ """Orchestrator for managing multiple agents."""
364
+
365
+ def __init__(self, default_config: Optional[AgentConfig] = None) -> None:
366
+ """Initialize orchestrator.
367
+
368
+ Args:
369
+ default_config: Default configuration for agents
370
+ """
371
+ self.default_config = default_config or AgentConfig()
372
+ self._agents: Dict[str, BaseAgent] = {}
373
+ self._semaphore: Optional[asyncio.Semaphore] = None
374
+
375
+ def register(self, agent: BaseAgent) -> None:
376
+ """Register an agent.
377
+
378
+ Args:
379
+ agent: Agent to register
380
+ """
381
+ self._agents[agent.name] = agent
382
+
383
+ def get_agent(self, name: str) -> Optional[BaseAgent]:
384
+ """Get agent by name.
385
+
386
+ Args:
387
+ name: Agent name
388
+
389
+ Returns:
390
+ Agent instance or None
391
+ """
392
+ return self._agents.get(name)
393
+
394
+ async def execute_single(
395
+ self,
396
+ agent_name: str,
397
+ prompt: str,
398
+ context: Optional[Any] = None,
399
+ **kwargs: Any,
400
+ ) -> AgentResult:
401
+ """Execute single agent.
402
+
403
+ Args:
404
+ agent_name: Name of agent to use
405
+ prompt: The prompt
406
+ context: Execution context
407
+ **kwargs: Additional parameters
408
+
409
+ Returns:
410
+ Execution result
411
+ """
412
+ agent = self.get_agent(agent_name)
413
+ if not agent:
414
+ return AgentResult(
415
+ success=False,
416
+ error=f"Agent '{agent_name}' not found",
417
+ )
418
+
419
+ return await agent.execute(prompt, context, **kwargs)
420
+
421
+ async def execute_parallel(
422
+ self,
423
+ tasks: List[Dict[str, Any]],
424
+ max_concurrent: int = 5,
425
+ ) -> List[AgentResult]:
426
+ """Execute multiple agents in parallel.
427
+
428
+ Args:
429
+ tasks: List of task definitions
430
+ max_concurrent: Maximum concurrent executions
431
+
432
+ Returns:
433
+ List of results
434
+ """
435
+ self._semaphore = asyncio.Semaphore(max_concurrent)
436
+
437
+ async def run_with_semaphore(task: Dict[str, Any]) -> AgentResult:
438
+ async with self._semaphore:
439
+ return await self.execute_single(
440
+ task["agent"],
441
+ task["prompt"],
442
+ task.get("context"),
443
+ **task.get("kwargs", {}),
444
+ )
445
+
446
+ return await asyncio.gather(
447
+ *[run_with_semaphore(task) for task in tasks],
448
+ return_exceptions=False,
449
+ )
450
+
451
+ async def execute_consensus(
452
+ self,
453
+ prompt: str,
454
+ agents: List[str],
455
+ threshold: float = 0.66,
456
+ ) -> Dict[str, Any]:
457
+ """Execute consensus operation with multiple agents.
458
+
459
+ Args:
460
+ prompt: The prompt
461
+ agents: List of agent names
462
+ threshold: Agreement threshold
463
+
464
+ Returns:
465
+ Consensus results
466
+ """
467
+ # Execute all agents in parallel
468
+ tasks = [{"agent": agent, "prompt": prompt} for agent in agents]
469
+ results = await self.execute_parallel(tasks)
470
+
471
+ # Analyze consensus
472
+ successful = [r for r in results if r.success]
473
+ agreement = len(successful) / len(results) if results else 0
474
+
475
+ return {
476
+ "consensus_reached": agreement >= threshold,
477
+ "agreement_score": agreement,
478
+ "individual_results": results,
479
+ "agents_used": agents,
480
+ }
481
+
482
+ async def execute_chain(
483
+ self,
484
+ initial_prompt: str,
485
+ agents: List[str],
486
+ ) -> List[AgentResult]:
487
+ """Execute agents in a chain, passing output forward.
488
+
489
+ Args:
490
+ initial_prompt: Initial prompt
491
+ agents: List of agent names
492
+
493
+ Returns:
494
+ List of results from each step
495
+ """
496
+ results = []
497
+ current_prompt = initial_prompt
498
+
499
+ for agent_name in agents:
500
+ result = await self.execute_single(agent_name, current_prompt)
501
+ results.append(result)
502
+
503
+ if result.success and result.output:
504
+ # Use output as input for next agent
505
+ current_prompt = f"Review and improve:\n{result.output}"
506
+ else:
507
+ # Chain broken
508
+ break
509
+
510
+ return results
511
+
512
+
513
+ __all__ = [
514
+ "AgentContext",
515
+ "AgentConfig",
516
+ "AgentResult",
517
+ "BaseAgent",
518
+ "CLIAgent",
519
+ "APIAgent",
520
+ "AgentOrchestrator",
521
+ ]