Copy CCLI source to backend for Docker builds

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
anthonyrawlins
2025-07-10 12:46:52 +10:00
parent baa48bfcd4
commit 8b32d54e79
26 changed files with 2930 additions and 0 deletions

View File

@@ -0,0 +1 @@
# CLI Agents Package

View File

@@ -0,0 +1,344 @@
"""
CLI Agent Factory
Creates and manages CLI-based agents with predefined configurations.
"""
import logging
from typing import Dict, List, Optional, Any
from enum import Enum
from dataclasses import dataclass
from agents.gemini_cli_agent import GeminiCliAgent, GeminiCliConfig
class CliAgentType(Enum):
"""Supported CLI agent types"""
GEMINI = "gemini"
class Specialization(Enum):
"""Agent specializations"""
GENERAL_AI = "general_ai"
REASONING = "reasoning"
CODE_ANALYSIS = "code_analysis"
DOCUMENTATION = "documentation"
TESTING = "testing"
@dataclass
class CliAgentDefinition:
"""Definition for a CLI agent instance"""
agent_id: str
agent_type: CliAgentType
config: Dict[str, Any]
specialization: Specialization
description: str
enabled: bool = True
class CliAgentFactory:
"""
Factory for creating and managing CLI agents
Provides predefined configurations for known agent instances and
supports dynamic agent creation with custom configurations.
"""
# Predefined agent configurations based on verified environment testing
PREDEFINED_AGENTS = {
"walnut-gemini": CliAgentDefinition(
agent_id="walnut-gemini",
agent_type=CliAgentType.GEMINI,
config={
"host": "walnut",
"node_version": "v22.14.0",
"model": "gemini-2.5-pro",
"max_concurrent": 2,
"command_timeout": 60,
"ssh_timeout": 5
},
specialization=Specialization.GENERAL_AI,
description="Gemini CLI agent on WALNUT for general AI tasks",
enabled=True
),
"ironwood-gemini": CliAgentDefinition(
agent_id="ironwood-gemini",
agent_type=CliAgentType.GEMINI,
config={
"host": "ironwood",
"node_version": "v22.17.0",
"model": "gemini-2.5-pro",
"max_concurrent": 2,
"command_timeout": 60,
"ssh_timeout": 5
},
specialization=Specialization.REASONING,
description="Gemini CLI agent on IRONWOOD for reasoning tasks (faster)",
enabled=True
),
# Additional specialized configurations
"walnut-gemini-code": CliAgentDefinition(
agent_id="walnut-gemini-code",
agent_type=CliAgentType.GEMINI,
config={
"host": "walnut",
"node_version": "v22.14.0",
"model": "gemini-2.5-pro",
"max_concurrent": 1, # More conservative for code analysis
"command_timeout": 90, # Longer timeout for complex code analysis
"ssh_timeout": 5
},
specialization=Specialization.CODE_ANALYSIS,
description="Gemini CLI agent specialized for code analysis tasks",
enabled=False # Start disabled, enable when needed
),
"ironwood-gemini-docs": CliAgentDefinition(
agent_id="ironwood-gemini-docs",
agent_type=CliAgentType.GEMINI,
config={
"host": "ironwood",
"node_version": "v22.17.0",
"model": "gemini-2.5-pro",
"max_concurrent": 2,
"command_timeout": 45,
"ssh_timeout": 5
},
specialization=Specialization.DOCUMENTATION,
description="Gemini CLI agent for documentation generation",
enabled=False
)
}
def __init__(self):
self.logger = logging.getLogger(__name__)
self.active_agents: Dict[str, GeminiCliAgent] = {}
@classmethod
def get_predefined_agent_ids(cls) -> List[str]:
"""Get list of all predefined agent IDs"""
return list(cls.PREDEFINED_AGENTS.keys())
@classmethod
def get_enabled_agent_ids(cls) -> List[str]:
"""Get list of enabled predefined agent IDs"""
return [
agent_id for agent_id, definition in cls.PREDEFINED_AGENTS.items()
if definition.enabled
]
@classmethod
def get_agent_definition(cls, agent_id: str) -> Optional[CliAgentDefinition]:
"""Get predefined agent definition by ID"""
return cls.PREDEFINED_AGENTS.get(agent_id)
def create_agent(self, agent_id: str, custom_config: Optional[Dict[str, Any]] = None) -> GeminiCliAgent:
"""
Create a CLI agent instance
Args:
agent_id: ID of predefined agent or custom ID
custom_config: Optional custom configuration to override defaults
Returns:
GeminiCliAgent instance
Raises:
ValueError: If agent_id is unknown and no custom_config provided
"""
# Check if agent already exists
if agent_id in self.active_agents:
self.logger.warning(f"Agent {agent_id} already exists, returning existing instance")
return self.active_agents[agent_id]
# Get configuration
if agent_id in self.PREDEFINED_AGENTS:
definition = self.PREDEFINED_AGENTS[agent_id]
if not definition.enabled:
self.logger.warning(f"Agent {agent_id} is disabled but being created anyway")
config_dict = definition.config.copy()
specialization = definition.specialization.value
# Apply custom overrides
if custom_config:
config_dict.update(custom_config)
elif custom_config:
# Custom agent configuration
config_dict = custom_config
specialization = custom_config.get("specialization", "general_ai")
else:
raise ValueError(f"Unknown agent ID '{agent_id}' and no custom configuration provided")
# Determine agent type and create appropriate agent
agent_type = config_dict.get("agent_type", "gemini")
if agent_type == "gemini" or agent_type == CliAgentType.GEMINI:
agent = self._create_gemini_agent(agent_id, config_dict, specialization)
else:
raise ValueError(f"Unsupported agent type: {agent_type}")
# Store in active agents
self.active_agents[agent_id] = agent
self.logger.info(f"Created CLI agent: {agent_id} ({specialization})")
return agent
def _create_gemini_agent(self, agent_id: str, config_dict: Dict[str, Any], specialization: str) -> GeminiCliAgent:
"""Create a Gemini CLI agent with the given configuration"""
# Create GeminiCliConfig from dictionary
config = GeminiCliConfig(
host=config_dict["host"],
node_version=config_dict["node_version"],
model=config_dict.get("model", "gemini-2.5-pro"),
max_concurrent=config_dict.get("max_concurrent", 2),
command_timeout=config_dict.get("command_timeout", 60),
ssh_timeout=config_dict.get("ssh_timeout", 5),
node_path=config_dict.get("node_path"),
gemini_path=config_dict.get("gemini_path")
)
return GeminiCliAgent(config, specialization)
def get_agent(self, agent_id: str) -> Optional[GeminiCliAgent]:
"""Get an existing agent instance"""
return self.active_agents.get(agent_id)
def remove_agent(self, agent_id: str) -> bool:
"""Remove an agent instance"""
if agent_id in self.active_agents:
agent = self.active_agents.pop(agent_id)
# Note: Cleanup should be called by the caller if needed
self.logger.info(f"Removed CLI agent: {agent_id}")
return True
return False
def get_active_agents(self) -> Dict[str, GeminiCliAgent]:
"""Get all active agent instances"""
return self.active_agents.copy()
def get_agent_info(self, agent_id: str) -> Optional[Dict[str, Any]]:
"""Get information about an agent"""
# Check active agents
if agent_id in self.active_agents:
agent = self.active_agents[agent_id]
return {
"agent_id": agent_id,
"status": "active",
"host": agent.config.host,
"model": agent.config.model,
"specialization": agent.specialization,
"active_tasks": len(agent.active_tasks),
"max_concurrent": agent.config.max_concurrent,
"statistics": agent.get_statistics()
}
# Check predefined but not active
if agent_id in self.PREDEFINED_AGENTS:
definition = self.PREDEFINED_AGENTS[agent_id]
return {
"agent_id": agent_id,
"status": "available" if definition.enabled else "disabled",
"agent_type": definition.agent_type.value,
"specialization": definition.specialization.value,
"description": definition.description,
"config": definition.config
}
return None
def list_all_agents(self) -> Dict[str, Dict[str, Any]]:
"""List all agents (predefined and active)"""
all_agents = {}
# Add predefined agents
for agent_id in self.PREDEFINED_AGENTS:
all_agents[agent_id] = self.get_agent_info(agent_id)
# Add any custom active agents not in predefined list
for agent_id in self.active_agents:
if agent_id not in all_agents:
all_agents[agent_id] = self.get_agent_info(agent_id)
return all_agents
async def health_check_all(self) -> Dict[str, Dict[str, Any]]:
"""Perform health checks on all active agents"""
health_results = {}
for agent_id, agent in self.active_agents.items():
try:
health_results[agent_id] = await agent.health_check()
except Exception as e:
health_results[agent_id] = {
"agent_id": agent_id,
"error": str(e),
"healthy": False
}
return health_results
async def cleanup_all(self):
"""Clean up all active agents"""
for agent_id, agent in list(self.active_agents.items()):
try:
await agent.cleanup()
self.logger.info(f"Cleaned up agent: {agent_id}")
except Exception as e:
self.logger.error(f"Error cleaning up agent {agent_id}: {e}")
self.active_agents.clear()
@classmethod
def create_custom_agent_config(cls, host: str, node_version: str,
specialization: str = "general_ai",
**kwargs) -> Dict[str, Any]:
"""
Helper to create custom agent configuration
Args:
host: Target host for SSH connection
node_version: Node.js version (e.g., "v22.14.0")
specialization: Agent specialization
**kwargs: Additional configuration options
Returns:
Configuration dictionary for create_agent()
"""
config = {
"host": host,
"node_version": node_version,
"specialization": specialization,
"agent_type": "gemini",
"model": "gemini-2.5-pro",
"max_concurrent": 2,
"command_timeout": 60,
"ssh_timeout": 5
}
config.update(kwargs)
return config
# Module-level convenience functions
_default_factory = None
def get_default_factory() -> CliAgentFactory:
"""Get the default CLI agent factory instance"""
global _default_factory
if _default_factory is None:
_default_factory = CliAgentFactory()
return _default_factory
def create_agent(agent_id: str, custom_config: Optional[Dict[str, Any]] = None) -> GeminiCliAgent:
"""Convenience function to create an agent using the default factory"""
factory = get_default_factory()
return factory.create_agent(agent_id, custom_config)

