hanzo-mcp 0.8.2__py3-none-any.whl → 0.8.4__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.

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