View File

@@ -0,0 +1,369 @@
"""
Gemini CLI Agent Adapter
Provides a standardized interface for executing tasks on Gemini CLI via SSH.
"""
import asyncio
import json
import time
import logging
import hashlib
from dataclasses import dataclass, asdict
from typing import Dict, Any, Optional, List
from enum import Enum
from executors.ssh_executor import SSHExecutor, SSHConfig, SSHResult
class TaskStatus(Enum):
"""Task execution status"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
TIMEOUT = "timeout"
@dataclass
class GeminiCliConfig:
"""Configuration for Gemini CLI agent"""
host: str
node_version: str
model: str = "gemini-2.5-pro"
max_concurrent: int = 2
command_timeout: int = 60
ssh_timeout: int = 5
node_path: Optional[str] = None
gemini_path: Optional[str] = None
def __post_init__(self):
"""Auto-generate paths if not provided"""
if self.node_path is None:
self.node_path = f"/home/tony/.nvm/versions/node/{self.node_version}/bin/node"
if self.gemini_path is None:
self.gemini_path = f"/home/tony/.nvm/versions/node/{self.node_version}/bin/gemini"
@dataclass
class TaskRequest:
"""Represents a task to be executed"""
prompt: str
model: Optional[str] = None
task_id: Optional[str] = None
priority: int = 3
metadata: Optional[Dict[str, Any]] = None
def __post_init__(self):
"""Generate task ID if not provided"""
if self.task_id is None:
# Generate a unique task ID based on prompt and timestamp
content = f"{self.prompt}_{time.time()}"
self.task_id = hashlib.md5(content.encode()).hexdigest()[:12]
@dataclass
class TaskResult:
"""Result of a task execution"""
task_id: str
status: TaskStatus
response: Optional[str] = None
error: Optional[str] = None
execution_time: float = 0.0
model: Optional[str] = None
agent_id: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
result = asdict(self)
result['status'] = self.status.value
return result
class GeminiCliAgent:
"""
Adapter for Google Gemini CLI execution via SSH
Provides a consistent interface for executing AI tasks on remote Gemini CLI installations
while handling SSH connections, environment setup, error recovery, and concurrent execution.
"""
def __init__(self, config: GeminiCliConfig, specialization: str = "general_ai"):
self.config = config
self.specialization = specialization
self.agent_id = f"{config.host}-gemini"
# SSH configuration
self.ssh_config = SSHConfig(
host=config.host,
connect_timeout=config.ssh_timeout,
command_timeout=config.command_timeout
)
# SSH executor with connection pooling
self.ssh_executor = SSHExecutor(pool_size=3, persist_timeout=120)
# Task management
self.active_tasks: Dict[str, asyncio.Task] = {}
self.task_history: List[TaskResult] = []
self.max_history = 100
# Logging
self.logger = logging.getLogger(f"gemini_cli.{config.host}")
# Performance tracking
self.stats = {
"total_tasks": 0,
"successful_tasks": 0,
"failed_tasks": 0,
"total_execution_time": 0.0,
"average_execution_time": 0.0
}
async def execute_task(self, request: TaskRequest) -> TaskResult:
"""
Execute a task on the Gemini CLI
Args:
request: TaskRequest containing prompt and configuration
Returns:
TaskResult with execution status and response
"""
# Check concurrent task limit
if len(self.active_tasks) >= self.config.max_concurrent:
return TaskResult(
task_id=request.task_id,
status=TaskStatus.FAILED,
error=f"Agent at maximum concurrent tasks ({self.config.max_concurrent})",
agent_id=self.agent_id
)
# Start task execution
task = asyncio.create_task(self._execute_task_impl(request))
self.active_tasks[request.task_id] = task
try:
result = await task
return result
finally:
# Clean up task from active list
self.active_tasks.pop(request.task_id, None)
async def _execute_task_impl(self, request: TaskRequest) -> TaskResult:
"""Internal implementation of task execution"""
start_time = time.time()
model = request.model or self.config.model
try:
self.logger.info(f"Starting task {request.task_id} with model {model}")
# Build the CLI command
command = self._build_cli_command(request.prompt, model)
# Execute via SSH
ssh_result = await self.ssh_executor.execute(self.ssh_config, command)
execution_time = time.time() - start_time
# Process result
if ssh_result.returncode == 0:
result = TaskResult(
task_id=request.task_id,
status=TaskStatus.COMPLETED,
response=self._clean_response(ssh_result.stdout),
execution_time=execution_time,
model=model,
agent_id=self.agent_id,
metadata={
"ssh_duration": ssh_result.duration,
"command": command,
"stderr": ssh_result.stderr
}
)
self.stats["successful_tasks"] += 1
else:
result = TaskResult(
task_id=request.task_id,
status=TaskStatus.FAILED,
error=f"CLI execution failed: {ssh_result.stderr}",
execution_time=execution_time,
model=model,
agent_id=self.agent_id,
metadata={
"returncode": ssh_result.returncode,
"command": command,
"stdout": ssh_result.stdout,
"stderr": ssh_result.stderr
}
)
self.stats["failed_tasks"] += 1
except Exception as e:
execution_time = time.time() - start_time
self.logger.error(f"Task {request.task_id} failed: {e}")
result = TaskResult(
task_id=request.task_id,
status=TaskStatus.FAILED,
error=str(e),
execution_time=execution_time,
model=model,
agent_id=self.agent_id
)
self.stats["failed_tasks"] += 1
# Update statistics
self.stats["total_tasks"] += 1
self.stats["total_execution_time"] += execution_time
self.stats["average_execution_time"] = (
self.stats["total_execution_time"] / self.stats["total_tasks"]
)
# Add to history (with size limit)
self.task_history.append(result)
if len(self.task_history) > self.max_history:
self.task_history.pop(0)
self.logger.info(f"Task {request.task_id} completed with status {result.status.value}")
return result
def _build_cli_command(self, prompt: str, model: str) -> str:
"""Build the complete CLI command for execution"""
# Environment setup
env_setup = f"source ~/.nvm/nvm.sh && nvm use {self.config.node_version}"
# Escape the prompt for shell safety
escaped_prompt = prompt.replace("'", "'\\''")
# Build gemini command
gemini_cmd = f"echo '{escaped_prompt}' | {self.config.gemini_path} --model {model}"
# Complete command
full_command = f"{env_setup} && {gemini_cmd}"
return full_command
def _clean_response(self, raw_output: str) -> str:
"""Clean up the raw CLI output"""
lines = raw_output.strip().split('\n')
# Remove NVM output lines
cleaned_lines = []
for line in lines:
if not (line.startswith('Now using node') or
line.startswith('MCP STDERR') or
line.strip() == ''):
cleaned_lines.append(line)
return '\n'.join(cleaned_lines).strip()
async def health_check(self) -> Dict[str, Any]:
"""Perform a health check on the agent"""
try:
# Test SSH connection
ssh_healthy = await self.ssh_executor.test_connection(self.ssh_config)
# Test Gemini CLI with a simple prompt
if ssh_healthy:
test_request = TaskRequest(
prompt="Say 'health check ok'",
task_id="health_check"
)
result = await self.execute_task(test_request)
cli_healthy = result.status == TaskStatus.COMPLETED
response_time = result.execution_time
else:
cli_healthy = False
response_time = None
# Get connection stats
connection_stats = await self.ssh_executor.get_connection_stats()
return {
"agent_id": self.agent_id,
"host": self.config.host,
"ssh_healthy": ssh_healthy,
"cli_healthy": cli_healthy,
"response_time": response_time,
"active_tasks": len(self.active_tasks),
"max_concurrent": self.config.max_concurrent,
"total_tasks": self.stats["total_tasks"],
"success_rate": (
self.stats["successful_tasks"] / max(self.stats["total_tasks"], 1)
),
"average_execution_time": self.stats["average_execution_time"],
"connection_stats": connection_stats,
"model": self.config.model,
"specialization": self.specialization
}
except Exception as e:
self.logger.error(f"Health check failed: {e}")
return {
"agent_id": self.agent_id,
"host": self.config.host,
"ssh_healthy": False,
"cli_healthy": False,
"error": str(e)
}
async def get_task_status(self, task_id: str) -> Optional[TaskResult]:
"""Get the status of a specific task"""
# Check active tasks
if task_id in self.active_tasks:
task = self.active_tasks[task_id]
if task.done():
return task.result()
else:
return TaskResult(
task_id=task_id,
status=TaskStatus.RUNNING,
agent_id=self.agent_id
)
# Check history
for result in reversed(self.task_history):
if result.task_id == task_id:
return result
return None
async def cancel_task(self, task_id: str) -> bool:
"""Cancel a running task"""
if task_id in self.active_tasks:
task = self.active_tasks[task_id]
if not task.done():
task.cancel()
return True
return False
def get_statistics(self) -> Dict[str, Any]:
"""Get agent performance statistics"""
return {
"agent_id": self.agent_id,
"host": self.config.host,
"specialization": self.specialization,
"model": self.config.model,
"stats": self.stats.copy(),
"active_tasks": len(self.active_tasks),
"history_length": len(self.task_history)
}
async def cleanup(self):
"""Clean up resources"""
# Cancel any active tasks
for task_id, task in list(self.active_tasks.items()):
if not task.done():
task.cancel()
# Wait for tasks to complete
if self.active_tasks:
await asyncio.gather(*self.active_tasks.values(), return_exceptions=True)
# Close SSH connections
await self.ssh_executor.cleanup()
self.logger.info(f"Agent {self.agent_id} cleaned up successfully")