From 32c4805a07928009aa23a0db68627fa99ea1522e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 10 Dec 2025 20:21:26 +0100 Subject: [PATCH] remove obsolete core-ai --- .../OBSOLETE_core-ai/ARCHITECTURE_DESIGN.md | 1158 ----------------- services/OBSOLETE_core-ai/Dockerfile | 18 - services/OBSOLETE_core-ai/README.md | 535 -------- .../OBSOLETE_core-ai/benchmark_multi_agent.py | 559 -------- .../OBSOLETE_core-ai/diagnostics/__init__.py | 1 - .../diagnostics/check_ollama.py | 128 -- services/OBSOLETE_core-ai/main.py | 458 ------- services/OBSOLETE_core-ai/pytest.ini | 31 - services/OBSOLETE_core-ai/requirements.txt | 24 - services/OBSOLETE_core-ai/src/__init__.py | 0 services/OBSOLETE_core-ai/src/agent.py | 126 -- .../OBSOLETE_core-ai/src/agents/__init__.py | 19 - .../src/agents/multi_stage_agent.py | 407 ------ .../src/agents/pydantic_agent.py | 287 ---- .../OBSOLETE_core-ai/src/agents/simple.py | 124 -- .../src/agents/steward_agent.py | 270 ---- .../src/agents/stream_handler.py | 464 ------- .../src/agents/text_steward.py | 288 ---- .../src/agents/tool_events.py | 108 -- .../src/agents/two_stage_agent.py | 389 ------ services/OBSOLETE_core-ai/src/config.py | 88 -- .../OBSOLETE_core-ai/src/memory/__init__.py | 56 - services/OBSOLETE_core-ai/src/memory/base.py | 169 --- .../OBSOLETE_core-ai/src/memory/manager.py | 337 ----- .../src/memory/qdrant_memory.py | 465 ------- .../OBSOLETE_core-ai/src/memory/schemas.py | 110 -- .../src/memory/tier1_buffer.py | 239 ---- .../OBSOLETE_core-ai/src/metrics/__init__.py | 12 - .../OBSOLETE_core-ai/src/metrics/collector.py | 314 ----- .../src/metrics/decorators.py | 128 -- .../OBSOLETE_core-ai/src/models/__init__.py | 15 - .../src/models/embeddings_ollama.py | 136 -- services/OBSOLETE_core-ai/src/prompts.py | 57 - .../OBSOLETE_core-ai/src/tools/__init__.py | 25 - .../src/tools/infrastructure/__init__.py | 40 - .../src/tools/infrastructure/containers.py | 490 ------- .../src/tools/infrastructure/monitoring.py | 247 ---- .../src/tools/infrastructure/services.py | 283 ---- services/OBSOLETE_core-ai/src/tools/local.py | 267 ---- .../src/tools/openapi_discovery.py | 307 ----- .../OBSOLETE_core-ai/src/tools/registry.py | 403 ------ services/OBSOLETE_core-ai/src/utils.py | 102 -- 42 files changed, 9684 deletions(-) delete mode 100644 services/OBSOLETE_core-ai/ARCHITECTURE_DESIGN.md delete mode 100644 services/OBSOLETE_core-ai/Dockerfile delete mode 100644 services/OBSOLETE_core-ai/README.md delete mode 100644 services/OBSOLETE_core-ai/benchmark_multi_agent.py delete mode 100755 services/OBSOLETE_core-ai/diagnostics/__init__.py delete mode 100755 services/OBSOLETE_core-ai/diagnostics/check_ollama.py delete mode 100644 services/OBSOLETE_core-ai/main.py delete mode 100644 services/OBSOLETE_core-ai/pytest.ini delete mode 100644 services/OBSOLETE_core-ai/requirements.txt delete mode 100644 services/OBSOLETE_core-ai/src/__init__.py delete mode 100644 services/OBSOLETE_core-ai/src/agent.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/__init__.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/multi_stage_agent.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/pydantic_agent.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/simple.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/steward_agent.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/stream_handler.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/text_steward.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/tool_events.py delete mode 100644 services/OBSOLETE_core-ai/src/agents/two_stage_agent.py delete mode 100644 services/OBSOLETE_core-ai/src/config.py delete mode 100644 services/OBSOLETE_core-ai/src/memory/__init__.py delete mode 100644 services/OBSOLETE_core-ai/src/memory/base.py delete mode 100644 services/OBSOLETE_core-ai/src/memory/manager.py delete mode 100644 services/OBSOLETE_core-ai/src/memory/qdrant_memory.py delete mode 100644 services/OBSOLETE_core-ai/src/memory/schemas.py delete mode 100644 services/OBSOLETE_core-ai/src/memory/tier1_buffer.py delete mode 100644 services/OBSOLETE_core-ai/src/metrics/__init__.py delete mode 100644 services/OBSOLETE_core-ai/src/metrics/collector.py delete mode 100644 services/OBSOLETE_core-ai/src/metrics/decorators.py delete mode 100644 services/OBSOLETE_core-ai/src/models/__init__.py delete mode 100644 services/OBSOLETE_core-ai/src/models/embeddings_ollama.py delete mode 100644 services/OBSOLETE_core-ai/src/prompts.py delete mode 100644 services/OBSOLETE_core-ai/src/tools/__init__.py delete mode 100644 services/OBSOLETE_core-ai/src/tools/infrastructure/__init__.py delete mode 100644 services/OBSOLETE_core-ai/src/tools/infrastructure/containers.py delete mode 100644 services/OBSOLETE_core-ai/src/tools/infrastructure/monitoring.py delete mode 100644 services/OBSOLETE_core-ai/src/tools/infrastructure/services.py delete mode 100644 services/OBSOLETE_core-ai/src/tools/local.py delete mode 100644 services/OBSOLETE_core-ai/src/tools/openapi_discovery.py delete mode 100644 services/OBSOLETE_core-ai/src/tools/registry.py delete mode 100644 services/OBSOLETE_core-ai/src/utils.py diff --git a/services/OBSOLETE_core-ai/ARCHITECTURE_DESIGN.md b/services/OBSOLETE_core-ai/ARCHITECTURE_DESIGN.md deleted file mode 100644 index 96e94bb..0000000 --- a/services/OBSOLETE_core-ai/ARCHITECTURE_DESIGN.md +++ /dev/null @@ -1,1158 +0,0 @@ -# Multi-Agent AI Architecture Design -**Status:** Design Phase - Pre-Implementation -**Date:** 2025-12-04 -**Version:** 2.0 - Text-Based Steward with Dynamic Toolsets - ---- - -## Executive Summary - -This document defines the architecture for a reliable, extensible multi-agent AI system with: - -- **Text-based steward** (no JSON validation failures) -- **Dynamic toolset loading** (PydanticAI toolsets per query) -- **Hierarchical agents** (Tatlock coordinator + specialized experts) -- **Logical tool clusters** (grouped by domain and intent) -- **Butler-appropriate UX** (modern British butler voice) - -**Key Decision**: Abandon structured JSON output in favor of text-based analysis with keyword extraction. This eliminates the 75% failure rate while maintaining intelligent tool selection. - ---- - -## 1. System Architecture - -### High-Level Flow - -``` -User Query - ↓ -[1. TEXT-BASED STEWARD] - - Analyzes query using mistral-nemo (2-3s) - - Returns: Plain text recommendations - - Parsed: Keyword extraction → domains - - Status: "Consulting the steward on the matter..." - ↓ -[2. TATLOCK COORDINATOR] - - Receives: List of recommended domains - - Loads: Only relevant toolsets dynamically - - Routes: To expert agents if needed - - Status: "The steward recommends: infrastructure" - ↓ -[3. TOOL EXECUTION / EXPERT ROUTING] - - Simple queries: Tatlock handles directly - - Complex queries: Route to expert (e.g., Handyman) - - Status: "Inspecting the household systems..." - ↓ -[4. RESPONSE SYNTHESIS] - - Tatlock: Reformats tool output → natural language - - User: Receives butler-appropriate response - - Status: "Your request is complete, sir." -``` - -### Component Diagram - -``` -┌─────────────────────────────────────────────────┐ -│ USER │ -└─────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────┐ -│ TEXT-BASED STEWARD │ -│ ─────────────────────────────────────────────── │ -│ • Model: mistral-nemo:latest │ -│ • Method: Plain text output (NO JSON) │ -│ • Speed: 2-3 seconds │ -│ • Reliability: 100% (no validation failures) │ -│ • Output: List of domain names + reasoning │ -└─────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────┐ -│ TATLOCK (Coordinator Agent) │ -│ ─────────────────────────────────────────────── │ -│ • Model: mistral-nemo:latest │ -│ • Framework: PydanticAI │ -│ • Tools: Dynamic (loaded per query) │ -│ • Role: Coordinate, route, synthesize │ -└─────────────────────────────────────────────────┘ - ↓ ↓ ↓ -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ DOMAIN: │ │ DOMAIN: │ │ DOMAIN: │ -│ Core │ │ Infrastructure│ │ Secretary │ -├──────────────┤ ├──────────────┤ ├──────────────┤ -│ Toolset: │ │ Toolset: │ │ Toolset: │ -│ - calculate │ │ - containers │ │ - calendar │ -│ - web_search │ │ - services │ │ - reminders │ -│ - time/date │ │ - monitoring │ │ - tasks │ -│ │ │ │ │ │ -│ Expert: None │ │ Expert: │ │ Expert: │ -│ │ │ Handyman │ │ Secretary │ -└──────────────┘ └──────────────┘ └──────────────┘ -``` - ---- - -## 2. Component Specifications - -### 2.1 Text-Based Steward - -**Purpose:** Analyze queries and recommend relevant domains without JSON validation failures. - -**Technical Specifications:** -```python -Class: TextBasedSteward -Model: mistral-nemo:latest -API: Direct Ollama HTTP (not PydanticAI) -Input: User query (string) -Output: StewardAnalysis(domains, reasoning, confidence) -Timeout: 5 seconds -Temperature: 0.3 (consistent recommendations) -``` - -**Domain Definitions:** -```yaml -core: - description: Essential utilities always available - tools: - - calculate (mathematical operations) - - web_search (current events, research) - - get_current_time (time queries) - - get_current_date (date queries) - - date calculations (add_days, date_diff) - always_included: true - -infrastructure: - description: System management and monitoring - tools: - - Container lifecycle (list, manage, inspect, logs) - - Service management (list, manage, status) - - Resource monitoring (system stats, container stats) - expert_agent: Handyman - mcp_server: null # Future: core-api MCP - -secretary: - description: Calendar and task management - status: planned - tools: - - Calendar (read, create, update, delete events) - - Reminders (set, list, dismiss) - - Tasks (create, list, complete) - expert_agent: SecretaryAgent - mcp_server: null # Future: calendar MCP - -home_automation: - description: Smart home control - status: planned - tools: - - Lights (turn on/off, dim, color) - - Climate (set temperature, mode) - - Scenes (activate, list) - expert_agent: HomeAutomationAgent - mcp_server: null # Future: Home Assistant MCP - -webdev: - description: Web development and automation - status: planned - tools: - - Screenshots (capture, analyze) - - Browser automation (navigate, click, form fill) - - HTML generation - expert_agent: null - mcp_server: null # Future: webdev MCP -``` - -**Analysis Process:** - -1. **LLM Generation (2-3s)** - ``` - Input: User query - Prompt: "Analyze this query and suggest relevant domains..." - Output: Plain text reasoning - - Example Output: - "This query involves Docker container management and system monitoring. - The infrastructure domain would be appropriate for this request." - ``` - -2. **Keyword Extraction (< 1ms)** - ```python - # Extract domains from text using keyword matching - domains = [] - if 'infrastructure' in text or 'docker' in text or 'container' in text: - domains.append('infrastructure') - if 'calendar' in text or 'remind' in text: - domains.append('secretary') - # ... more patterns - - # Always include core - domains.append('core') - ``` - -3. **Confidence Calculation** - ```python - # Based on explicit domain mentions in reasoning - confidence = (explicit_mentions / total_domains) - - # High confidence (0.9-1.0): LLM explicitly named domains - # Medium confidence (0.5-0.9): Keyword-based detection - # Low confidence (0.3-0.5): Fallback only - ``` - -**Fallback Strategy:** -- If steward LLM fails → Use keyword-only extraction -- If keywords match nothing → Default to ['core'] -- If timeout → Log warning, use cached/default domains - ---- - -### 2.2 Tatlock (Coordinator Agent) - -**Purpose:** Central coordinator that loads dynamic toolsets and routes to experts. - -**Technical Specifications:** -```python -Class: CoordinatorTatlock -Model: mistral-nemo:latest -Framework: PydanticAI with dynamic toolsets -Tools: Loaded per query based on steward analysis -Memory: Enabled (Qdrant + buffer) -``` - -**Responsibilities:** - -1. **Toolset Management** - ```python - # Based on steward's domain recommendations - recommended_domains = ['core', 'infrastructure'] - - # Load only relevant toolsets - toolsets = [] - for domain in recommended_domains: - toolsets.append(DOMAIN_TOOLSETS[domain]) - - # Create agent with dynamic tools - agent = Agent( - model=model, - system_prompt=prompt, - toolsets=toolsets # Only infrastructure + core tools loaded - ) - ``` - -2. **Expert Routing** - ```python - # Check if query needs expert agent - if 'infrastructure' in domains and query_is_complex(query): - # Route to Handyman (infrastructure expert) - return await handyman.handle(query) - - # Simple query? Handle directly - return await agent.run(query) - ``` - -3. **Response Synthesis** - ```python - # Tool returns structured data - tool_result = "Container: nginx\nStatus: running\nMemory: 45MB" - - # Tatlock reformats to natural language - # "The nginx container is currently running and using 45 megabytes of memory, sir." - ``` - -**System Prompt (Abbreviated):** -``` -You are Tatlock, a helpful personal assistant with the demeanor of a modern British butler. - -Today is {current_date}. - -TOOL USAGE GUIDELINES: -- ALWAYS use calculate for mathematical operations -- Use web_search for current events and real-time information -- Infrastructure tools available: {infrastructure_tools_summary} - -Maintain a formal yet personable tone. Address the user as "sir". -``` - ---- - -### 2.3 The Handyman (Infrastructure Expert) - -**Purpose:** Specialized agent for infrastructure management with deep domain knowledge. - -**Technical Specifications:** -```python -Class: HandymanAgent -Model: mistral-nemo:latest (or dedicated infrastructure model) -Framework: PydanticAI -Tools: Full infrastructure toolset -Context: System state awareness -``` - -**When to Route to Handyman:** - -```python -# Route to expert if: -conditions = [ - 'infrastructure' in domains, - query_complexity > 2, # Multi-step operations - requires_context # Needs system state awareness -] - -if all(conditions): - return await handyman.handle(query) -``` - -**Example Scenarios:** - -**Simple → Tatlock Handles:** -``` -User: "Is nginx running?" -Tatlock: Calls docker_inspect("nginx") → formats response -``` - -**Complex → Routes to Handyman:** -``` -User: "Restart all stopped containers and show me their logs" -Handyman: - 1. Lists stopped containers - 2. Restarts each one - 3. Fetches logs for all - 4. Synthesizes comprehensive report -``` - -**System Prompt (Handyman-Specific):** -``` -You are the Handyman, responsible for maintaining the household's infrastructure systems. - -You have deep knowledge of: -- Docker container management -- Service orchestration -- System monitoring and diagnostics - -Your tools give you direct access to infrastructure systems. -Be thorough, precise, and proactive in identifying issues. - -Report findings to Tatlock for presentation to sir. -``` - ---- - -## 3. Tool Design: Logical Clusters - -### 3.1 Tool Return Format Specification - -**Target Consumer:** Tatlock (LLM), not end-user directly. - -**Format:** Formatted text with structured data (hybrid). - -**Template:** -``` -{SUMMARY_LINE} - -{STRUCTURED_DATA} - Key1: Value1 - Key2: Value2 - ... - -{ADDITIONAL_CONTEXT} -``` - -**Examples:** - -**Good - Formatted Text with Structure:** -```python -def docker_list_containers() -> str: - return """Containers (3 running, 1 stopped): - -• nginx - Status: running (uptime: 3 days) - Ports: 80→8080, 443→8443 - Memory: 45.2 MB / 100 MB (45%) - -• core-ai - Status: running (uptime: 7 hours) - Ports: 8086→8086 - Memory: 312.5 MB / 512 MB (61%) - -• ollama - Status: running (uptime: 2 days) - Ports: 11434→11434 - Memory: 6.8 GB / 11 GB (62%) - -• test-db - Status: stopped - Last exit: 1 hour ago (exit code 0)""" -``` - -**Why This Works:** -- ✅ Tatlock can parse structure (bullet points, key-value pairs) -- ✅ Summary line gives context ("3 running, 1 stopped") -- ✅ Human-debuggable (easy to read in logs) -- ✅ Natural language friendly (Tatlock reformats for user) - -**Bad - Raw JSON:** -```python -def docker_list_containers() -> str: - return '{"containers": [{"name": "nginx", "status": "running", ...}]}' -# ❌ Hard for LLM to reason about -# ❌ Verbose -# ❌ Not user-friendly -``` - -**Bad - Plain Unstructured Text:** -```python -def docker_list_containers() -> str: - return "There are 3 running containers and 1 stopped container." -# ❌ No details -# ❌ Can't extract specific information -# ❌ Not actionable -``` - -### 3.2 Infrastructure Toolset (Logical Clusters) - -**Design Philosophy:** Group by domain intent, not by API endpoint. - -#### Cluster 1: Container Lifecycle - -```python -@register_tool -async def docker_list_containers( - status: Optional[Literal["all", "running", "stopped", "paused"]] = "running" -) -> str: - """ - List Docker containers with status and resource usage. - - Args: - status: Filter by status (all/running/stopped/paused) - - Returns: - Formatted list with container details - - Example: - docker_list_containers("all") - """ - # Calls core-api, formats response - pass - - -@register_tool -async def docker_manage_container( - container: str, - action: Literal["start", "stop", "restart", "pause", "unpause", "remove"] -) -> str: - """ - Manage Docker container state. - - This is a high-level tool for all container lifecycle operations. - - Args: - container: Container name or ID - action: Action to perform - - Returns: - Success message or error details - - Examples: - docker_manage_container("nginx", "restart") - docker_manage_container("old-db", "remove") - - Error Handling: - - Container not found → Try partial name match - - Already in target state → Report current state - - Permission denied → Escalate to user - """ - pass - - -@register_tool -async def docker_inspect_container( - container: str, - details: Literal["summary", "full", "resources"] = "summary" -) -> str: - """ - Get detailed container information. - - Args: - container: Container name or ID - details: Level of detail (summary/full/resources) - - Returns: - Formatted container inspection - - summary: Name, status, uptime, ports, basic resources - - full: Add environment, mounts, network, labels - - resources: Focus on CPU, memory, I/O metrics - """ - pass - - -@register_tool -async def docker_container_logs( - container: str, - lines: int = 50, - since: Optional[str] = None -) -> str: - """ - Retrieve Docker container logs. - - Args: - container: Container name or ID - lines: Number of recent lines (default: 50, max: 500) - since: Time filter (e.g., "1h", "30m", "2024-12-04") - - Returns: - Container logs with timestamps - - Example: - docker_container_logs("nginx", lines=100) - docker_container_logs("core-ai", since="1h") - """ - pass -``` - -**Why 4 Tools Instead of 20+:** -- `docker_list_containers` - Discovery -- `docker_manage_container` - All state changes in one place -- `docker_inspect_container` - Information gathering -- `docker_container_logs` - Diagnostics - -**Benefits:** -- Clear intent for each tool -- LLM easily picks the right one -- Validation logic centralized -- Easy to test and maintain - -#### Cluster 2: Service Management - -```python -@register_tool -async def list_services( - stack: Optional[str] = None -) -> str: - """ - List Docker Compose services. - - Args: - stack: Filter by stack name (optional) - - Returns: - Service list with status and replicas - """ - pass - - -@register_tool -async def manage_service( - service: str, - action: Literal["start", "stop", "restart", "scale"], - replicas: Optional[int] = None -) -> str: - """ - Manage Docker service lifecycle and scaling. - - Args: - service: Service name - action: Action to perform - replicas: Number of replicas (required for scale action) - - Examples: - manage_service("web", "restart") - manage_service("worker", "scale", replicas=3) - """ - pass - - -@register_tool -async def service_status( - service: str -) -> str: - """ - Get detailed service status and health. - - Returns: - Status, replica health, recent events, resource usage - """ - pass -``` - -#### Cluster 3: Monitoring & Resources - -```python -@register_tool -async def system_resources() -> str: - """ - Get overall system resource usage. - - Returns: - CPU, memory, disk, network stats for the host - """ - pass - - -@register_tool -async def container_resources( - container: Optional[str] = None -) -> str: - """ - Get container-specific resource usage. - - Args: - container: Specific container (or all if None) - - Returns: - Real-time CPU, memory, network, I/O stats - """ - pass -``` - -**Total Infrastructure Tools: 9 (instead of 30+)** - ---- - -## 4. Status Messages & UX - -### 4.1 Butler Voice Specification - -**Style:** Modern British butler (Option C: Hybrid) - -**Characteristics:** -- Formal but not archaic -- Clear technical context -- Subtle personality -- Professional efficiency - -### 4.2 Status Message Catalog - -```python -STATUS_MESSAGES = { - # Steward phase - 'steward_consulting': "Consulting the steward on the matter...", - 'steward_complete': "The steward recommends: {domains}", - 'steward_fallback': "The steward is unavailable. Proceeding with standard protocols...", - - # Tool execution - 'tool_calculate': "Calculating... (one prefers precision in mathematics)", - 'tool_web_search': "Making enquiries... (searching the web)", - 'tool_time': "Checking the time, sir...", - - # Infrastructure specific - 'infra_checking': "Inspecting the household systems...", - 'infra_container_manage': "Attending to the {container} container...", - 'infra_services': "Checking service status...", - 'infra_monitoring': "Reviewing system resources...", - - # Expert routing - 'routing_handyman': "This matter requires the handyman's expertise...", - 'routing_secretary': "I shall consult with the secretary...", - - # Completion - 'complete': "Your request is complete, sir.", - 'complete_with_issues': "Your request is complete, though there were some complications: {details}", - - # Errors - 'error_minor': "I must report a minor complication: {error}", - 'error_major': "I do apologise, sir. A significant issue has occurred: {error}", - 'error_need_clarification': "I'm afraid I require clarification: {question}", -} -``` - -### 4.3 Status Message Flow - -``` -Example Query: "Restart nginx and show me the logs" - -Output Stream: -┌─ Consulting the steward on the matter... -└─ The steward recommends: infrastructure - -┌─ Inspecting the household systems... -├─ Attending to the nginx container... -├─ Container restarted successfully -└─ Retrieving logs... - -[LOGS CONTENT STREAMED] - -└─ Your request is complete, sir. -``` - -**Visual Style:** -- Box drawing characters (`┌─`, `├─`, `└─`) for structure -- No emoji (not butler-appropriate) -- Clear phase separation -- Progress indication - ---- - -## 5. Error Handling Strategy - -### 5.1 Error Classification - -```yaml -Transient Errors: - - Network timeouts - - Container temporarily unavailable - - Rate limiting - Action: Retry once with backoff - -Actionable Errors: - - Container not found - - Invalid parameters - - Permission denied - Action: Try alternative, then ask for clarification - -Fatal Errors: - - Service completely down - - Authentication failure - - Malformed configuration - Action: Report immediately, do not retry -``` - -### 5.2 Error Handling Flow - -```python -async def execute_with_retry(tool_func, *args, **kwargs): - """ - Execute tool with automatic error handling. - - Strategy: - 1. Try primary approach - 2. If error → Try alternative once - 3. If still failing → Report and ask for clarification - """ - try: - # Attempt 1: Primary execution - return await tool_func(*args, **kwargs) - - except TransientError as e: - logger.warning(f"Transient error, retrying: {e}") - await asyncio.sleep(1) - - try: - # Attempt 2: Retry - return await tool_func(*args, **kwargs) - except Exception as e2: - return await handle_actionable_error(e2, tool_func, args, kwargs) - - except ActionableError as e: - return await handle_actionable_error(e, tool_func, args, kwargs) - - except FatalError as e: - return f"Critical error: {e}. Unable to complete request." - - -async def handle_actionable_error(error, tool_func, args, kwargs): - """ - Try alternative approaches or ask for clarification. - """ - # Try alternative (e.g., partial name match for container) - alternative = find_alternative(tool_func, args, kwargs, error) - - if alternative: - logger.info(f"Trying alternative: {alternative}") - try: - return await alternative() - except Exception as e: - pass # Fall through to clarification - - # No alternative worked → Ask user - return await ask_for_clarification(error, tool_func, args, kwargs) -``` - -### 5.3 Example Error Scenarios - -**Scenario 1: Container Not Found** -``` -User: "Restart ngnix" # Typo - -Tool: docker_manage_container("ngnix", "restart") -Error: Container "ngnix" not found - -Alternative: Search for similar names -Found: "nginx" (edit distance: 1) - -Status: "I believe you meant 'nginx', sir. Restarting that container..." -Result: Success -``` - -**Scenario 2: Alternative Failed** -``` -User: "Stop the database" - -Tool: docker_manage_container("database", "stop") -Error: Multiple containers match "database": db-postgres, db-mysql, db-redis - -Alternative: None (ambiguous) - -Status: "I require clarification, sir. Multiple database containers found: - - db-postgres - - db-mysql - - db-redis - Which would you like me to stop?" -``` - -**Scenario 3: Fatal Error** -``` -Tool: system_resources() -Error: Connection refused to core-api - -Alternative: None (service down) - -Status: "I must report a critical issue, sir. The infrastructure monitoring - service is unavailable. I cannot retrieve system resources at this time." -``` - ---- - -## 6. MCP Integration Planning - -### 6.1 Current State (Phase 1) - -```python -# Toolsets defined statically -DOMAIN_TOOLSETS = { - 'core': Toolset(calculate, web_search, ...), - 'infrastructure': Toolset(docker_list, docker_manage, ...) -} -``` - -### 6.2 Future State (Phase 2 - MCP Integration) - -```python -# Toolsets loaded from MCP servers -DOMAIN_TOOLSETS = { - 'core': Toolset(calculate, web_search, ...), # Local tools, always available - 'infrastructure': Toolset.from_mcp("http://core-api:8083/mcp"), - 'secretary': Toolset.from_mcp("http://calendar-mcp:8080/mcp"), - 'webdev': Toolset.from_mcp("http://webdev-mcp:8090/mcp") -} -``` - -### 6.3 MCP Server Structure (Future) - -Following [MCP Best Practices](https://modelcontextprotocol.info/docs/best-practices/): - -``` -core-api-mcp/ - ├── server.py # MCP server implementation - ├── tools/ - │ ├── containers.py # Container tools - │ ├── services.py # Service tools - │ └── monitoring.py # Resource tools - └── manifest.json # MCP tool definitions - -Exposes: -- 9 infrastructure tools (logical clusters) -- Authentication: Bearer token -- Rate limit: 100 req/min -- Health check: /health -``` - -### 6.4 Migration Path - -```python -# Phase 1: Static tools (now) -tools = [docker_list_containers, docker_manage_container, ...] -toolset = Toolset(*tools) - -# Phase 1.5: Wrap existing tools in MCP-compatible format -for tool in tools: - tool.__mcp_metadata__ = { - 'domain': 'infrastructure', - 'category': 'containers', - 'requires': 'docker_access' - } - -# Phase 2: Load from MCP server (future) -mcp_client = MCPClient("http://core-api:8083/mcp") -toolset = Toolset.from_mcp(mcp_client, domain='infrastructure') - -# Toolset API remains identical - no code changes needed -``` - -**Key Point:** The toolset/domain architecture we're building now naturally extends to MCP without requiring rewrites. - ---- - -## 7. Implementation Phases - -### Phase 1: Foundation (Week 1) - -**Goals:** -- Text-based steward working reliably -- Dynamic toolset loading -- Basic infrastructure tools (9 tools) -- Butler status messages - -**Deliverables:** -1. `src/agents/text_steward.py` - Text-based steward -2. `src/agents/coordinator_tatlock.py` - Tatlock with dynamic toolsets -3. `src/tools/infrastructure/` - Logical tool clusters -4. `src/ui/status_messages.py` - Butler voice catalog -5. Updated `main.py` - Use new architecture - -**Success Criteria:** -- Steward 100% reliable (no JSON failures) -- Infrastructure tools working -- 2-5s query response time -- Butler-appropriate status messages - -### Phase 2: Expert Agent (Week 2) - -**Goals:** -- Handyman infrastructure expert -- Complex query routing -- Multi-step operations - -**Deliverables:** -1. `src/agents/handyman_agent.py` - Infrastructure expert -2. Routing logic in coordinator -3. Context-aware tool execution - -**Success Criteria:** -- Complex queries handled intelligently -- Multi-step operations work -- Expert provides better results than coordinator alone - -### Phase 3: Future Domains (Week 3+) - -**Goals:** -- Secretary agent (calendar/tasks) -- MCP integration foundations -- CLI interface to Tatlock - -**Deliverables:** -1. Secretary agent implementation -2. MCP metadata in tools -3. CLI client - ---- - -## 8. Open Questions & Decisions Needed - -### 8.1 Tool Granularity - RESOLVED ✅ - -**Decision:** Logical clusters (9 tools for infrastructure) -- Not too granular (20+ tools) -- Not too coarse (1 meta-tool) -- Grouped by intent - -### 8.2 Steward Approach - RESOLVED ✅ - -**Decision:** Text-based with keyword extraction -- No JSON validation -- 100% reliable -- Simple parsing - -### 8.3 Status Messages - RESOLVED ✅ - -**Decision:** Modern British butler (Option C) -- Box drawing characters -- No emoji -- Clear technical context - -### 8.4 Error Handling - RESOLVED ✅ - -**Decision:** Try alternative once, then ask for clarification -- Retry transient errors -- Find alternatives for actionable errors -- Report fatal errors immediately - -### 8.5 Tool Return Format - RESOLVED ✅ - -**Decision:** Formatted text with structured data -- For Tatlock consumption (not end-user directly) -- Machine parseable + human debuggable -- Natural language friendly - -### 8.6 OPEN: Handyman Activation Threshold - -**Question:** When should queries route to Handyman vs Tatlock handling directly? - -**Options:** -A. **Complexity-based** (recommended) - ```python - complexity = analyze_query_complexity(query) - if complexity > 2 and 'infrastructure' in domains: - route_to_handyman() - ``` - -B. **Keyword-based** - ```python - if 'all containers' in query or 'multiple' in query: - route_to_handyman() - ``` - -C. **Always route infrastructure queries** - ```python - if 'infrastructure' in domains: - route_to_handyman() # Always - ``` - -D. **Let Tatlock decide** - ```python - # Give Tatlock a "consult_handyman" tool - # Tatlock calls it when needed - ``` - -**Recommendation needed before implementation.** - -### 8.7 OPEN: Core-API Tool Discovery - -**Question:** Should we use existing OpenAPI discovery or define tools manually? - -**Current State:** -- `src/tools/openapi_discovery.py` exists -- Auto-generates tools from OpenAPI spec -- May have broken endpoints (user mentioned) - -**Options:** -A. **Manual tool definitions** (recommended for Phase 1) - - Full control over tool behavior - - Can handle broken endpoints gracefully - - Better error messages - - Clear documentation - -B. **OpenAPI discovery with filters** - - Use discovery but filter/wrap tools - - Add validation layer - - Handle errors gracefully - -C. **Hybrid approach** - - Core tools: Manual definitions - - Extended tools: OpenAPI discovery - -**Recommendation needed before implementation.** - -### 8.8 OPEN: Steward Caching - -**Question:** Should steward cache analysis for similar queries? - -**Benefits:** -- Faster repeat queries (0ms vs 2-3s) -- Consistent domain selection -- Reduced load on Ollama - -**Concerns:** -- Cache invalidation complexity -- Query similarity detection overhead -- Reduced flexibility - -**Options:** -A. **No caching** - Always analyze (simple, consistent) -B. **Simple caching** - Exact query match only -C. **Semantic caching** - Similar query detection (complex) - -**Recommendation needed before implementation.** - ---- - -## 9. Success Metrics - -### Reliability Metrics -```yaml -Steward Reliability: - Target: 100% (no JSON validation failures) - Fallback: Keyword-based if LLM unavailable - -Tool Execution Success: - Target: >95% (within error handling) - Transient errors: Retry once - Fatal errors: Report immediately - -End-to-End Success: - Target: >95% (user gets answer) - Includes error clarifications -``` - -### Performance Metrics -```yaml -Response Time (P95): - Simple queries: <1s (core tools only) - Infrastructure queries: <3s (steward + tools) - Complex multi-step: <8s (expert agents) - -Steward Analysis: - Target: 2-3s - Fallback: <1s (keyword-only) - -Tool Execution: - Individual tool: <500ms - Multi-tool queries: <2s total -``` - -### User Experience Metrics -```yaml -Status Message Quality: - - Clear phase indication - - Appropriate butler voice - - Technical context when needed - - No emoji - -Error Handling: - - Automatic retry: Silent (just works) - - Alternative found: Brief status - - Clarification needed: Clear question -``` - ---- - -## 10. Next Steps - -1. **Review & Approve Architecture** - - Address open questions (8.6, 8.7, 8.8) - - Get user approval on design - -2. **Finalize Implementation Plan** - - Break into tasks - - Assign priorities - - Set milestones - -3. **Begin Phase 1 Implementation** - - Text-based steward - - Infrastructure tools (manual definitions) - - Coordinator with dynamic toolsets - - Butler status messages - -4. **Test & Iterate** - - Benchmark performance - - Validate reliability - - Refine based on real usage - ---- - -## Appendix A: Code Structure - -``` -services/core-ai/ -├── src/ -│ ├── agents/ -│ │ ├── text_steward.py # Text-based steward (NEW) -│ │ ├── coordinator_tatlock.py # Tatlock coordinator (NEW) -│ │ ├── handyman_agent.py # Infrastructure expert (NEW - Phase 2) -│ │ ├── pydantic_agent.py # Base PydanticAI wrapper (existing) -│ │ └── [deprecated]/ -│ │ ├── steward_agent.py # Old JSON-based steward -│ │ ├── speedy_steward_agent.py -│ │ └── multi_stage_agent.py -│ │ -│ ├── tools/ -│ │ ├── domains.py # Domain definitions (NEW) -│ │ ├── infrastructure/ # Infrastructure tools (NEW) -│ │ │ ├── __init__.py -│ │ │ ├── containers.py # Container lifecycle (4 tools) -│ │ │ ├── services.py # Service management (3 tools) -│ │ │ └── monitoring.py # Resource monitoring (2 tools) -│ │ ├── local.py # Core tools (existing) -│ │ ├── registry.py # Tool registration (existing) -│ │ └── openapi_discovery.py # OpenAPI tools (existing - maybe used) -│ │ -│ ├── ui/ -│ │ └── status_messages.py # Butler voice catalog (NEW) -│ │ -│ └── config.py # Updated configuration -│ -├── main.py # Updated entry point -├── DIAGNOSTIC_REPORT.md # Original diagnosis -└── ARCHITECTURE_DESIGN.md # This document -``` - ---- - -**End of Architecture Design Document** diff --git a/services/OBSOLETE_core-ai/Dockerfile b/services/OBSOLETE_core-ai/Dockerfile deleted file mode 100644 index dc104c9..0000000 --- a/services/OBSOLETE_core-ai/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -# Use a Python base image -FROM python:3.12-slim-bookworm - -# Set working directory -WORKDIR /app - -# Copy requirements file and install dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy the rest of the application code -COPY . . - -# Expose the port the app runs on -EXPOSE 8084 - -# Run the application -CMD ["python", "main.py"] diff --git a/services/OBSOLETE_core-ai/README.md b/services/OBSOLETE_core-ai/README.md deleted file mode 100644 index 9589863..0000000 --- a/services/OBSOLETE_core-ai/README.md +++ /dev/null @@ -1,535 +0,0 @@ -# Core-AI Service - -AI agent service built on PydanticAI for infrastructure management and automation. - -## Architecture - -Core-AI provides two agents with distinct capabilities: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Core-AI Service │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────┐ ┌──────────────────────┐ │ -│ │ PydanticAgent │ │ SimpleLiteLLMAgent │ │ -│ │ (Primary) │ │ (Fallback) │ │ -│ │ │ │ │ │ -│ │ • Tool calling │ │ • No tools │ │ -│ │ • Memory (3-tier)│ │ • Direct LiteLLM │ │ -│ │ • OpenAPI tools │ │ • Minimal overhead │ │ -│ └────────┬─────────┘ └──────────┬───────────┘ │ -│ │ │ │ -│ └──────────┬───────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────┐ │ -│ │ PydanticAI Runtime │ │ -│ │ (Ollama backend) │ │ -│ └──────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### PydanticAgent (Primary) - -**Endpoint:** `/v1/chat/completions` (default) - -Advanced agent using the PydanticAI framework with: - -- **Tool Calling:** Automatic function calling with proper validation -- **Memory System:** 3-tier conversation memory (buffer + Qdrant) -- **Local Tools:** Time, calculations, web search (SearXNG) -- **OpenAPI Tools:** Auto-discovered from core-api infrastructure endpoints -- **Streaming Support:** Server-sent events for real-time responses - -### SimpleLiteLLMAgent (Fallback) - -**Endpoint:** `/v1/chat/simple` - -Lightweight agent for direct LLM interaction: - -- **No Tools:** Pure conversational mode -- **Direct LiteLLM:** Minimal abstraction layer -- **No Memory:** Stateless request/response -- **Low Latency:** Fastest response times - -## Tool System - -### Local Tools - -Built-in utilities available immediately (defined in `src/tools/local.py`): - -- `get_current_time(timezone)` - Timezone-aware time with IANA timezone support -- `get_current_date()` - Current date in ISO format -- `calculate(expression)` - Safe mathematical calculations -- `calculate_date_difference(date1, date2)` - Date arithmetic -- `add_days_to_date(date, days)` - Date manipulation -- `web_search(query, category, max_results)` - SearXNG metasearch integration - -### OpenAPI Discovery - -Dynamically discovers infrastructure tools from core-api's OpenAPI spec: - -- **Auto-Discovery:** Fetches `/openapi.json` on startup -- **REST Mapping:** Converts endpoints to callable functions -- **Prefixed Names:** Tools prefixed with service name (e.g., `core-api__list_containers`) -- **Type Safety:** Preserves parameter types and validation - -**Configuration:** -```bash -OPENAPI_ENABLED=true -OPENAPI_ENDPOINTS=http://core-api:8083/openapi.json -``` - -**List Available Tools:** -```bash -curl http://localhost:8086/v1/tools -``` - -## Memory System - -3-tier multi-tenant memory with per-user data isolation: - -### Tier 1: Conversation Buffer (RAM) -- **Storage:** In-memory per-user buffers -- **Scope:** Recent N turns (configurable, default: 10) -- **Speed:** Instant access -- **Purpose:** Fast context for ongoing conversations - -### Tier 2: Persistent Storage (Qdrant) -- **Storage:** Per-user Qdrant collections -- **Scope:** Complete conversation history -- **Speed:** Fast retrieval by conversation ID -- **Purpose:** Conversation continuity across sessions - -### Tier 3: Semantic Search (Qdrant) -- **Storage:** Same as Tier 2 with vector embeddings -- **Scope:** Cross-conversation semantic search -- **Speed:** Sub-second similarity search -- **Purpose:** Contextual recall across all user conversations - -### Multi-Tenancy - -- **Per-User Collections:** Each user gets isolated Qdrant collection -- **User ID Format:** Sanitized email (`username_at_domain_com`) -- **GDPR Compliance:** Complete user data deletion support -- **Automatic Isolation:** No cross-user data leakage - -**Memory Configuration:** -```bash -MEMORY_ENABLED=true -MEMORY_TIER1_SIZE=10 -QDRANT_URL=http://qdrant:6333 -EMBEDDING_MODEL=nomic-embed-text -DEFAULT_USER_ID=llmdefault_at_schweitz_net -``` - -## API Endpoints - -### Chat Completions - -**POST /v1/chat/completions** (Default: PydanticAI) - -OpenAI-compatible chat endpoint using PydanticAgent. - -**Request:** -```json -{ - "messages": [ - {"role": "user", "content": "What containers are running?"} - ], - "conversation_id": "optional-conversation-id", - "enable_tools": true, - "stream": false -} -``` - -**Response:** -```json -{ - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "I found 5 running containers..." - }, - "finish_reason": "stop" - }], - "model": "pydantic", - "tools_enabled": true, - "tools_count": 12 -} -``` - -**Streaming:** Set `"stream": true` for SSE response - -### Simple Chat - -**POST /v1/chat/simple** - -No-tools fallback endpoint using SimpleLiteLLMAgent. - -Same request/response format as above, but `tools_enabled` will be `false`. - -### List Models - -**GET /v1/models** - -Returns available agent types: -- `pydantic` - PydanticAgent (primary) -- `simple` - SimpleLiteLLMAgent (fallback) - -### List Tools - -**GET /v1/tools** - -Returns all available tools (local + discovered OpenAPI tools). - -### Health Check - -**GET /health** - -Service health status with agent availability. - -## Configuration - -All configuration via environment variables (see `src/config.py`): - -### Core Settings - -| Variable | Default | Description | -|----------|---------|-------------| -| `HOST` | `0.0.0.0` | Server host | -| `PORT` | `8086` | Server port | -| `LOG_LEVEL` | `INFO` | Logging level | - -### Ollama Integration - -| Variable | Default | Description | -|----------|---------|-------------| -| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API URL | -| `AGENT_MODEL` | `mistral-nemo:latest` | Primary model (tool-calling optimized) | -| `OLLAMA_TIMEOUT` | `300` | Request timeout (seconds) | - -### System Prompts - -| Variable | Default | Description | -|----------|---------|-------------| -| `SYSTEM_PROMPT_VARIANT` | `minimal_agent` | Prompt for SimpleLiteLLMAgent | -| `PYDANTIC_SYSTEM_PROMPT_VARIANT` | `pydantic_agent` | Prompt for PydanticAgent | - -### Tool Discovery - -| Variable | Default | Description | -|----------|---------|-------------| -| `OPENAPI_ENABLED` | `true` | Enable OpenAPI tool discovery | -| `OPENAPI_ENDPOINTS` | `http://core-api:8083/openapi.json` | OpenAPI spec URLs (comma-separated) | - -### Memory System - -| Variable | Default | Description | -|----------|---------|-------------| -| `MEMORY_ENABLED` | `true` | Enable conversation memory | -| `MEMORY_TIER1_SIZE` | `10` | Max turns in RAM buffer | -| `QDRANT_URL` | `http://qdrant:6333` | Qdrant vector DB URL | -| `QDRANT_COLLECTION_PREFIX` | `core_ai_user` | Prefix for user collections | -| `EMBEDDING_MODEL` | `nomic-embed-text` | Ollama embedding model | -| `EMBEDDING_DIMENSION` | `768` | Embedding vector size | -| `DEFAULT_USER_ID` | `llmdefault_at_schweitz_net` | Default user (until auth integration) | - -## Quick Start - -### 1. Install Dependencies - -```bash -pip install -r requirements.txt -``` - -### 2. Configure Environment - -Create `.env` file: - -```bash -OLLAMA_BASE_URL=http://ollama:11434 -AGENT_MODEL=mistral-nemo:latest -QDRANT_URL=http://qdrant:6333 -MEMORY_ENABLED=true -OPENAPI_ENABLED=true -``` - -### 3. Start Service - -```bash -python main.py -``` - -Service available at `http://localhost:8086` - -### 4. Test Chat - -```bash -curl -X POST http://localhost:8086/v1/chat/completions \ - -H 'Content-Type: application/json' \ - -d '{ - "messages": [ - {"role": "user", "content": "What time is it in Amsterdam?"} - ], - "enable_tools": true - }' -``` - -The agent will automatically use the `get_current_time` tool. - -## Testing - -### Unit Tests - -```bash -# Run all tests -pytest tests/ -v - -# Run specific test suite -pytest tests/test_ai_flow_quality.py -v - -# Run with coverage -pytest tests/ --cov=src --cov-report=html -``` - -### Integration Tests - -Quality tests for end-to-end AI flows: - -```bash -pytest tests/test_ai_flow_quality.py -v -``` - -See `tests/QUALITY_TESTS.md` for test documentation. - -### Manual Testing - -```bash -# Test PydanticAgent (with tools) -curl -X POST http://localhost:8086/v1/chat/completions \ - -H 'Content-Type: application/json' \ - -d '{"messages": [{"role": "user", "content": "Calculate 123 * 456"}]}' - -# Test SimpleLiteLLMAgent (no tools) -curl -X POST http://localhost:8086/v1/chat/simple \ - -H 'Content-Type: application/json' \ - -d '{"messages": [{"role": "user", "content": "Hello!"}]}' - -# List available tools -curl http://localhost:8086/v1/tools - -# Health check -curl http://localhost:8086/health -``` - -## Docker Deployment - -### Build - -```bash -docker build -t core-ai:latest . -``` - -### Run - -```bash -docker run -d \ - --name core-ai \ - -p 8086:8086 \ - -e OLLAMA_BASE_URL=http://ollama:11434 \ - -e QDRANT_URL=http://qdrant:6333 \ - -e AGENT_MODEL=mistral-nemo:latest \ - --network docker-dataplane \ - core-ai:latest -``` - -### Using Docker Compose - -```bash -docker-compose -f ../../stacks/core-ai.yml up -``` - -## Project Structure - -``` -services/core-ai/ -├── main.py # HTTP server (aiohttp) -├── src/ -│ ├── agents/ -│ │ ├── __init__.py # Agent exports -│ │ ├── pydantic_agent.py # PydanticAgent (primary) -│ │ └── simple.py # SimpleLiteLLMAgent (fallback) -│ ├── memory/ -│ │ ├── manager.py # Multi-tenant memory manager -│ │ ├── tier1_buffer.py # RAM conversation buffer -│ │ ├── qdrant_memory.py # Qdrant persistent + semantic -│ │ ├── base.py # Base memory interfaces -│ │ └── schemas.py # Memory data schemas -│ ├── tools/ -│ │ ├── local.py # Local utility tools -│ │ ├── openapi_discovery.py # OpenAPI tool discovery -│ │ └── registry.py # Tool registration system -│ ├── config.py # Configuration (Pydantic Settings) -│ ├── prompts.py # System prompts -│ └── utils.py # Utilities -├── tests/ -│ ├── test_ai_flow_quality.py # End-to-end AI quality tests -│ └── QUALITY_TESTS.md # Test documentation -├── requirements.txt -├── Dockerfile -└── README.md (this file) -``` - -## Development - -### Adding Local Tools - -Edit `src/tools/local.py`: - -```python -from src.tools.registry import register_tool - -@register_tool -async def my_new_tool(param: str) -> str: - """ - Tool description for LLM. - - Args: - param: Parameter description - - Returns: - Result description - """ - # Implementation - return f"Result: {param}" -``` - -Tool automatically available to PydanticAgent. - -### Adding OpenAPI Sources - -Add endpoints to configuration: - -```bash -OPENAPI_ENDPOINTS=http://core-api:8083/openapi.json,http://automation:8080/openapi.json -``` - -Tools auto-discovered on startup with service prefix: -- `core-api__list_containers` -- `automation__deploy_stack` - -### Modifying System Prompts - -Edit `src/prompts.py`: - -```python -PROMPTS = { - "pydantic_agent": "Your custom PydanticAgent prompt...", - "minimal_agent": "Your custom SimpleLiteLLMAgent prompt..." -} -``` - -Update environment: -```bash -PYDANTIC_SYSTEM_PROMPT_VARIANT=pydantic_agent -``` - -### Memory System Usage - -Memory automatically managed per user: - -```python -from src.memory import get_memory_manager_for_user - -# Get user's memory manager -memory = get_memory_manager_for_user(user_id="user_at_example_com") - -# Memory automatically used by PydanticAgent when conversation_id provided -# See: src/agents/pydantic_agent.py -``` - -## Troubleshooting - -### PydanticAI Not Available - -**Error:** `PydanticAI not available. Install with: pip install pydantic-ai` - -**Solution:** -```bash -pip install pydantic-ai -``` - -### Tools Not Discovered - -**Issue:** `/v1/tools` returns empty list or only local tools - -**Check:** -1. Verify `OPENAPI_ENABLED=true` -2. Check core-api is running: `curl http://core-api:8083/openapi.json` -3. Review logs for discovery errors: `docker logs core-ai` - -### Memory Errors - -**Issue:** Memory operations failing - -**Check:** -1. Verify Qdrant running: `curl http://qdrant:6333/collections` -2. Check embedding model available: `docker exec ollama ollama list | grep nomic-embed-text` -3. Review logs for initialization errors - -### Model Timeouts - -**Issue:** Requests timing out - -**Solutions:** -1. Increase timeout: `OLLAMA_TIMEOUT=600` -2. Use smaller model: `AGENT_MODEL=mistral-tools:7b` -3. Check GPU access: `docker exec ollama nvidia-smi` - -### Tool Calling Failures - -**Issue:** Agent not using tools correctly - -**Check:** -1. Verify model supports tool calling: `mistral-nemo`, `mistral-tools:7b` -2. Test with `enable_tools=false` to isolate issue -3. Review tool logs: Look for `🔧 TOOL CALL:` in logs - -## Model Recommendations - -### For Tool Calling (PydanticAgent) - -- **mistral-nemo:latest** (default) - Best balance -- **mistral-tools:7b** - Faster, less accurate -- **llama3.1:8b** - Good alternative - -### For Simple Chat (SimpleLiteLLMAgent) - -- **gemma2:9b** - Fast conversational -- **llama3.2:3b** - Minimal resources -- Any model works (no tool calling required) - -## Migration Notes - -This service has migrated from: -- **ADK (Agent Development Kit)** → PydanticAI -- **LangChain/LangGraph** → PydanticAI native -- **OllamaNativeAgent** → Removed (superseded by PydanticAgent) - -All references to these frameworks have been removed. The codebase now exclusively uses PydanticAI for agent orchestration. - -## Contributing - -When making changes: -1. Add tests in `tests/` -2. Update docstrings -3. Test with both agents (`/v1/chat/completions` and `/v1/chat/simple`) -4. Verify tool discovery works -5. Test memory persistence - -## License - -Part of the portainer-core project. diff --git a/services/OBSOLETE_core-ai/benchmark_multi_agent.py b/services/OBSOLETE_core-ai/benchmark_multi_agent.py deleted file mode 100644 index f984025..0000000 --- a/services/OBSOLETE_core-ai/benchmark_multi_agent.py +++ /dev/null @@ -1,559 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent AI System Benchmark and Diagnostic Tool - -This script thoroughly tests and profiles the core-ai multi-agent system to identify -performance bottlenecks and diagnose the ~30s tool selection issue. - -Usage: - python benchmark_multi_agent.py [--verbose] [--save-requests] -""" -import asyncio -import time -import json -import logging -import sys -from pathlib import Path -from typing import Dict, List, Any, Optional -from datetime import datetime -import statistics - -# Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent / "src")) - -from src.config import get_settings -from src.agents.pydantic_agent import get_pydantic_agent -from src.agents.steward_agent import get_steward_agent -from src.agents.speedy_steward_agent import get_speedy_steward_agent -from src.agents.multi_stage_agent import create_multi_stage_agent - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - - -class OllamaRequestInterceptor: - """ - Intercepts and logs requests to Ollama for analysis. - - Captures: - - Full request payload - - Response - - Timing - - Token counts (if available) - """ - - def __init__(self): - self.requests: List[Dict[str, Any]] = [] - self.enabled = False - - def enable(self): - """Enable request interception""" - self.enabled = True - logger.info("Ollama request interception ENABLED") - - def disable(self): - """Disable request interception""" - self.enabled = False - logger.info("Ollama request interception DISABLED") - - def log_request(self, endpoint: str, payload: Dict[str, Any], - response: Optional[Dict[str, Any]] = None, - duration_ms: float = 0): - """Log a request to Ollama""" - if not self.enabled: - return - - # Calculate payload size - payload_size = len(json.dumps(payload)) - - record = { - 'timestamp': datetime.utcnow().isoformat(), - 'endpoint': endpoint, - 'payload_size_bytes': payload_size, - 'payload': payload, - 'response': response, - 'duration_ms': duration_ms - } - - self.requests.append(record) - - logger.info(f"Captured Ollama request: {endpoint} ({payload_size} bytes, {duration_ms:.1f}ms)") - - def save_to_file(self, filepath: str): - """Save captured requests to JSON file""" - with open(filepath, 'w') as f: - json.dump(self.requests, f, indent=2) - logger.info(f"Saved {len(self.requests)} requests to {filepath}") - - def get_summary(self) -> Dict[str, Any]: - """Get summary statistics of captured requests""" - if not self.requests: - return {'total_requests': 0} - - payload_sizes = [r['payload_size_bytes'] for r in self.requests] - durations = [r['duration_ms'] for r in self.requests] - - return { - 'total_requests': len(self.requests), - 'total_payload_bytes': sum(payload_sizes), - 'avg_payload_bytes': statistics.mean(payload_sizes), - 'max_payload_bytes': max(payload_sizes), - 'avg_duration_ms': statistics.mean(durations), - 'max_duration_ms': max(durations), - 'p95_duration_ms': sorted(durations)[int(len(durations) * 0.95)] if len(durations) > 1 else durations[0] - } - - -# Global interceptor instance -interceptor = OllamaRequestInterceptor() - - -class BenchmarkResult: - """Container for benchmark results""" - - def __init__(self, name: str): - self.name = name - self.durations: List[float] = [] - self.errors: List[str] = [] - self.stages: Dict[str, List[float]] = {} - - def add_timing(self, duration_ms: float): - """Add a timing measurement""" - self.durations.append(duration_ms) - - def add_error(self, error: str): - """Add an error""" - self.errors.append(error) - - def add_stage_timing(self, stage: str, duration_ms: float): - """Add a stage-specific timing""" - if stage not in self.stages: - self.stages[stage] = [] - self.stages[stage].append(duration_ms) - - def get_stats(self) -> Dict[str, Any]: - """Get statistical summary""" - if not self.durations: - return { - 'name': self.name, - 'status': 'no_data', - 'error_count': len(self.errors) - } - - sorted_durations = sorted(self.durations) - - stats = { - 'name': self.name, - 'runs': len(self.durations), - 'avg_ms': statistics.mean(self.durations), - 'median_ms': statistics.median(self.durations), - 'min_ms': min(self.durations), - 'max_ms': max(self.durations), - 'p95_ms': sorted_durations[int(len(sorted_durations) * 0.95)] if len(sorted_durations) > 1 else sorted_durations[0], - 'p99_ms': sorted_durations[int(len(sorted_durations) * 0.99)] if len(sorted_durations) > 1 else sorted_durations[0], - 'error_count': len(self.errors), - 'success_rate': (len(self.durations) - len(self.errors)) / len(self.durations) if self.durations else 0 - } - - # Add stage breakdowns - if self.stages: - stats['stages'] = {} - for stage, timings in self.stages.items(): - stats['stages'][stage] = { - 'avg_ms': statistics.mean(timings), - 'min_ms': min(timings), - 'max_ms': max(timings) - } - - return stats - - -class MultiAgentBenchmark: - """ - Comprehensive benchmark suite for the multi-agent AI system. - """ - - def __init__(self, save_requests: bool = False): - self.settings = get_settings() - self.save_requests = save_requests - self.results: Dict[str, BenchmarkResult] = {} - - # Test queries representing different scenarios - self.test_queries = [ - { - 'name': 'simple_general_knowledge', - 'query': 'What is the capital of France?', - 'expected_tools': [], - 'category': 'no_tools' - }, - { - 'name': 'simple_calculation', - 'query': 'What is 15 + 27?', - 'expected_tools': ['calculate'], - 'category': 'single_tool' - }, - { - 'name': 'time_query', - 'query': 'What time is it?', - 'expected_tools': ['get_current_time'], - 'category': 'single_tool' - }, - { - 'name': 'web_search_query', - 'query': 'What are the latest developments in AI?', - 'expected_tools': ['web_search'], - 'category': 'single_tool' - }, - { - 'name': 'complex_multi_tool', - 'query': 'Search for the current price of Bitcoin and calculate 50% of it', - 'expected_tools': ['web_search', 'calculate'], - 'category': 'multi_tool' - } - ] - - def _init_result(self, name: str) -> BenchmarkResult: - """Initialize a benchmark result""" - if name not in self.results: - self.results[name] = BenchmarkResult(name) - return self.results[name] - - async def benchmark_tatlock_solo(self, query: str, run_id: int) -> Dict[str, Any]: - """ - Benchmark Tatlock agent alone (no steward). - - This is the baseline - fastest path. - """ - result = self._init_result('tatlock_solo') - - logger.info(f"[Run {run_id}] Testing Tatlock Solo: {query[:50]}...") - - start = time.time() - - try: - # Get Tatlock agent directly - tatlock = get_pydantic_agent(discover_tools=True) - - messages = [{'role': 'user', 'content': query}] - - # Non-streaming for simplicity - response = await tatlock.chat_completion(messages=messages) - - duration_ms = (time.time() - start) * 1000 - result.add_timing(duration_ms) - - logger.info(f"[Run {run_id}] Tatlock Solo: {duration_ms:.1f}ms") - - return { - 'duration_ms': duration_ms, - 'response': response, - 'success': True - } - - except Exception as e: - duration_ms = (time.time() - start) * 1000 - result.add_error(str(e)) - logger.error(f"[Run {run_id}] Tatlock Solo failed: {e}") - - return { - 'duration_ms': duration_ms, - 'error': str(e), - 'success': False - } - - async def benchmark_steward_only(self, query: str, run_id: int, use_speedy: bool = False) -> Dict[str, Any]: - """ - Benchmark steward analysis only (no Tatlock execution). - - This isolates the steward's performance. - """ - variant = 'speedy_steward' if use_speedy else 'regular_steward' - result = self._init_result(f'{variant}_only') - - logger.info(f"[Run {run_id}] Testing {variant.upper()}: {query[:50]}...") - - start = time.time() - - try: - if use_speedy: - steward = get_speedy_steward_agent() - else: - steward = get_steward_agent() - - recommendation = await steward.analyze(query, timeout=None) - - duration_ms = (time.time() - start) * 1000 - result.add_timing(duration_ms) - - logger.info( - f"[Run {run_id}] {variant.upper()}: {duration_ms:.1f}ms - " - f"Recommended {len(recommendation.recommended_tools)} tools: {recommendation.recommended_tools}" - ) - - return { - 'duration_ms': duration_ms, - 'recommendation': { - 'tools': recommendation.recommended_tools, - 'reasoning': recommendation.reasoning, - 'requires_assistance': recommendation.requires_assistance - }, - 'success': True - } - - except Exception as e: - duration_ms = (time.time() - start) * 1000 - result.add_error(str(e)) - logger.error(f"[Run {run_id}] {variant.upper()} failed: {e}") - - return { - 'duration_ms': duration_ms, - 'error': str(e), - 'success': False - } - - async def benchmark_multi_stage(self, query: str, run_id: int, use_speedy: bool = True) -> Dict[str, Any]: - """ - Benchmark full multi-stage flow: Steward → Tatlock. - - This tests the complete orchestration. - """ - variant = 'multi_stage_speedy' if use_speedy else 'multi_stage_regular' - result = self._init_result(variant) - - logger.info(f"[Run {run_id}] Testing {variant.upper()}: {query[:50]}...") - - total_start = time.time() - stages = {} - - try: - # Create multi-stage agent - tatlock = get_pydantic_agent(discover_tools=True) - - # Temporarily set the use_speedy_steward config - original_setting = self.settings.use_speedy_steward - self.settings.use_speedy_steward = use_speedy - - multi_agent = create_multi_stage_agent(tatlock, enable_multi_stage=True) - - messages = [{'role': 'user', 'content': query}] - - # Track stages - stage_start = time.time() - - full_response = "" - steward_duration = None - tatlock_start = None - - async for chunk in multi_agent.chat_with_analysis(messages=messages, stream=True): - if chunk.get('type') == 'status': - phase = chunk.get('phase') - - if phase == 'analysis_complete': - steward_duration = (time.time() - stage_start) * 1000 - stages['steward_analysis'] = steward_duration - tatlock_start = time.time() - - elif chunk.get('type') == 'content': - full_response += chunk.get('content', '') - - elif chunk.get('type') == 'done': - if tatlock_start: - tatlock_duration = (time.time() - tatlock_start) * 1000 - stages['tatlock_execution'] = tatlock_duration - - total_duration_ms = (time.time() - total_start) * 1000 - result.add_timing(total_duration_ms) - - # Add stage timings - for stage, duration in stages.items(): - result.add_stage_timing(stage, duration) - - logger.info( - f"[Run {run_id}] {variant.upper()}: {total_duration_ms:.1f}ms total " - f"(Steward: {stages.get('steward_analysis', 0):.1f}ms, " - f"Tatlock: {stages.get('tatlock_execution', 0):.1f}ms)" - ) - - # Restore original setting - self.settings.use_speedy_steward = original_setting - - return { - 'duration_ms': total_duration_ms, - 'stages': stages, - 'response': full_response, - 'success': True - } - - except Exception as e: - total_duration_ms = (time.time() - total_start) * 1000 - result.add_error(str(e)) - logger.error(f"[Run {run_id}] {variant.upper()} failed: {e}") - - return { - 'duration_ms': total_duration_ms, - 'stages': stages, - 'error': str(e), - 'success': False - } - - async def run_full_benchmark(self, runs_per_test: int = 3): - """ - Run comprehensive benchmark suite. - - Tests all variants across all test queries. - """ - logger.info("=" * 80) - logger.info("MULTI-AGENT AI SYSTEM BENCHMARK") - logger.info("=" * 80) - - if self.save_requests: - interceptor.enable() - - for test_case in self.test_queries: - query = test_case['query'] - logger.info(f"\n{'=' * 80}") - logger.info(f"Test Case: {test_case['name']}") - logger.info(f"Query: {query}") - logger.info(f"Expected Tools: {test_case['expected_tools']}") - logger.info(f"{'=' * 80}\n") - - for run in range(1, runs_per_test + 1): - logger.info(f"\n--- Run {run}/{runs_per_test} ---") - - # Test 1: Tatlock Solo (baseline) - await self.benchmark_tatlock_solo(query, run) - await asyncio.sleep(1) # Cooldown - - # Test 2: Speedy Steward Only - await self.benchmark_steward_only(query, run, use_speedy=True) - await asyncio.sleep(1) - - # Test 3: Regular Steward Only - await self.benchmark_steward_only(query, run, use_speedy=False) - await asyncio.sleep(1) - - # Test 4: Multi-Stage with Speedy Steward - await self.benchmark_multi_stage(query, run, use_speedy=True) - await asyncio.sleep(1) - - # Test 5: Multi-Stage with Regular Steward - await self.benchmark_multi_stage(query, run, use_speedy=False) - await asyncio.sleep(2) # Longer cooldown - - if self.save_requests: - interceptor.disable() - - def generate_report(self) -> Dict[str, Any]: - """ - Generate comprehensive diagnostic report. - """ - report = { - 'timestamp': datetime.utcnow().isoformat() + 'Z', - 'configuration': { - 'agent_model': self.settings.agent_model, - 'multi_stage_enabled': self.settings.multi_stage_enabled, - 'use_speedy_steward': self.settings.use_speedy_steward, - 'analysis_timeout': self.settings.analysis_timeout, - }, - 'results': {} - } - - # Add all benchmark results - for name, result in self.results.items(): - report['results'][name] = result.get_stats() - - # Add Ollama request analysis if available - if self.save_requests and interceptor.requests: - report['ollama_requests'] = interceptor.get_summary() - - return report - - def print_report(self): - """Print human-readable report""" - print("\n" + "=" * 80) - print("BENCHMARK RESULTS") - print("=" * 80) - - for name, result in sorted(self.results.items()): - stats = result.get_stats() - print(f"\n{name}:") - print(f" Runs: {stats.get('runs', 0)}") - print(f" Average: {stats.get('avg_ms', 0):.1f}ms") - print(f" Median: {stats.get('median_ms', 0):.1f}ms") - print(f" Min: {stats.get('min_ms', 0):.1f}ms") - print(f" Max: {stats.get('max_ms', 0):.1f}ms") - print(f" P95: {stats.get('p95_ms', 0):.1f}ms") - print(f" Errors: {stats.get('error_count', 0)}") - - if 'stages' in stats: - print(" Stage Breakdown:") - for stage, stage_stats in stats['stages'].items(): - print(f" {stage}: {stage_stats['avg_ms']:.1f}ms avg") - - if self.save_requests and interceptor.requests: - print("\n" + "=" * 80) - print("OLLAMA REQUEST ANALYSIS") - print("=" * 80) - summary = interceptor.get_summary() - print(f" Total Requests: {summary['total_requests']}") - print(f" Total Payload: {summary['total_payload_bytes']:,} bytes") - print(f" Avg Payload: {summary['avg_payload_bytes']:.1f} bytes") - print(f" Max Payload: {summary['max_payload_bytes']:,} bytes") - print(f" Avg Duration: {summary['avg_duration_ms']:.1f}ms") - - print("\n" + "=" * 80) - - -async def main(): - """Main benchmark execution""" - import argparse - - parser = argparse.ArgumentParser(description='Benchmark multi-agent AI system') - parser.add_argument('--verbose', action='store_true', help='Enable verbose logging') - parser.add_argument('--save-requests', action='store_true', help='Save Ollama requests to file') - parser.add_argument('--runs', type=int, default=3, help='Number of runs per test (default: 3)') - - args = parser.parse_args() - - if args.verbose: - logging.getLogger().setLevel(logging.DEBUG) - - benchmark = MultiAgentBenchmark(save_requests=args.save_requests) - - try: - await benchmark.run_full_benchmark(runs_per_test=args.runs) - - # Generate and save report - report = benchmark.generate_report() - - report_file = f"benchmark_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - with open(report_file, 'w') as f: - json.dump(report, f, indent=2) - - print(f"\n✓ Report saved to: {report_file}") - - # Print summary - benchmark.print_report() - - # Save Ollama requests if captured - if args.save_requests: - requests_file = f"ollama_requests_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - interceptor.save_to_file(requests_file) - print(f"✓ Ollama requests saved to: {requests_file}") - - except KeyboardInterrupt: - print("\n\nBenchmark interrupted by user") - benchmark.print_report() - except Exception as e: - logger.error(f"Benchmark failed: {e}", exc_info=True) - return 1 - - return 0 - - -if __name__ == '__main__': - sys.exit(asyncio.run(main())) diff --git a/services/OBSOLETE_core-ai/diagnostics/__init__.py b/services/OBSOLETE_core-ai/diagnostics/__init__.py deleted file mode 100755 index b9bd167..0000000 --- a/services/OBSOLETE_core-ai/diagnostics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Diagnostic tools for core-ai service""" diff --git a/services/OBSOLETE_core-ai/diagnostics/check_ollama.py b/services/OBSOLETE_core-ai/diagnostics/check_ollama.py deleted file mode 100755 index aff66a7..0000000 --- a/services/OBSOLETE_core-ai/diagnostics/check_ollama.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -""" -Diagnostic tool to check Ollama connectivity and available models. -Run this first to verify the foundation is working. - -Usage: - python diagnostics/check_ollama.py -""" -import asyncio -import httpx -import os -import sys -from pathlib import Path - -# Add parent directory to path to import from src -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.config import get_settings - - -async def check_ollama(): - """Check Ollama connectivity and list available models""" - settings = get_settings() - ollama_url = settings.ollama_base_url - - print("=" * 70) - print("OLLAMA CONNECTIVITY CHECK") - print("=" * 70) - print(f"\n1. Configuration") - print(f" Ollama URL: {ollama_url}") - print(f" Target Model: {settings.agent_model}") - print(f" Timeout: {settings.ollama_timeout}s") - - async with httpx.AsyncClient(timeout=settings.ollama_timeout) as client: - # Test 1: Basic connectivity - print(f"\n2. Testing connectivity to {ollama_url}...") - try: - response = await client.get(f"{ollama_url}/api/tags") - if response.status_code == 200: - print(" ✓ Ollama is reachable") - else: - print(f" ✗ Unexpected status code: {response.status_code}") - print(f" Response: {response.text}") - return False - except httpx.ConnectError as e: - print(f" ✗ Connection failed: {e}") - print(f" → Is Ollama running?") - print(f" → Check docker ps | grep ollama") - print(f" → Verify network connectivity") - return False - except Exception as e: - print(f" ✗ Error: {e}") - return False - - # Test 2: List available models - print(f"\n3. Available models:") - try: - data = response.json() - models = data.get("models", []) - - if not models: - print(" ✗ No models found!") - print(" → Pull a model: docker exec ollama ollama pull gemma2:9b-instruct-q5_K_M") - return False - - target_found = False - for model in models: - model_name = model.get("name", "unknown") - size_gb = model.get("size", 0) / (1024**3) - is_target = "✓" if settings.agent_model in model_name else " " - print(f" {is_target} {model_name} ({size_gb:.2f} GB)") - if settings.agent_model in model_name: - target_found = True - - if not target_found: - print(f"\n ⚠ Target model '{settings.agent_model}' not found!") - print(f" → Pull it: docker exec ollama ollama pull {settings.agent_model}") - return False - else: - print(f"\n ✓ Target model '{settings.agent_model}' is available") - - except Exception as e: - print(f" ✗ Error parsing models: {e}") - return False - - # Test 3: Simple generation test - print(f"\n4. Testing text generation with '{settings.agent_model}'...") - try: - test_payload = { - "model": settings.agent_model, - "prompt": "Say 'Hello, Ollama is working!' and nothing else.", - "stream": False - } - - response = await client.post( - f"{ollama_url}/api/generate", - json=test_payload, - timeout=60.0 - ) - - if response.status_code == 200: - result = response.json() - generated_text = result.get("response", "").strip() - print(f" Response: {generated_text}") - print(" ✓ Text generation successful!") - else: - print(f" ✗ Generation failed with status {response.status_code}") - print(f" Response: {response.text}") - return False - - except httpx.TimeoutException: - print(f" ✗ Request timed out") - print(f" → Model may be loading (first run takes longer)") - print(f" → Try again or increase timeout") - return False - except Exception as e: - print(f" ✗ Error: {e}") - return False - - print("\n" + "=" * 70) - print("✓ ALL CHECKS PASSED - Ollama is ready!") - print("=" * 70) - return True - - -if __name__ == "__main__": - result = asyncio.run(check_ollama()) - sys.exit(0 if result else 1) diff --git a/services/OBSOLETE_core-ai/main.py b/services/OBSOLETE_core-ai/main.py deleted file mode 100644 index f8d276d..0000000 --- a/services/OBSOLETE_core-ai/main.py +++ /dev/null @@ -1,458 +0,0 @@ -import os -import logging -import json -import time -from aiohttp import web -from aiohttp_cors import setup as cors_setup, ResourceOptions -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -# Set up logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(name)s - %(message)s') -logger = logging.getLogger(__name__) - -# Import the agent logic -from src.agents import ( - get_simple_litellm_agent, - get_pydantic_agent, - PYDANTIC_AI_AVAILABLE -) -from src.agents.multi_stage_agent import create_multi_stage_agent -from src.agents.stream_handler import get_stream_handler -from src.tools import get_all_tools -from src.utils import extract_user_id_from_request - -def format_status_message(message: str, phase: str) -> str: - """ - Format status messages with butler-appropriate box-drawing characters. - - Uses Unicode box-drawing characters for visual structure: - - ┌─ for starting messages - - ├─ for continuing messages - - └─ for completing messages - """ - if phase == "analysis": - # Starting steward consultation - return f"┌─ {message}" - elif phase == "analysis_complete": - # Steward consultation complete - return f"└─ {message}" - elif phase == "tool_execution": - # Tool being executed - return f"├─ {message}" - elif phase == "fallback": - # Fallback/warning - return f"└─ {message}" - else: - # Default - return f"├─ {message}" - -async def chat_completions(request): - """ - Handles OpenAI-compatible chat completion requests using PydanticAI. - Default endpoint - uses PydanticAI with tools enabled. - """ - if not PYDANTIC_AI_AVAILABLE: - return web.json_response({ - "error": {"message": "PydanticAI not available. Install with: pip install pydantic-ai"} - }, status=503) - - from src.metrics import get_metrics_collector - metrics = get_metrics_collector() - metrics.increment_concurrent_requests() - start_time = time.time() - agent_type = "pydantic" - success = False - error_msg = None - - try: - data = await request.json() - logger.info(f"[DEFAULT/PYDANTIC_AI] Received chat request") - - # Extract relevant fields from the request - messages = data.get("messages") - model = data.get("model", "Tatlock") - stream = data.get("stream", False) - conversation_id = data.get("conversation_id") - enable_tools = data.get("enable_tools", True) - multi_stage_analysis = data.get("multi_stage_analysis", True) # Enable by default - - # Extract user ID from request - user_id = extract_user_id_from_request(data) - - if not messages: - raise web.HTTPBadRequest(reason="'messages' field is required") - - # Get the agent instance - base_agent = get_pydantic_agent(discover_tools=enable_tools, user_id=user_id) - - # Wrap with multi-stage orchestration if enabled - agent = create_multi_stage_agent(base_agent, enable_multi_stage=multi_stage_analysis) - - # For non-streaming requests, collect the full response - if not stream: - # Collect all content chunks from two-stage agent - full_content = [] - async for chunk in agent.chat_with_analysis( - messages=messages, - conversation_id=conversation_id, - stream=False - ): - if chunk.get("type") == "content": - full_content.append(chunk.get("content", "")) - - response_content = "".join(full_content) - success = True - return web.json_response({ - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": response_content - }, - "finish_reason": "stop" - }], - "model": model, - "usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0 - }, - "tools_enabled": enable_tools, - "multi_stage_analysis": multi_stage_analysis - }) - else: - # Handle streaming response with StreamHandler - response = web.StreamResponse( - status=200, - reason='OK', - headers={ - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - } - ) - await response.prepare(request) - - try: - # Create stream handler instance - handler = get_stream_handler(model_name=model) - - # Process stream through handler - async for chunk in handler.process_stream( - messages=messages, - multi_stage_enabled=multi_stage_analysis - ): - # Stream handler returns pre-formatted messages - await response.write(f"data: {json.dumps(chunk)}\n\n".encode('utf-8')) - - await response.write(b"data: [DONE]\n\n") - success = True - finally: - await response.write_eof() - - return response - - except web.HTTPBadRequest: - raise - except Exception as e: - error_msg = str(e) - logger.exception(f"Error in chat_completions: {e}") - return web.json_response({ - "error": {"message": f"Internal server error: {str(e)}"} - }, status=500) - finally: - duration_ms = (time.time() - start_time) * 1000 - metrics.decrement_concurrent_requests() - metrics.record_request( - agent_type=agent_type, - duration_ms=duration_ms, - success=success, - streaming=data.get("stream", False) if 'data' in locals() else False, - user_id=user_id if 'user_id' in locals() else None, - error=error_msg - ) - -async def chat_simple(request): - """ - Handles chat requests using SimpleLiteLLMAgent (fallback, no tools). - Endpoint: /v1/chat/simple - """ - from src.metrics import get_metrics_collector - metrics = get_metrics_collector() - metrics.increment_concurrent_requests() - start_time = time.time() - agent_type = "simple" - success = False - error_msg = None - - try: - data = await request.json() - logger.info(f"[SIMPLE/LITELLM] Received chat request") - - # Extract relevant fields from the request - messages = data.get("messages") - model = data.get("model", "simple") - stream = data.get("stream", False) - conversation_id = data.get("conversation_id") - user_id = extract_user_id_from_request(data) - - if not messages: - raise web.HTTPBadRequest(reason="'messages' field is required") - - # Get simple agent instance (no tools) - agent = get_simple_litellm_agent() - - # For non-streaming requests - if not stream: - response_content = await agent.chat_completion( - messages=messages, - conversation_id=conversation_id - ) - return web.json_response({ - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": response_content - }, - "finish_reason": "stop" - }], - "model": model, - "usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0 - } - }) - else: - # Streaming response - response = web.StreamResponse( - status=200, - reason='OK', - headers={ - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - } - ) - await response.prepare(request) - - try: - async for chunk in agent.chat(messages=messages, conversation_id=conversation_id, stream=True): - if chunk["type"] == "content": - chunk_data = { - "choices": [{ - "index": 0, - "delta": {"content": chunk["content"]}, - "finish_reason": chunk.get("finish_reason") - }], - "model": model - } - await response.write(f"data: {json.dumps(chunk_data)}\n\n".encode('utf-8')) - - await response.write(b"data: [DONE]\n\n") - success = True - finally: - await response.write_eof() - - return response - - except web.HTTPBadRequest: - raise - except Exception as e: - error_msg = str(e) - logger.exception(f"Error in chat_simple: {e}") - return web.json_response({ - "error": {"message": f"Internal server error: {str(e)}"} - }, status=500) - finally: - duration_ms = (time.time() - start_time) * 1000 - metrics.decrement_concurrent_requests() - metrics.record_request( - agent_type=agent_type, - duration_ms=duration_ms, - success=success, - streaming=data.get("stream", False) if 'data' in locals() else False, - user_id=user_id if 'user_id' in locals() else None, - error=error_msg - ) - -async def list_models(request): - """List available models""" - return web.json_response({ - "object": "list", - "data": [ - { - "id": "Tatlock", - "object": "model", - "created": int(time.time()), - "owned_by": "core-ai", - "description": "PydanticAI agent with full tool support - your British butler assistant" - } - ] - }) - -async def list_tools(request): - """List all available tools""" - try: - tools = get_all_tools() - tool_list = [] - - for name, func in tools.items(): - import inspect - doc = inspect.getdoc(func) or "No description" - tool_list.append({ - "name": name, - "description": doc.split('\n')[0], - "type": "local" if not name.startswith("core-api__") else "openapi" - }) - - return web.json_response({ - "tools": tool_list, - "tools_count": len(tool_list) - }) - - except Exception as e: - logger.exception(f"Error listing tools: {e}") - return web.json_response({ - "error": {"message": str(e)} - }, status=500) - -async def health_check(request): - """Health check endpoint""" - return web.json_response({ - "status": "ok", - "service": "core-ai", - "agents": { - "Tatlock": PYDANTIC_AI_AVAILABLE - }, - "default_agent": "Tatlock" if PYDANTIC_AI_AVAILABLE else None, - "tools_count": len(get_all_tools()) - }) - -async def get_metrics(request): - """ - Get comprehensive performance metrics. - - Returns detailed statistics on agent performance, tool execution, - memory system, and request patterns. - """ - try: - from src.metrics import get_metrics_collector - metrics_collector = get_metrics_collector() - metrics = metrics_collector.get_metrics() - return web.json_response(metrics) - except Exception as e: - logger.exception(f"Error getting metrics: {e}") - return web.json_response({ - "error": {"message": str(e)} - }, status=500) - -async def get_recent_errors(request): - """Get recent request errors.""" - try: - from src.metrics import get_metrics_collector - metrics_collector = get_metrics_collector() - - limit = int(request.query.get('limit', 20)) - errors = metrics_collector.get_recent_errors(limit=limit) - - return web.json_response({ - "errors": errors, - "total": len(errors) - }) - except Exception as e: - logger.exception(f"Error getting errors: {e}") - return web.json_response({ - "error": {"message": str(e)} - }, status=500) - -async def get_tool_failures(request): - """Get recent tool execution failures.""" - try: - from src.metrics import get_metrics_collector - metrics_collector = get_metrics_collector() - - limit = int(request.query.get('limit', 20)) - failures = metrics_collector.get_recent_tool_failures(limit=limit) - - return web.json_response({ - "failures": failures, - "total": len(failures) - }) - except Exception as e: - logger.exception(f"Error getting tool failures: {e}") - return web.json_response({ - "error": {"message": str(e)} - }, status=500) - -async def reset_metrics(request): - """Reset all metrics (admin endpoint).""" - try: - from src.metrics import get_metrics_collector - metrics_collector = get_metrics_collector() - metrics_collector.reset() - - return web.json_response({ - "message": "Metrics reset successfully" - }) - except Exception as e: - logger.exception(f"Error resetting metrics: {e}") - return web.json_response({ - "error": {"message": str(e)} - }, status=500) - -async def setup_routes(app): - # Chat endpoints - app.router.add_post("/chat/completions", chat_completions) # Alias without /v1 - app.router.add_post("/v1/chat/completions", chat_completions) # Default: PydanticAI - app.router.add_post("/v1/chat/simple", chat_simple) # Fallback: Simple agent - - # Models endpoint - app.router.add_get("/models", list_models) - app.router.add_get("/v1/models", list_models) - - # Tools endpoint - app.router.add_get("/tools", list_tools) - app.router.add_get("/v1/tools", list_tools) - - # Health check - app.router.add_get("/health", health_check) - - # Metrics endpoints - app.router.add_get("/metrics", get_metrics) - app.router.add_get("/metrics/errors", get_recent_errors) - app.router.add_get("/metrics/tool-failures", get_tool_failures) - app.router.add_post("/metrics/reset", reset_metrics) - - # Setup CORS - cors = cors_setup(app, defaults={ - "*": ResourceOptions( - allow_credentials=True, - expose_headers="*", - allow_headers="*", - allow_methods="*" - ) - }) - - # Apply CORS to all routes - for route in list(app.router.routes()): - cors.add(route) - -def main(): - app = web.Application() - - # Set up routes - import asyncio - loop = asyncio.get_event_loop() - loop.run_until_complete(setup_routes(app)) - - # Run the application - logger.info("Starting core-ai service on http://0.0.0.0:8086") - web.run_app(app, host='0.0.0.0', port=8086) - -if __name__ == '__main__': - main() diff --git a/services/OBSOLETE_core-ai/pytest.ini b/services/OBSOLETE_core-ai/pytest.ini deleted file mode 100644 index 6157d94..0000000 --- a/services/OBSOLETE_core-ai/pytest.ini +++ /dev/null @@ -1,31 +0,0 @@ -[pytest] -# Pytest configuration for core-ai tests - -# Test discovery patterns -python_files = test_*.py -python_classes = Test* -python_functions = test_* - -# Output options -addopts = - -v - --tb=short - --strict-markers - --color=yes - -# Markers -markers = - asyncio: mark test as async - integration: mark test as integration test (requires external services) - -# Asyncio configuration -asyncio_mode = auto - -# Log configuration -log_cli = true -log_cli_level = INFO -log_cli_format = %(asctime)s [%(levelname)8s] %(message)s -log_cli_date_format = %Y-%m-%d %H:%M:%S - -# Test paths -testpaths = tests diagnostics diff --git a/services/OBSOLETE_core-ai/requirements.txt b/services/OBSOLETE_core-ai/requirements.txt deleted file mode 100644 index 717d03d..0000000 --- a/services/OBSOLETE_core-ai/requirements.txt +++ /dev/null @@ -1,24 +0,0 @@ -# PydanticAI and dependencies (slim to reduce bloat) -pydantic-ai-slim # Minimal library - Ollama uses OpenAI-compatible API -pydantic>=2.10.3 # Let pydantic-ai determine the compatible version -pydantic-settings==2.6.1 -ollama>=0.4.0 # Native Ollama Python library with tool calling support - -# LiteLLM (for simple agent fallback) -litellm==1.80.5 - -# Core dependencies -aiohttp==3.10.1 -aiohttp-cors==0.7.0 -python-dotenv>=1.1.0 -httpx==0.28.1 - -# Memory system -qdrant-client>=1.12.0 # Vector database client - -# Timezone support -pytz>=2025.2 - -# Testing -pytest==8.3.4 -pytest-asyncio==0.24.0 diff --git a/services/OBSOLETE_core-ai/src/__init__.py b/services/OBSOLETE_core-ai/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/OBSOLETE_core-ai/src/agent.py b/services/OBSOLETE_core-ai/src/agent.py deleted file mode 100644 index 321c643..0000000 --- a/services/OBSOLETE_core-ai/src/agent.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Core AI Agent - Direct LiteLLM Chat Completion -This is a diagnostic file to test direct text generation via LiteLLM, bypassing Google ADK. -""" -import os -import logging -from typing import AsyncIterator, Dict, Any, List, Optional -from functools import lru_cache - -# We will directly use litellm here -import litellm - -# Adjusted import paths for the new core-ai service structure -from src.config import get_settings -from src.prompts import get_prompt - -logger = logging.getLogger(__name__) - - -# Simplified Agent for direct LiteLLM interaction -class SimpleLiteLLMAgent: - def __init__(self): - # Enable verbose logging for LiteLLM - litellm.set_verbose = True - logger.info("LiteLLM verbose logging enabled.") - - self.settings = get_settings() - - # Load system prompt - self.system_prompt = get_prompt(self.settings.system_prompt_variant) - logger.info(f"System prompt variant: {self.settings.system_prompt_variant}") - logger.info(f"System prompt: {self.system_prompt[:100]}...") - - # Initialize LiteLLM for Ollama (format: "ollama/model_name") - model_name = self.settings.agent_model - litellm_model = f"ollama/{model_name}" - - logger.info(f"Initializing LiteLLM direct model: {litellm_model}") - logger.info(f"Ollama base URL from settings: {self.settings.ollama_base_url}") - - self.model_params = { - "model": litellm_model, - "api_base": self.settings.ollama_base_url, - "temperature": 0.1, - # No tool definitions passed here to force text generation - } - - async def chat( - self, - messages: List[Dict[str, str]], - conversation_id: str = None, # Not used in this simple mode - stream: bool = True, - prompt_variant: Optional[str] = None # Not used in this simple mode - ) -> AsyncIterator[Dict[str, Any]]: - """ - Processes a chat message using direct LiteLLM completion. - """ - logger.info(f"🚀 Starting direct LiteLLM completion for message: {messages[-1]['content'][:50]}...") - try: - # Prepare messages in LiteLLM format - litellm_messages = [{"role": m["role"], "content": m["content"]} for m in messages] - - # Inject system prompt if not already present - if not litellm_messages or litellm_messages[0]["role"] != "system": - litellm_messages.insert(0, {"role": "system", "content": self.system_prompt}) - logger.info("✓ System prompt injected") - - # Log full message payload for debugging - logger.info(f"📤 Sending {len(litellm_messages)} messages to LiteLLM:") - for i, msg in enumerate(litellm_messages): - content_preview = msg['content'][:100] + "..." if len(msg['content']) > 100 else msg['content'] - logger.info(f" [{i}] {msg['role']}: {content_preview}") - - # Use acompletion for async environments - response = await litellm.acompletion( - messages=litellm_messages, - stream=stream, - **self.model_params - ) - - if stream: - chunk_count = 0 - async for chunk in response: - chunk_count += 1 - content_delta = chunk.choices[0].delta.content if chunk.choices[0].delta.content else "" - finish_reason = chunk.choices[0].finish_reason - if content_delta: - yield {"type": "content", "content": content_delta} - if finish_reason: - logger.info(f"📥 Stream completed after {chunk_count} chunks. Finish reason: {finish_reason}") - yield {"type": "content", "content": "", "finish_reason": finish_reason} - else: - content = response.choices[0].message.content - logger.info(f"📥 Response received: {content[:200]}..." if len(content) > 200 else f"📥 Response received: {content}") - yield {"type": "content", "content": content, "finish_reason": "stop"} - - except Exception as e: - logger.error(f"Error in direct LiteLLM chat: {e}", exc_info=True) - yield { - "type": "error", - "content": f"Sorry, an error occurred during text generation: {str(e)}", - "finish_reason": "stop" - } - - async def chat_completion( - self, - messages: List[Dict[str, str]], - conversation_id: str = None, - prompt_variant: Optional[str] = None - ) -> str: - """ - Get a non-streaming response from the direct LiteLLM chat. - """ - final_content = "" - async for chunk in self.chat(messages=messages, conversation_id=conversation_id, stream=False, prompt_variant=prompt_variant): - if chunk["type"] == "content": - final_content += chunk["content"] - if chunk.get("finish_reason") == "stop": - break - return final_content if final_content else "I couldn't generate a response." - - -@lru_cache() -def get_simple_litellm_agent() -> SimpleLiteLLMAgent: - """Get cached simple LiteLLM agent instance""" - return SimpleLiteLLMAgent() diff --git a/services/OBSOLETE_core-ai/src/agents/__init__.py b/services/OBSOLETE_core-ai/src/agents/__init__.py deleted file mode 100644 index 5dc974a..0000000 --- a/services/OBSOLETE_core-ai/src/agents/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Agent implementations for core-ai service""" - -from .simple import SimpleLiteLLMAgent, get_simple_litellm_agent - -try: - from .pydantic_agent import PydanticAgent, get_pydantic_agent - PYDANTIC_AI_AVAILABLE = True -except ImportError: - PYDANTIC_AI_AVAILABLE = False - PydanticAgent = None - get_pydantic_agent = None - -__all__ = [ - 'SimpleLiteLLMAgent', - 'get_simple_litellm_agent', - 'PydanticAgent', - 'get_pydantic_agent', - 'PYDANTIC_AI_AVAILABLE', -] diff --git a/services/OBSOLETE_core-ai/src/agents/multi_stage_agent.py b/services/OBSOLETE_core-ai/src/agents/multi_stage_agent.py deleted file mode 100644 index fd7f9ff..0000000 --- a/services/OBSOLETE_core-ai/src/agents/multi_stage_agent.py +++ /dev/null @@ -1,407 +0,0 @@ -""" -Multi-Stage Agent Orchestration - Coordinates expert agents for intelligent query processing. - -This module implements a multi-stage agent system with: -1. Steward: Analyzes queries and recommends optimal tools (0-5 tools) -2. Tatlock: Main execution agent using steward's recommendations -3. Future: Additional expert agents can be added to the flow - -Features: -- Silent time/date injection when recommended -- Visible status for web_search tool calls -- Context enrichment with tool recommendations -- Streaming status events separate from content -- Native PydanticAI event streaming for tool call detection -- Fallback to single-stage if steward fails -""" -import logging -import asyncio -from typing import Optional, AsyncGenerator, Dict, Any -from datetime import datetime -import json - -try: - from pydantic_ai import Agent - PYDANTIC_AI_AVAILABLE = True -except ImportError: - PYDANTIC_AI_AVAILABLE = False - Agent = None - -from src.agents.steward_agent import get_steward_agent, ToolRecommendation -from src.config import get_settings - -logger = logging.getLogger(__name__) - - -class MultiStageAgent: - """ - Multi-stage agent orchestrator. - - Coordinates steward analysis with Tatlock execution, providing - intelligent tool selection and transparent status updates. - Designed for extensibility with future expert agents. - """ - - def __init__(self, tatlock_agent: Agent, enable_multi_stage: bool = True): - """ - Initialize multi-stage orchestrator. - - Args: - tatlock_agent: The main Tatlock agent instance - enable_multi_stage: Whether to use multi-stage analysis (default: True) - """ - self.tatlock = tatlock_agent - self.enable_multi_stage = enable_multi_stage - self.settings = get_settings() - - # Get steward instance - if self.enable_multi_stage: - try: - # Use regular steward with mistral-nemo (same model as Tatlock) - steward_model = getattr(self.settings, 'steward_model', 'mistral-nemo:latest') - self.steward = get_steward_agent(model_name=steward_model) - logger.info(f"MultiStageAgent: Steward enabled with {steward_model}") - except Exception as e: - logger.warning(f"Failed to initialize steward, disabling multi-stage: {e}") - self.enable_multi_stage = False - self.steward = None - else: - self.steward = None - logger.info("MultiStageAgent: Multi-stage analysis disabled") - - async def _get_current_datetime(self) -> Dict[str, str]: - """ - Get current date and time for silent injection. - - Returns: - Dict with 'date' and 'time' keys - """ - now = datetime.now() - return { - "date": now.strftime("%A, %B %d, %Y"), - "time": now.strftime("%I:%M %p %Z").strip() - } - - def _enrich_user_message( - self, - original_message: str, - recommendation: ToolRecommendation, - datetime_info: Optional[Dict[str, str]] = None - ) -> str: - """ - Enrich user message with steward's note and optional time/date. - - Args: - original_message: Original user query - recommendation: Steward's recommendation (reasoning contains the note) - datetime_info: Optional current date/time to inject silently - - Returns: - Enriched message with steward's note prepended - """ - enrichment_parts = [] - - # Silent time/date injection (if recommended) - if datetime_info: - enrichment_parts.append( - f"[Current context - Date: {datetime_info['date']}, Time: {datetime_info['time']}]" - ) - - # Prepend steward's note (the full text analysis) - if recommendation.reasoning: - enrichment_parts.append( - f"[Steward's analysis: {recommendation.reasoning}]" - ) - - # Combine enrichments with original message - if enrichment_parts: - enrichment = "\n".join(enrichment_parts) - result = f"{enrichment}\n\n{original_message}" - logger.info(f"_enrich_user_message: enrichment={len(enrichment)} chars, original={len(original_message)} chars, result={len(result)} chars") - return result - - return original_message - - async def _perform_steward_analysis(self, user_query: str) -> Optional[ToolRecommendation]: - """ - Perform steward analysis with error handling. - - Args: - user_query: User's query to analyze - - Returns: - ToolRecommendation if successful, None if failed - """ - try: - timeout = getattr(self.settings, 'analysis_timeout', 3) - recommendation = await self.steward.analyze(user_query, timeout=timeout) - - if recommendation: - logger.info( - f"Steward analysis: {len(recommendation.recommended_tools)} tools recommended" - ) - else: - logger.warning("Steward analysis returned None") - - return recommendation - - except asyncio.TimeoutError: - logger.error("Steward analysis timed out, falling back to single-stage") - return None - - except Exception as e: - logger.error(f"Steward analysis failed: {e}", exc_info=True) - return None - - async def chat_with_analysis( - self, - messages: list[dict], - conversation_id: Optional[str] = None, - stream: bool = True - ) -> AsyncGenerator[Dict[str, Any], None]: - """ - Execute multi-stage chat with steward analysis and streaming. - - Args: - messages: Conversation messages (OpenAI format) - conversation_id: Optional conversation ID for memory - stream: Whether to stream response (default: True) - - Yields: - Dict with 'type' and relevant fields: - - type='status': Status update (tool_name, message, arguments) - - type='content': Response content chunk - - type='done': Completion marker - """ - # Extract user query from last message - user_query = messages[-1].get("content", "") if messages else "" - - if not user_query: - logger.warning("Empty user query in multi-stage analysis") - # Fall through to single-stage - async for chunk in self._single_stage_chat(messages, conversation_id, stream): - yield chunk - return - - # Stage 1: Steward Analysis - if self.enable_multi_stage and self.steward: - # Emit consulting status (butler-appropriate) - yield { - "type": "status", - "message": self.settings.status_consulting, - "phase": "analysis" - } - - # Perform analysis - recommendation = await self._perform_steward_analysis(user_query) - - if recommendation is None: - # Analysis failed, fall back to single-stage - yield { - "type": "status", - "message": self.settings.status_fallback, - "phase": "fallback" - } - async for chunk in self._single_stage_chat(messages, conversation_id, stream): - yield chunk - return - - # Emit completion status based on tool count - if len(recommendation.recommended_tools) == 0: - yield { - "type": "status", - "message": self.settings.status_no_assistance, - "phase": "analysis_complete" - } - else: - # Format tool recommendations in butler voice - tools_str = ", ".join(recommendation.recommended_tools) - yield { - "type": "status", - "message": f"The steward recommends: {tools_str}", - "phase": "analysis_complete", - "recommended_tools": recommendation.recommended_tools, - "reasoning": recommendation.reasoning - } - - # Check if time/date tools recommended (for silent injection) - needs_datetime = any( - tool in recommendation.recommended_tools - for tool in ('get_current_time', 'get_current_date') - ) - - datetime_info = None - if needs_datetime: - datetime_info = await self._get_current_datetime() - logger.info("Injecting current date/time silently") - - # Enrich user message with recommendations and time/date - logger.info(f"user_query before enrichment (length={len(user_query)}): {user_query[:100]}") - enriched_message = self._enrich_user_message( - user_query, - recommendation, - datetime_info - ) - - logger.info(f"Enriched message (length={len(enriched_message)})") - logger.info(f"Enriched (first 200): {enriched_message[:200]}") - logger.info(f"Enriched (last 200): {enriched_message[-200:]}") - - # Replace last message with enriched version - enriched_messages = messages[:-1] + [{ - "role": "user", - "content": enriched_message - }] - else: - # Multi-stage disabled, use original messages - enriched_messages = messages - - # Stage 2: Tatlock Execution with PydanticAI Event Streaming - try: - # Extract user query from enriched messages - user_query = enriched_messages[-1].get("content", "") if enriched_messages else "" - - if not user_query: - logger.error("Empty query for Tatlock execution") - yield {"type": "error", "message": "Empty query"} - return - - # Use PydanticAI's native streaming with event monitoring - # Access the underlying PydanticAI agent directly - from pydantic_ai.messages import ( - ModelResponse, - ToolCallPart, - ToolReturnPart, - ) - - logger.info("Starting Tatlock execution with event streaming") - logger.info(f"Tatlock user_query length={len(user_query)}, first 100 chars: {user_query[:100]}") - - # Track cumulative text for delta calculation - previous_text = "" - - async with self.tatlock.agent.run_stream(user_query) as run: - # Stream all messages (includes tool calls and text) - async for message in run.stream(): - # Handle tool call events - if hasattr(message, 'parts'): - for part in message.parts: - # Check for tool call parts - if isinstance(part, ToolCallPart): - # Emit butler-appropriate status for specific tools - if part.tool_name == "web_search": - # Extract query argument safely - query = "" - if hasattr(part, 'args') and isinstance(part.args, dict): - query = part.args.get("query", "") - - yield { - "type": "status", - "message": f"{self.settings.status_web_search} (searching for: {query})", - "phase": "tool_execution", - "tool_name": part.tool_name - } - logger.info(f"Tool call detected: {part.tool_name}") - elif part.tool_name == "calculate": - yield { - "type": "status", - "message": self.settings.status_calculate, - "phase": "tool_execution", - "tool_name": part.tool_name - } - logger.info(f"Tool call detected: {part.tool_name}") - - # Handle text response - if isinstance(message, ModelResponse): - # Get current cumulative text - current_text = await run.get_text_so_far() - - # Calculate delta (new text only) - delta = current_text[len(previous_text):] - if delta: - yield {"type": "content", "content": delta} - - previous_text = current_text - - # Final finish marker - yield {"type": "content", "content": "", "finish_reason": "stop"} - logger.info("Tatlock execution complete") - - except Exception as e: - logger.error(f"Error in Tatlock execution: {e}", exc_info=True) - yield { - "type": "error", - "message": f"Error generating response: {str(e)}" - } - - # Final completion marker - yield {"type": "done"} - - async def _single_stage_chat( - self, - messages: list[dict], - conversation_id: Optional[str] = None, - stream: bool = True - ) -> AsyncGenerator[Dict[str, Any], None]: - """ - Execute single-stage chat (no steward analysis). - - Args: - messages: Conversation messages - conversation_id: Optional conversation ID - stream: Whether to stream - - Yields: - Chat response chunks - """ - if not stream: - # Non-streaming response - try: - response = await self.tatlock.chat_completion( - messages=messages, - conversation_id=conversation_id - ) - yield { - "type": "content", - "content": response, - "finish_reason": "stop" - } - yield {"type": "done"} - except Exception as e: - logger.error(f"Error in single-stage chat: {e}", exc_info=True) - yield { - "type": "error", - "message": str(e) - } - else: - # Streaming response - try: - async for chunk in self.tatlock.chat( - messages=messages, - conversation_id=conversation_id, - stream=True - ): - yield chunk - except Exception as e: - logger.error(f"Error in single-stage streaming: {e}", exc_info=True) - yield { - "type": "error", - "message": str(e) - } - - -def create_multi_stage_agent( - tatlock_agent: Agent, - enable_multi_stage: bool = True -) -> MultiStageAgent: - """ - Create multi-stage agent orchestrator. - - Args: - tatlock_agent: The main Tatlock agent instance - enable_multi_stage: Whether to enable multi-stage analysis - - Returns: - MultiStageAgent instance - """ - return MultiStageAgent(tatlock_agent, enable_multi_stage=enable_multi_stage) diff --git a/services/OBSOLETE_core-ai/src/agents/pydantic_agent.py b/services/OBSOLETE_core-ai/src/agents/pydantic_agent.py deleted file mode 100644 index 78bd924..0000000 --- a/services/OBSOLETE_core-ai/src/agents/pydantic_agent.py +++ /dev/null @@ -1,287 +0,0 @@ -""" -PydanticAI Agent - Agent using PydanticAI framework with Ollama backend. - -Based on documentation: -- https://ai.pydantic.dev/ -- https://ai.pydantic.dev/models/#ollama -""" -import logging -from typing import AsyncIterator, Dict, Any, List, Optional -from functools import lru_cache - -# PydanticAI imports -try: - from pydantic_ai import Agent - from pydantic_ai.models.openai import OpenAIModel - from pydantic_ai.providers.ollama import OllamaProvider - PYDANTIC_AI_AVAILABLE = True -except ImportError: - PYDANTIC_AI_AVAILABLE = False - Agent = None - OpenAIModel = None - OllamaProvider = None - -from src.config import get_settings -from src.prompts import get_prompt -from src.memory import get_memory_manager_for_user, MessageRole -from src.utils import sanitize_email_to_user_id - -logger = logging.getLogger(__name__) - - -class PydanticAgent: - """ - Agent using PydanticAI framework with Ollama backend. - Supports tool calling with proper response handling. - - Example: - agent = PydanticAgent(tools=[my_tool]) - response = await agent.chat_completion(messages=[{"role": "user", "content": "Hello"}]) - """ - - def __init__(self, tools: List = None, discover_tools: bool = False, user_id: Optional[str] = None, enable_memory: Optional[bool] = None): - if not PYDANTIC_AI_AVAILABLE: - raise ImportError("PydanticAI not available. Install with: pip install pydantic-ai") - - logger.info("PydanticAgent: Initializing PydanticAI agent...") - - self.settings = get_settings() - - # Memory configuration - self.enable_memory = enable_memory if enable_memory is not None else self.settings.memory_enabled - self.user_id = user_id or self.settings.default_user_id - - # Initialize memory manager if enabled - if self.enable_memory: - try: - self.memory_manager = get_memory_manager_for_user( - user_id=self.user_id, - buffer_max_turns=self.settings.memory_tier1_size - ) - logger.info(f"PydanticAgent: Memory enabled for user '{self.user_id}'") - except Exception as e: - logger.warning(f"PydanticAgent: Failed to initialize memory: {e}. Continuing without memory.") - self.enable_memory = False - self.memory_manager = None - else: - self.memory_manager = None - logger.info("PydanticAgent: Memory disabled") - - # Tools can be provided explicitly or discovered - if tools is not None: - # Explicit tools provided - self.tools = tools - logger.info(f"PydanticAgent: Using {len(tools)} explicitly provided tools") - elif discover_tools: - # Discover tools from registry (includes local + core-api) - logger.info("PydanticAgent: Discovering tools from registry...") - from src.tools.registry import get_all_tools - # Get the raw tool functions (not wrapped in ADK FunctionTool) - tool_dict = get_all_tools() - self.tools = list(tool_dict.values()) - logger.info(f"PydanticAgent: Discovered {len(self.tools)} tools") - else: - # No tools - self.tools = [] - logger.info("PydanticAgent: No tools enabled") - - # Load system prompt and inject current date - from datetime import datetime - pydantic_prompt_variant = getattr(self.settings, 'pydantic_system_prompt_variant', 'minimal_agent') - base_prompt = get_prompt(pydantic_prompt_variant) - - # Inject current date for general temporal awareness - current_date = datetime.now().strftime("%A, %B %d, %Y") - self.system_prompt = f"Today is {current_date}.\n\n{base_prompt}" - - logger.info(f"PydanticAgent: System prompt variant: {pydantic_prompt_variant}") - logger.info(f"PydanticAgent: System prompt: {self.system_prompt[:100]}...") - - # Initialize Ollama model via OpenAI-compatible API - model_name = self.settings.agent_model - logger.info(f"PydanticAgent: Initializing Ollama model: {model_name}") - logger.info(f"PydanticAgent: Ollama API base: {self.settings.ollama_base_url}") - logger.info(f"PydanticAgent: Tools registered: {len(self.tools)}") - - # Create Ollama provider with custom base URL - # PydanticAI uses OpenAI-compatible Ollama API which requires /v1 suffix - ollama_base_url_v1 = self.settings.ollama_base_url.rstrip('/') + '/v1' - logger.info(f"PydanticAgent: Using Ollama URL with /v1: {ollama_base_url_v1}") - - ollama_provider = OllamaProvider( - base_url=ollama_base_url_v1, - ) - - self.model = OpenAIModel( - model_name=model_name, - provider=ollama_provider, - ) - - # Create PydanticAI Agent - self.agent = Agent( - model=self.model, - system_prompt=self.system_prompt, - tools=self.tools, - ) - - logger.info("✓ PydanticAgent: Initialization complete") - - async def chat( - self, - messages: List[Dict[str, str]], - conversation_id: str = None, - stream: bool = True, - prompt_variant: Optional[str] = None - ) -> AsyncIterator[Dict[str, Any]]: - """ - Process a chat message using PydanticAI agent. - - Args: - messages: List of message dicts with 'role' and 'content' - conversation_id: Optional conversation ID (not used yet) - stream: Whether to stream responses - prompt_variant: Optional prompt variant (not used, set in __init__) - - Yields: - Dict with 'type' and content. Types: - - {"type": "content", "content": "text chunk"} - - {"type": "content", "content": "", "finish_reason": "stop"} - - {"type": "error", "content": "error message"} - """ - logger.info(f"🚀 PydanticAgent: Starting completion for message: {messages[-1]['content'][:50]}...") - - try: - # Extract user message (PydanticAI handles system prompt internally) - user_messages = [m for m in messages if m["role"] != "system"] - if not user_messages: - raise ValueError("No user messages provided") - - # Use the last user message - user_query = user_messages[-1]["content"] - logger.info(f"📤 PydanticAgent: User query: {user_query[:100]}...") - - # Store user message in memory - if self.enable_memory and conversation_id: - try: - await self.memory_manager.add_turn( - conversation_id=conversation_id, - role=MessageRole.USER, - content=user_query - ) - logger.debug(f"Stored user message in memory for conversation {conversation_id}") - except Exception as e: - logger.warning(f"Failed to store user message in memory: {e}") - - # Run the agent - if stream: - # Streaming response - collect chunks to avoid async context issues - chunks = [] - try: - async with self.agent.run_stream(user_query) as response: - async for chunk in response.stream_text(): - chunks.append(chunk) - except Exception as e: - logger.error(f"Streaming error: {e}") - # Fall back to non-streaming - result = await self.agent.run(user_query) - yield {"type": "content", "content": str(result.output), "finish_reason": "stop"} - return - - # Convert cumulative chunks to deltas (only new content) - previous_text = "" - for chunk in chunks: - # Calculate delta: new text = current chunk - previous text - delta = chunk[len(previous_text):] - if delta: - yield {"type": "content", "content": delta} - previous_text = chunk - - # Final chunk with finish reason - yield {"type": "content", "content": "", "finish_reason": "stop"} - logger.info(f"📥 PydanticAgent: Streaming complete") - - # Store assistant response in memory (streaming) - if self.enable_memory and conversation_id: - try: - await self.memory_manager.add_turn( - conversation_id=conversation_id, - role=MessageRole.ASSISTANT, - content=previous_text - ) - logger.debug(f"Stored assistant response in memory for conversation {conversation_id}") - except Exception as e: - logger.warning(f"Failed to store assistant response in memory: {e}") - - else: - # Non-streaming response - result = await self.agent.run(user_query) - response_text = result.output - logger.info(f"📥 PydanticAgent: Response: {str(response_text)[:100]}...") - yield {"type": "content", "content": str(response_text), "finish_reason": "stop"} - - # Store assistant response in memory (non-streaming) - if self.enable_memory and conversation_id: - try: - await self.memory_manager.add_turn( - conversation_id=conversation_id, - role=MessageRole.ASSISTANT, - content=str(response_text) - ) - logger.debug(f"Stored assistant response in memory for conversation {conversation_id}") - except Exception as e: - logger.warning(f"Failed to store assistant response in memory: {e}") - - except Exception as e: - logger.error(f"PydanticAgent: Error during chat: {e}", exc_info=True) - yield { - "type": "error", - "content": f"Sorry, an error occurred: {str(e)}", - "finish_reason": "error" - } - - async def chat_completion( - self, - messages: List[Dict[str, str]], - conversation_id: str = None, - prompt_variant: Optional[str] = None - ) -> str: - """ - Get a non-streaming response from the PydanticAI agent. - - Args: - messages: List of message dicts - conversation_id: Optional conversation ID - prompt_variant: Optional prompt variant - - Returns: - Complete response string - """ - final_content = "" - async for chunk in self.chat(messages=messages, conversation_id=conversation_id, stream=False, prompt_variant=prompt_variant): - if chunk["type"] == "content": - final_content += chunk["content"] - if chunk.get("finish_reason"): - break - - return final_content if final_content else "I couldn't generate a response." - - -@lru_cache() -def get_pydantic_agent(tools: tuple = None, discover_tools: bool = False, user_id: str = None, enable_memory: bool = None) -> PydanticAgent: - """ - Get cached PydanticAI agent instance. - - Note: tools must be a tuple for caching to work. - Convert list to tuple before calling: get_pydantic_agent(tuple(tools)) - - Args: - tools: Tuple of tool functions (None to use discovery) - discover_tools: Whether to discover tools from registry - user_id: Optional user ID for memory (defaults to config default_user_id) - enable_memory: Optional memory enable flag (defaults to config memory_enabled) - - Returns: - Cached PydanticAgent instance - """ - tools_list = list(tools) if tools is not None else None - return PydanticAgent(tools=tools_list, discover_tools=discover_tools, user_id=user_id, enable_memory=enable_memory) diff --git a/services/OBSOLETE_core-ai/src/agents/simple.py b/services/OBSOLETE_core-ai/src/agents/simple.py deleted file mode 100644 index b5e6658..0000000 --- a/services/OBSOLETE_core-ai/src/agents/simple.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -Simple LiteLLM Agent - Direct text generation via LiteLLM, bypassing Google ADK. -""" -import logging -from typing import AsyncIterator, Dict, Any, List, Optional -from functools import lru_cache - -# We will directly use litellm here -import litellm - -# Adjusted import paths for the new core-ai service structure -from src.config import get_settings -from src.prompts import get_prompt - -logger = logging.getLogger(__name__) - - -# Simplified Agent for direct LiteLLM interaction -class SimpleLiteLLMAgent: - def __init__(self): - # Enable verbose logging for LiteLLM - litellm.set_verbose = True - logger.info("SimpleLiteLLMAgent: LiteLLM verbose logging enabled.") - - self.settings = get_settings() - - # Load system prompt - self.system_prompt = get_prompt(self.settings.system_prompt_variant) - logger.info(f"SimpleLiteLLMAgent: System prompt variant: {self.settings.system_prompt_variant}") - logger.info(f"SimpleLiteLLMAgent: System prompt: {self.system_prompt[:100]}...") - - # Initialize LiteLLM for Ollama (format: "ollama/model_name") - model_name = self.settings.agent_model - litellm_model = f"ollama/{model_name}" - - logger.info(f"SimpleLiteLLMAgent: Initializing LiteLLM direct model: {litellm_model}") - logger.info(f"SimpleLiteLLMAgent: Ollama base URL from settings: {self.settings.ollama_base_url}") - - self.model_params = { - "model": litellm_model, - "api_base": self.settings.ollama_base_url, - "temperature": 0.1, - # No tool definitions passed here to force text generation - } - - async def chat( - self, - messages: List[Dict[str, str]], - conversation_id: str = None, # Not used in this simple mode - stream: bool = True, - prompt_variant: Optional[str] = None # Not used in this simple mode - ) -> AsyncIterator[Dict[str, Any]]: - """ - Processes a chat message using direct LiteLLM completion. - """ - logger.info(f"🚀 SimpleLiteLLMAgent: Starting direct LiteLLM completion for message: {messages[-1]['content'][:50]}...") - try: - # Prepare messages in LiteLLM format - litellm_messages = [{"role": m["role"], "content": m["content"]} for m in messages] - - # Inject system prompt if not already present - if not litellm_messages or litellm_messages[0]["role"] != "system": - litellm_messages.insert(0, {"role": "system", "content": self.system_prompt}) - logger.info("✓ SimpleLiteLLMAgent: System prompt injected") - - # Log full message payload for debugging - logger.info(f"📤 SimpleLiteLLMAgent: Sending {len(litellm_messages)} messages to LiteLLM:") - for i, msg in enumerate(litellm_messages): - content_preview = msg['content'][:100] + "..." if len(msg['content']) > 100 else msg['content'] - logger.info(f" [{i}] {msg['role']}: {content_preview}") - - # Use acompletion for async environments - response = await litellm.acompletion( - messages=litellm_messages, - stream=stream, - **self.model_params - ) - - if stream: - chunk_count = 0 - async for chunk in response: - chunk_count += 1 - content_delta = chunk.choices[0].delta.content if chunk.choices[0].delta.content else "" - finish_reason = chunk.choices[0].finish_reason - if content_delta: - yield {"type": "content", "content": content_delta} - if finish_reason: - logger.info(f"📥 SimpleLiteLLMAgent: Stream completed after {chunk_count} chunks. Finish reason: {finish_reason}") - yield {"type": "content", "content": "", "finish_reason": finish_reason} - else: - content = response.choices[0].message.content - logger.info(f"📥 SimpleLiteLLMAgent: Response received: {content[:200]}..." if len(content) > 200 else f"📥 SimpleLiteLLMAgent: Response received: {content}") - yield {"type": "content", "content": content, "finish_reason": "stop"} - - except Exception as e: - logger.error(f"SimpleLiteLLMAgent: Error in direct LiteLLM chat: {e}", exc_info=True) - yield { - "type": "error", - "content": f"Sorry, an error occurred during text generation: {str(e)}", - "finish_reason": "stop" - } - - async def chat_completion( - self, - messages: List[Dict[str, str]], - conversation_id: str = None, - prompt_variant: Optional[str] = None - ) -> str: - """ - Get a non-streaming response from the direct LiteLLM chat. - """ - final_content = "" - async for chunk in self.chat(messages=messages, conversation_id=conversation_id, stream=False, prompt_variant=prompt_variant): - if chunk["type"] == "content": - final_content += chunk["content"] - if chunk.get("finish_reason") == "stop": - break - return final_content if final_content else "I couldn't generate a response." - - -@lru_cache() -def get_simple_litellm_agent() -> SimpleLiteLLMAgent: - """Get cached simple LiteLLM agent instance""" - return SimpleLiteLLMAgent() diff --git a/services/OBSOLETE_core-ai/src/agents/steward_agent.py b/services/OBSOLETE_core-ai/src/agents/steward_agent.py deleted file mode 100644 index c6ec10c..0000000 --- a/services/OBSOLETE_core-ai/src/agents/steward_agent.py +++ /dev/null @@ -1,270 +0,0 @@ -""" -Steward Analysis Agent - Analyzes queries and recommends optimal tools. - -The steward is a lightweight analysis layer that examines user queries and -recommends 0-5 tools that would be most helpful for answering the query. -Uses the same model as Tatlock (mistral-nemo) for consistency and performance. -""" -import logging -from typing import Optional -from pydantic import BaseModel, Field -from datetime import datetime - -try: - from pydantic_ai import Agent - from pydantic_ai.models.openai import OpenAIModel - from pydantic_ai.providers.ollama import OllamaProvider - PYDANTIC_AI_AVAILABLE = True -except ImportError: - PYDANTIC_AI_AVAILABLE = False - Agent = None - OpenAIModel = None - OllamaProvider = None - -from src.config import get_settings - -logger = logging.getLogger(__name__) - - -class ToolRecommendation(BaseModel): - """Structured tool recommendations from steward analysis.""" - intent: str = Field(description="Brief description of what user wants") - recommended_tools: list[str] = Field( - default_factory=list, - description="List of 0-5 recommended tool names" - ) - reasoning: str = Field(description="Why these tools are optimal (or why none needed)") - requires_assistance: bool = Field( - default=False, - description="False if 0 tools recommended (general knowledge sufficient)" - ) - - -class StewardAgent: - """ - The Steward - Tatlock's analytical assistant for tool selection. - - Analyzes user queries and recommends which tools would be most helpful. - Uses the same model as Tatlock (mistral-nemo) to avoid VRAM overhead. - - Key Rules: - - ALWAYS recommend 'calculate' for any math/arithmetic - - Recommend time/date tools for temporal queries (will be injected silently) - - Recommend web_search for real-time information - - Can recommend 0 tools if general knowledge is sufficient - - Maximum 5 tool recommendations - """ - - def __init__(self, model_name: Optional[str] = None): - """ - Initialize the steward agent. - - Args: - model_name: Model to use (default: from settings, same as Tatlock) - """ - if not PYDANTIC_AI_AVAILABLE: - raise ImportError("PydanticAI not available. Install with: pip install pydantic-ai") - - logger.info("StewardAgent: Initializing steward analysis agent...") - - self.settings = get_settings() - - # Use steward model from config (defaults to same as Tatlock to stay in VRAM) - if model_name is None: - model_name = getattr(self.settings, 'steward_model', self.settings.agent_model) - - # Initialize Ollama model via OpenAI-compatible API - ollama_base_url = self.settings.ollama_base_url - # OpenAI-compatible endpoint requires /v1 suffix - ollama_base_url_v1 = f"{ollama_base_url}/v1" if not ollama_base_url.endswith('/v1') else ollama_base_url - - ollama_provider = OllamaProvider(base_url=ollama_base_url_v1) - - self.model = OpenAIModel( - model_name=model_name, - provider=ollama_provider, - ) - - # Generate system prompt with tool catalog - self.system_prompt = self._generate_steward_prompt() - - # Create PydanticAI agent WITHOUT structured output (text-based for reliability) - self.agent = Agent( - model=self.model, - system_prompt=self.system_prompt, - # NO output_type - returns plain text instead of structured JSON - ) - - logger.info(f"StewardAgent: Initialized with model {model_name}") - - def _generate_steward_prompt(self) -> str: - """ - Generate steward's system prompt with tool catalog. - - Returns: - System prompt string - """ - # Get current date for context - current_date = datetime.now().strftime("%A, %B %d, %Y") - - # Import tool registry to get available tools - from src.tools.registry import get_all_tools - - try: - tools = get_all_tools(include_openapi=False) # Local tools only for now - tool_catalog = self._format_tool_catalog(tools) - except Exception as e: - logger.warning(f"Failed to load tool catalog: {e}") - tool_catalog = "Tool catalog unavailable" - - return f"""You are the steward of Tatlock's household. Your role is to analyze incoming requests and provide a brief, helpful note to Tatlock about how best to address them. - -Today is {current_date}. - -AVAILABLE TOOLS FOR TATLOCK: -{tool_catalog} - -YOUR TASK: -Write a concise analysis note (2-3 sentences) that: -1. Identifies what the user is asking for -2. Recommends which tools would be most helpful (if any) -3. Explains why those tools are appropriate - -CRITICAL TOOL SELECTION RULES: -- ANY math/calculations → recommend 'calculate' (LLMs are unreliable with math) -- Current information (news, markets, weather) → recommend 'web_search' -- Time/date queries → recommend 'get_current_time' or 'get_current_date' -- Infrastructure operations → recommend appropriate infrastructure tools -- General knowledge → no tools needed - -OUTPUT FORMAT (plain text, natural language): -Write your note as you would brief a butler about a household matter. Be concise and specific. - -EXAMPLE: -"Sir's query requires current financial market data and mathematical calculations. I recommend employing web_search to gather the latest information, followed by calculate for the numerical analysis. This combination will ensure accurate, up-to-date results." - -Now write your analysis note for the query.""" - - def _format_tool_catalog(self, tools: dict) -> str: - """ - Format available tools into a readable catalog. - - Args: - tools: Dictionary of tool name -> function - - Returns: - Formatted tool catalog string - """ - import inspect - - catalog_lines = [] - - for name, func in tools.items(): - # Get function signature - try: - sig = inspect.signature(func) - params = [p.name for p in sig.parameters.values()] - signature = f"{name}({', '.join(params)})" - except Exception: - signature = f"{name}(...)" - - # Get docstring (first line only) - doc = inspect.getdoc(func) - if doc: - description = doc.split('\n')[0] - else: - description = "No description" - - catalog_lines.append(f" • {signature}") - catalog_lines.append(f" {description}") - - return "\n".join(catalog_lines) - - async def analyze(self, query: str, timeout: Optional[float] = None) -> ToolRecommendation: - """ - Analyze user query and recommend tools. - - Args: - query: User's query to analyze - timeout: Optional timeout in seconds - - Returns: - ToolRecommendation with recommended tools and reasoning - - Raises: - asyncio.TimeoutError: If analysis exceeds timeout - Exception: If analysis fails - """ - if timeout is None: - timeout = self.settings.analysis_timeout if hasattr(self.settings, 'analysis_timeout') else 3 - - logger.info(f"Steward analyzing query: {query[:100]}...") - - try: - import asyncio - - # Run analysis with timeout (text output, no structured format) - result = await asyncio.wait_for( - self.agent.run(f"User query: {query}"), - timeout=timeout - ) - - # Get text response (steward's note for Tatlock) - steward_note = str(result.output).strip() - - # Strip surrounding quotes if present (avoid JSON escaping issues) - if steward_note.startswith('"') and steward_note.endswith('"'): - steward_note = steward_note[1:-1] - elif steward_note.startswith("'") and steward_note.endswith("'"): - steward_note = steward_note[1:-1] - - logger.info(f"Steward's note: {steward_note[:150]}...") - - # Extract tool names mentioned in the note (for logging and status) - tools_mentioned = [] - note_lower = steward_note.lower() - for tool in ['web_search', 'calculate', 'get_current_time', 'get_current_date', - 'add_days_to_date', 'calculate_date_difference']: - if tool in note_lower: - tools_mentioned.append(tool) - - logger.info(f"Tools mentioned in steward's note: {tools_mentioned}") - - # Return as ToolRecommendation for compatibility - # The reasoning field contains the full steward's note - recommendation = ToolRecommendation( - intent=query[:50] + "..." if len(query) > 50 else query, - recommended_tools=tools_mentioned, # Extracted for display/logging - reasoning=steward_note, # Full note text for Tatlock - requires_assistance=len(tools_mentioned) > 0 or "recommend" in note_lower - ) - - return recommendation - - except asyncio.TimeoutError: - logger.error(f"Steward analysis timed out after {timeout}s") - raise - - except Exception as e: - logger.error(f"Steward analysis failed: {e}", exc_info=True) - raise - - -# Singleton instance -_steward_agent: Optional[StewardAgent] = None - - -def get_steward_agent(model_name: Optional[str] = None) -> StewardAgent: - """ - Get singleton steward agent instance. - - Args: - model_name: Optional model name (uses settings default if not provided) - - Returns: - StewardAgent instance - """ - global _steward_agent - if _steward_agent is None: - _steward_agent = StewardAgent(model_name=model_name) - return _steward_agent diff --git a/services/OBSOLETE_core-ai/src/agents/stream_handler.py b/services/OBSOLETE_core-ai/src/agents/stream_handler.py deleted file mode 100644 index 39aab6e..0000000 --- a/services/OBSOLETE_core-ai/src/agents/stream_handler.py +++ /dev/null @@ -1,464 +0,0 @@ -""" -StreamHandler - Central coordinator for async agent tasks and message streaming. - -Decouples PydanticAI and other components from the output stream, allowing -any component to emit messages independently. Handles OpenAI format conversion, -message history, and proper scope management. -""" -import asyncio -import logging -from typing import Dict, Any, Optional, List, AsyncIterator, Callable -from datetime import datetime -from enum import Enum - -logger = logging.getLogger(__name__) - - -class MessageType(Enum): - """Types of messages that can be emitted.""" - STATUS = "status" - CONTENT = "content" - TOOL_CALL = "tool_call" - TOOL_RESULT = "tool_result" - ERROR = "error" - DONE = "done" - - -class StreamHandler: - """ - Central coordinator for async agent tasks and output streaming. - - The StreamHandler: - - Manages the output stream to the user (OpenAI SSE format) - - Spawns and coordinates async tasks (steward, tatlock, tools, etc.) - - Receives messages from anywhere in the async tree via queue - - Maintains message history and conversation state - - Handles format conversions and scope management - - Architecture: - User Request - ↓ - StreamHandler (coordinator) - ├─▶ Steward (async task) ──▶ emit_message() - ├─▶ Tatlock (async task) ──▶ emit_message() - │ └─▶ Tools ──▶ emit_message() - └─▶ Future: Handyman, Secretary, etc. - ↓ - Formatted Output Stream - """ - - def __init__(self, model_name: str = "Tatlock"): - """ - Initialize stream handler. - - Args: - model_name: Model name for OpenAI format responses - """ - self.model_name = model_name - self.message_queue: asyncio.Queue = asyncio.Queue() - self.message_history: List[Dict[str, str]] = [] - self.tasks: List[asyncio.Task] = [] - self.running = False - - # Track cumulative content for delta calculation - self._content_buffer = "" - - logger.info("StreamHandler: Initialized") - - async def emit_message( - self, - message_type: MessageType, - content: Optional[str] = None, - phase: Optional[str] = None, - tool_name: Optional[str] = None, - **kwargs - ): - """ - Emit a message to the stream from any component. - - This is the main interface for components (steward, tatlock, tools) - to send messages to the user. - - Args: - message_type: Type of message (status, content, error, etc.) - content: Message content - phase: Phase identifier (for status messages) - tool_name: Tool name (for tool-related messages) - **kwargs: Additional message-specific data - - Example: - await handler.emit_message( - MessageType.STATUS, - content="Consulting the steward...", - phase="analysis" - ) - """ - message = { - "type": message_type.value, - "content": content, - "phase": phase, - "tool_name": tool_name, - **kwargs - } - - # Remove None values - message = {k: v for k, v in message.items() if v is not None} - - await self.message_queue.put(message) - logger.debug(f"Message emitted: {message_type.value}") - - async def process_stream( - self, - messages: List[Dict[str, str]], - multi_stage_enabled: bool = True - ) -> AsyncIterator[Dict[str, Any]]: - """ - Main entry point - processes a chat request and streams responses. - - Args: - messages: Conversation history (OpenAI format) - multi_stage_enabled: Whether to use multi-stage analysis - - Yields: - Formatted message dicts ready for SSE output - """ - self.running = True - self.message_history = messages - - try: - # Extract user query - user_query = messages[-1].get("content", "") if messages else "" - if not user_query: - await self.emit_message( - MessageType.ERROR, - content="Empty user query" - ) - return - - # Spawn the main orchestration task - orchestration_task = asyncio.create_task( - self._orchestrate_request(user_query, multi_stage_enabled) - ) - self.tasks.append(orchestration_task) - - # Stream messages as they arrive - async for message in self._stream_messages(): - yield message - - finally: - self.running = False - await self._cleanup_tasks() - - async def _orchestrate_request( - self, - user_query: str, - multi_stage_enabled: bool - ): - """ - Orchestrate the full request flow. - - This runs as an async task and coordinates: - 1. Steward analysis (if enabled) - 2. Tatlock execution - 3. Tool calls - 4. Response generation - - Args: - user_query: User's query - multi_stage_enabled: Whether to use steward - """ - try: - recommendation = None - - # Stage 1: Steward Analysis - if multi_stage_enabled: - await self.emit_message( - MessageType.STATUS, - content="Consulting the steward on the matter...", - phase="analysis" - ) - - # Run steward analysis - recommendation = await self._run_steward_analysis(user_query) - - if recommendation: - tools_str = ", ".join(recommendation.get("tools", [])) - await self.emit_message( - MessageType.STATUS, - content=f"The steward recommends: {tools_str}" if tools_str else "The steward advises no further assistance is required", - phase="analysis_complete" - ) - else: - await self.emit_message( - MessageType.STATUS, - content="The steward is unavailable. Proceeding with standard protocols...", - phase="fallback" - ) - - # Stage 2: Enrich message with steward's note - enriched_query = user_query - if recommendation and recommendation.get("note"): - enriched_query = f"[Steward's analysis: {recommendation['note']}]\n\n{user_query}" - - # Stage 3: Tatlock execution - logger.info(f"StreamHandler: Starting Tatlock execution with query length={len(enriched_query)}") - await self._run_tatlock(enriched_query) - logger.info("StreamHandler: Tatlock execution complete") - - # Signal completion - await self.emit_message(MessageType.DONE) - - except Exception as e: - logger.error(f"Orchestration error: {e}", exc_info=True) - await self.emit_message( - MessageType.ERROR, - content=f"Error: {str(e)}" - ) - await self.emit_message(MessageType.DONE) - - async def _run_steward_analysis(self, query: str) -> Optional[Dict[str, Any]]: - """ - Run steward analysis as an async task. - - Args: - query: User query to analyze - - Returns: - Dict with steward analysis or None if failed - """ - try: - from src.agents.steward_agent import get_steward_agent - from src.config import get_settings - - settings = get_settings() - steward_model = getattr(settings, 'steward_model', 'mistral-nemo:latest') - timeout = getattr(settings, 'analysis_timeout', 10) - - steward = get_steward_agent(model_name=steward_model) - recommendation = await steward.analyze(query, timeout=timeout) - - if recommendation: - return { - "tools": recommendation.recommended_tools, - "note": recommendation.reasoning, - "intent": recommendation.intent - } - - return None - - except Exception as e: - logger.error(f"Steward analysis failed: {e}", exc_info=True) - return None - - async def _run_tatlock(self, query: str): - """ - Run Tatlock agent using PydanticAI's run_stream() - each event reports to StreamHandler. - - Instead of PydanticAI controlling the stream, we iterate through each message - and report to the StreamHandler, giving us full control over output. - - Args: - query: Enriched user query (with steward note if available) - """ - try: - from src.agents.pydantic_agent import get_pydantic_agent - from pydantic_ai.messages import ( - ModelResponse, - ToolCallPart, - ToolReturnPart, - ) - - tatlock = get_pydantic_agent() - - # Track cumulative text for delta calculation - previous_text = "" - - # Use run_stream() with event monitoring - async with tatlock.agent.run_stream(query) as run: - # Stream all messages (includes tool calls and text) - async for message in run.stream(): - # Handle tool call events - if hasattr(message, 'parts'): - for part in message.parts: - # Check for tool call parts - if isinstance(part, ToolCallPart): - await self._handle_tool_call(part) - - # Handle text response - if isinstance(message, ModelResponse): - # Get current cumulative text - current_text = await run.get_text_so_far() - - # Calculate delta (new text only) - delta = current_text[len(previous_text):] - if delta: - await self.emit_message( - MessageType.CONTENT, - content=delta - ) - - previous_text = current_text - - except Exception as e: - logger.error(f"Tatlock execution error: {e}", exc_info=True) - await self.emit_message( - MessageType.ERROR, - content=f"Error generating response: {str(e)}" - ) - - async def _handle_tool_call(self, part): - """ - Handle a tool call part from PydanticAI message stream. - - Args: - part: ToolCallPart from message.parts - """ - tool_name = getattr(part, 'tool_name', 'unknown') - - # Emit butler-appropriate status for specific tools - if tool_name == "web_search": - args = getattr(part, 'args', {}) - query = args.get('query', '') if isinstance(args, dict) else '' - await self.emit_message( - MessageType.STATUS, - content=f"Making enquiries... (searching for: {query})", - phase="tool_execution", - tool_name=tool_name - ) - logger.info(f"Tool call detected: web_search (query: {query})") - elif tool_name == "calculate": - await self.emit_message( - MessageType.STATUS, - content="Calculating... (one prefers precision in mathematics)", - phase="tool_execution", - tool_name=tool_name - ) - logger.info(f"Tool call detected: calculate") - - async def _stream_messages(self) -> AsyncIterator[Dict[str, Any]]: - """ - Stream messages from the queue, formatting for OpenAI SSE. - - Yields: - Formatted message dicts ready for SSE output - """ - while self.running or not self.message_queue.empty(): - try: - # Wait for message with timeout - message = await asyncio.wait_for( - self.message_queue.get(), - timeout=0.1 - ) - - # Format based on message type - formatted = self._format_message(message) - if formatted: - yield formatted - - # Check if done - if message.get("type") == "done": - break - - except asyncio.TimeoutError: - continue - except Exception as e: - logger.error(f"Error streaming message: {e}", exc_info=True) - continue - - def _format_message(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Format a message for OpenAI SSE output. - - Args: - message: Internal message dict - - Returns: - Formatted message or None - """ - msg_type = message.get("type") - - if msg_type == "status": - # Format status with box-drawing characters - phase = message.get("phase", "") - content = message.get("content", "") - - # Apply box-drawing formatting - if phase == "analysis": - formatted_content = f"┌─ {content}" - elif phase == "analysis_complete": - formatted_content = f"└─ {content}" - elif phase in ("tool_execution", "fallback"): - formatted_content = f"├─ {content}" - else: - formatted_content = f"├─ {content}" - - return { - "type": "status", - "message": formatted_content, - "phase": phase, - "tool_name": message.get("tool_name"), - "arguments": message.get("arguments") - } - - elif msg_type == "content": - return { - "type": "content", - "choices": [{ - "index": 0, - "delta": {"content": message.get("content", "")}, - "finish_reason": message.get("finish_reason") - }], - "model": self.model_name - } - - elif msg_type == "error": - return { - "type": "error", - "message": message.get("content", "Unknown error") - } - - elif msg_type == "done": - return { - "type": "content", - "choices": [{ - "index": 0, - "delta": {"content": ""}, - "finish_reason": "stop" - }], - "model": self.model_name - } - - return None - - async def _cleanup_tasks(self): - """Clean up any running async tasks.""" - for task in self.tasks: - if not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - self.tasks.clear() - logger.debug("StreamHandler: Tasks cleaned up") - - -# Singleton instance -_stream_handler: Optional[StreamHandler] = None - - -def get_stream_handler(model_name: str = "Tatlock") -> StreamHandler: - """ - Get or create stream handler instance. - - Args: - model_name: Model name for responses - - Returns: - StreamHandler instance - """ - global _stream_handler - if _stream_handler is None: - _stream_handler = StreamHandler(model_name=model_name) - return _stream_handler diff --git a/services/OBSOLETE_core-ai/src/agents/text_steward.py b/services/OBSOLETE_core-ai/src/agents/text_steward.py deleted file mode 100644 index 1c3d099..0000000 --- a/services/OBSOLETE_core-ai/src/agents/text_steward.py +++ /dev/null @@ -1,288 +0,0 @@ -""" -Text-Based Steward Agent - Reliable domain analysis without JSON validation. - -The steward analyzes queries and recommends which domains (toolsets) are relevant. -Returns plain text instead of structured JSON to avoid validation failures. - -Architecture: -- Input: User query -- Process: LLM analysis (mistral-nemo, 2-3s) -- Output: Plain text with domain recommendations -- Parsing: Simple keyword extraction (no JSON) - -Reliability: 100% (no JSON validation failures) -""" -import logging -import httpx -import re -from typing import List, Dict, Optional -from dataclasses import dataclass - -logger = logging.getLogger(__name__) - - -@dataclass -class StewardAnalysis: - """ - Result of steward's domain analysis. - - Attributes: - domains: List of recommended domain names (e.g., ['core', 'infrastructure']) - reasoning: Steward's plain text explanation - query: Original user query - confidence: Confidence score (0.0-1.0, based on keyword matches) - """ - domains: List[str] - reasoning: str - query: str - confidence: float = 1.0 - - -class TextBasedSteward: - """ - Reliable steward using plain text output. - - No JSON validation = No failures = Happy household. - - Example: - steward = TextBasedSteward() - analysis = await steward.analyze("Check if nginx is running") - # Returns: StewardAnalysis(domains=['core', 'infrastructure'], reasoning="...", ...) - """ - - # Domain keyword mapping - # Format: domain_name → list of keywords that indicate this domain - DOMAIN_KEYWORDS = { - 'infrastructure': [ - 'infrastructure', 'docker', 'container', 'service', - 'nginx', 'ollama', 'core-ai', 'core-api', - 'restart', 'status', 'logs', 'system', - 'running', 'stopped', 'deployed', 'monitoring', - 'memory', 'cpu', 'resource', 'health' - ], - 'secretary': [ - 'secretary', 'calendar', 'remind', 'appointment', - 'schedule', 'meeting', 'task', 'todo', 'event', - 'tomorrow', 'next week', 'later today' - ], - 'home_automation': [ - 'home', 'automation', 'light', 'climate', - 'temperature', 'thermostat', 'scene', - 'turn on', 'turn off', 'dim', 'brighten' - ], - 'webdev': [ - 'webdev', 'screenshot', 'browser', 'html', - 'webpage', 'css', 'javascript', 'render' - ] - } - - def __init__( - self, - model: str = "mistral-nemo:latest", - ollama_url: str = "http://ollama:11434", - timeout: float = 5.0 - ): - """ - Initialize text-based steward. - - Args: - model: Ollama model to use - ollama_url: Ollama API base URL - timeout: Request timeout in seconds - """ - self.model = model - self.ollama_url = ollama_url - self.timeout = timeout - - logger.info(f"TextBasedSteward: Initialized with {model}") - - async def analyze(self, query: str) -> StewardAnalysis: - """ - Analyze query and recommend relevant domains. - - This is the main entry point. Returns domains to load for the query. - - Args: - query: User's query to analyze - - Returns: - StewardAnalysis with recommended domains and reasoning - - Example: - >>> analysis = await steward.analyze("Turn off the living room lights") - >>> analysis.domains - ['core', 'home_automation'] - """ - logger.info(f"Steward analyzing: {query[:100]}...") - - try: - # Generate analysis text (2-3s) - reasoning = await self._generate_analysis(query) - - # Extract domains from text (< 1ms) - domains = self._extract_domains(reasoning, query) - - # Calculate confidence - confidence = self._calculate_confidence(domains, reasoning) - - logger.info(f"Steward recommends: {domains} (confidence: {confidence:.2f})") - - return StewardAnalysis( - domains=domains, - reasoning=reasoning, - query=query, - confidence=confidence - ) - - except Exception as e: - logger.error(f"Steward analysis failed: {e}", exc_info=True) - - # Fallback: Use keyword-based analysis only - logger.info("Falling back to keyword-based domain selection") - domains = self._keyword_fallback(query) - - return StewardAnalysis( - domains=domains, - reasoning=f"Steward unavailable. Using keyword analysis: {', '.join(domains)}", - query=query, - confidence=0.5 - ) - - async def _generate_analysis(self, query: str) -> str: - """ - Generate plain text analysis using LLM. - - Returns plain text (not JSON) to avoid validation failures. - """ - prompt = self._build_analysis_prompt(query) - - # Call Ollama API directly (not via PydanticAI) - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.post( - f"{self.ollama_url}/api/generate", - json={ - "model": self.model, - "prompt": prompt, - "stream": False, - "options": { - "temperature": 0.3, # Lower = more consistent - "top_p": 0.9 - } - } - ) - - response.raise_for_status() - result = response.json() - - return result["response"].strip() - - def _build_analysis_prompt(self, query: str) -> str: - """ - Build the steward's analysis prompt. - - Asks for plain text domain recommendations (no JSON). - """ - return f"""You are the steward of a British household, assisting with domain selection for the butler (Tatlock). - -Your task: Analyze the query and suggest which domains are relevant. - -AVAILABLE DOMAINS: -• core - Essential utilities (mathematics, web search, time/date queries) -• infrastructure - System management (Docker containers, services, monitoring, logs) -• secretary - Calendar, reminders, appointments, task management (not yet available) -• home_automation - Smart home control (lights, climate, scenes) (not yet available) -• webdev - Web development tools (screenshots, browser automation) (not yet available) - -GUIDELINES: -- Mathematical calculations → core -- Web searches, current events → core -- Time/date queries → core -- Docker, containers, services, logs → infrastructure -- Calendar, reminders, appointments → secretary -- Smart home, lights, temperature → home_automation -- Multiple domains may be relevant for complex queries - -USER QUERY: {query} - -Respond with 1-2 sentences suggesting which domains are relevant and why. -Use domain names in your response (e.g., "This involves infrastructure and core domains"). -Plain text only - no JSON, no special formatting.""" - - def _extract_domains(self, reasoning: str, query: str) -> List[str]: - """ - Extract domain names from steward's text response. - - Uses both the reasoning text and the original query for robustness. - - Args: - reasoning: Steward's text response - query: Original user query - - Returns: - List of domain names - """ - text_lower = (reasoning + " " + query).lower() - - domains = set() - - # Check each domain's keywords - for domain, keywords in self.DOMAIN_KEYWORDS.items(): - if any(keyword in text_lower for keyword in keywords): - domains.add(domain) - - # Always include 'core' domain (has essential tools) - domains.add('core') - - return sorted(list(domains)) - - def _keyword_fallback(self, query: str) -> List[str]: - """ - Fallback domain selection using only keywords (no LLM). - - Used when steward LLM is unavailable. - """ - # Use same extraction logic with empty reasoning - return self._extract_domains("", query) - - def _calculate_confidence(self, domains: List[str], reasoning: str) -> float: - """ - Calculate confidence score based on domain detection. - - Higher confidence = more explicit domain mentions in reasoning. - """ - reasoning_lower = reasoning.lower() - - # Count explicit domain mentions - mentions = 0 - for domain in domains: - if domain != 'core' and domain in reasoning_lower: - mentions += 1 - - # Base confidence on mention rate - non_core_domains = [d for d in domains if d != 'core'] - if non_core_domains: - mention_rate = mentions / len(non_core_domains) - else: - mention_rate = 1.0 # Only core domain - - return min(mention_rate, 1.0) - - -# Singleton instance -_steward_instance: Optional[TextBasedSteward] = None - - -def get_text_steward() -> TextBasedSteward: - """ - Get or create singleton text-based steward instance. - - Returns: - TextBasedSteward instance - """ - global _steward_instance - - if _steward_instance is None: - _steward_instance = TextBasedSteward() - logger.info("Created TextBasedSteward singleton") - - return _steward_instance diff --git a/services/OBSOLETE_core-ai/src/agents/tool_events.py b/services/OBSOLETE_core-ai/src/agents/tool_events.py deleted file mode 100644 index 088f447..0000000 --- a/services/OBSOLETE_core-ai/src/agents/tool_events.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -Tool Event Emitter - Allows tools to emit events during execution. - -Provides a lightweight event system for tools to signal when they're being called, -enabling real-time status updates during streaming responses. -""" -import asyncio -import time -from typing import Optional -from dataclasses import dataclass -import logging - -logger = logging.getLogger(__name__) - - -@dataclass -class ToolCallEvent: - """Event emitted when a tool is called.""" - tool_name: str - arguments: dict - timestamp: float - - -class ToolEventEmitter: - """ - Singleton event emitter for tool calls. - - Tools emit events when they execute, and the streaming system - can listen for these events to provide real-time status updates. - """ - - def __init__(self): - self._queue = asyncio.Queue() - self._enabled = True - - def emit(self, tool_name: str, arguments: dict): - """ - Emit a tool call event. - - Args: - tool_name: Name of the tool being called - arguments: Arguments passed to the tool - """ - if not self._enabled: - return - - event = ToolCallEvent( - tool_name=tool_name, - arguments=arguments, - timestamp=time.time() - ) - - # Non-blocking emit - don't wait for consumers - try: - self._queue.put_nowait(event) - logger.debug(f"Tool event emitted: {tool_name}") - except asyncio.QueueFull: - logger.warning(f"Tool event queue full, dropping event for {tool_name}") - - def has_events(self) -> bool: - """Check if there are pending events.""" - return not self._queue.empty() - - async def get_event(self, timeout: float = 0.01) -> Optional[ToolCallEvent]: - """ - Get next tool event with timeout. - - Args: - timeout: Maximum time to wait for event (seconds) - - Returns: - ToolCallEvent if available, None if timeout - """ - try: - event = await asyncio.wait_for(self._queue.get(), timeout=timeout) - return event - except asyncio.TimeoutError: - return None - - def clear(self): - """Clear all pending events.""" - while not self._queue.empty(): - try: - self._queue.get_nowait() - except asyncio.QueueEmpty: - break - - def enable(self): - """Enable event emission.""" - self._enabled = True - logger.info("Tool event emission enabled") - - def disable(self): - """Disable event emission.""" - self._enabled = False - logger.info("Tool event emission disabled") - - -# Global singleton emitter -_emitter: Optional[ToolEventEmitter] = None - - -def get_tool_emitter() -> ToolEventEmitter: - """Get the global tool event emitter instance.""" - global _emitter - if _emitter is None: - _emitter = ToolEventEmitter() - return _emitter diff --git a/services/OBSOLETE_core-ai/src/agents/two_stage_agent.py b/services/OBSOLETE_core-ai/src/agents/two_stage_agent.py deleted file mode 100644 index 5149d28..0000000 --- a/services/OBSOLETE_core-ai/src/agents/two_stage_agent.py +++ /dev/null @@ -1,389 +0,0 @@ -""" -Two-Stage Agent Orchestration - Coordinates steward analysis and Tatlock execution. - -This module implements the two-stage tool selection system: -1. Stage 1 (Steward): Analyze query and recommend 0-5 optimal tools -2. Stage 2 (Tatlock): Answer using tool recommendations as guidance - -Features: -- Silent time/date injection when recommended -- Visible status for web_search tool calls -- Context enrichment with tool recommendations -- Streaming status events separate from content -- Fallback to single-stage if steward fails -""" -import logging -import asyncio -from typing import Optional, AsyncGenerator, Dict, Any -from datetime import datetime -import json - -try: - from pydantic_ai import Agent - PYDANTIC_AI_AVAILABLE = True -except ImportError: - PYDANTIC_AI_AVAILABLE = False - Agent = None - -from src.agents.steward_agent import get_steward_agent, ToolRecommendation -from src.agents.tool_events import get_tool_emitter -from src.config import get_settings - -logger = logging.getLogger(__name__) - - -class TwoStageAgent: - """ - Two-stage agent orchestrator. - - Coordinates steward analysis with Tatlock execution, providing - intelligent tool selection and transparent status updates. - """ - - def __init__(self, tatlock_agent: Agent, enable_two_stage: bool = True): - """ - Initialize two-stage orchestrator. - - Args: - tatlock_agent: The main Tatlock agent instance - enable_two_stage: Whether to use two-stage analysis (default: True) - """ - self.tatlock = tatlock_agent - self.enable_two_stage = enable_two_stage - self.settings = get_settings() - - # Get steward instance (uses same model as Tatlock) - if self.enable_two_stage: - try: - self.steward = get_steward_agent() - logger.info("TwoStageAgent: Steward enabled for tool analysis") - except Exception as e: - logger.warning(f"Failed to initialize steward, disabling two-stage: {e}") - self.enable_two_stage = False - self.steward = None - else: - self.steward = None - logger.info("TwoStageAgent: Two-stage analysis disabled") - - async def _get_current_datetime(self) -> Dict[str, str]: - """ - Get current date and time for silent injection. - - Returns: - Dict with 'date' and 'time' keys - """ - now = datetime.now() - return { - "date": now.strftime("%A, %B %d, %Y"), - "time": now.strftime("%I:%M %p %Z").strip() - } - - def _enrich_user_message( - self, - original_message: str, - recommendation: ToolRecommendation, - datetime_info: Optional[Dict[str, str]] = None - ) -> str: - """ - Enrich user message with steward recommendations and optional time/date. - - Args: - original_message: Original user query - recommendation: Steward's tool recommendations - datetime_info: Optional current date/time to inject silently - - Returns: - Enriched message with injected context - """ - enrichment_parts = [] - - # Silent time/date injection (if recommended) - if datetime_info: - enrichment_parts.append( - f"[Current context - Date: {datetime_info['date']}, Time: {datetime_info['time']}]" - ) - - # Tool recommendations (if any, excluding time/date tools) - visible_tools = [ - tool for tool in recommendation.recommended_tools - if tool not in ('get_current_time', 'get_current_date') - ] - - if visible_tools: - tools_str = ", ".join(visible_tools) - enrichment_parts.append( - f"[Steward analysis: Recommended tools for this query: {tools_str}. " - f"Reasoning: {recommendation.reasoning}]" - ) - - # Combine enrichments with original message - if enrichment_parts: - enrichment = "\n".join(enrichment_parts) - return f"{enrichment}\n\n{original_message}" - - return original_message - - async def _perform_steward_analysis(self, user_query: str) -> Optional[ToolRecommendation]: - """ - Perform steward analysis with error handling. - - Args: - user_query: User's query to analyze - - Returns: - ToolRecommendation if successful, None if failed - """ - try: - timeout = getattr(self.settings, 'analysis_timeout', 3) - recommendation = await self.steward.analyze(user_query, timeout=timeout) - - logger.info( - f"Steward analysis: {len(recommendation.recommended_tools)} tools recommended" - ) - - return recommendation - - except asyncio.TimeoutError: - logger.error("Steward analysis timed out, falling back to single-stage") - return None - - except Exception as e: - logger.error(f"Steward analysis failed: {e}", exc_info=True) - return None - - async def chat_with_analysis( - self, - messages: list[dict], - conversation_id: Optional[str] = None, - stream: bool = True - ) -> AsyncGenerator[Dict[str, Any], None]: - """ - Execute two-stage chat with steward analysis and streaming. - - Args: - messages: Conversation messages (OpenAI format) - conversation_id: Optional conversation ID for memory - stream: Whether to stream response (default: True) - - Yields: - Dict with 'type' and relevant fields: - - type='status': Status update (tool_name, message, arguments) - - type='content': Response content chunk - - type='done': Completion marker - """ - # Extract user query from last message - user_query = messages[-1].get("content", "") if messages else "" - - if not user_query: - logger.warning("Empty user query in two-stage analysis") - # Fall through to single-stage - async for chunk in self._single_stage_chat(messages, conversation_id, stream): - yield chunk - return - - # Stage 1: Steward Analysis - if self.enable_two_stage and self.steward: - # Emit consulting status - yield { - "type": "status", - "message": "🤵 Consulting the steward...", - "phase": "analysis" - } - - # Perform analysis - recommendation = await self._perform_steward_analysis(user_query) - - if recommendation is None: - # Analysis failed, fall back to single-stage - yield { - "type": "status", - "message": "⚠️ Steward unavailable, proceeding without analysis", - "phase": "fallback" - } - async for chunk in self._single_stage_chat(messages, conversation_id, stream): - yield chunk - return - - # Emit completion status based on tool count - if len(recommendation.recommended_tools) == 0: - yield { - "type": "status", - "message": "✓ No further assistance required - answering from general knowledge", - "phase": "analysis_complete" - } - else: - yield { - "type": "status", - "message": "✓ Steward consultation complete", - "phase": "analysis_complete", - "recommended_tools": recommendation.recommended_tools, - "reasoning": recommendation.reasoning - } - - # Check if time/date tools recommended (for silent injection) - needs_datetime = any( - tool in recommendation.recommended_tools - for tool in ('get_current_time', 'get_current_date') - ) - - datetime_info = None - if needs_datetime: - datetime_info = await self._get_current_datetime() - logger.info("Injecting current date/time silently") - - # Enrich user message with recommendations and time/date - enriched_message = self._enrich_user_message( - user_query, - recommendation, - datetime_info - ) - - # Replace last message with enriched version - enriched_messages = messages[:-1] + [{ - "role": "user", - "content": enriched_message - }] - else: - # Two-stage disabled, use original messages - enriched_messages = messages - - # Stage 2: Tatlock Execution with PydanticAI Event Streaming - try: - # Extract user query from enriched messages - user_query = enriched_messages[-1].get("content", "") if enriched_messages else "" - - if not user_query: - logger.error("Empty query for Tatlock execution") - yield {"type": "error", "message": "Empty query"} - return - - # Use PydanticAI's native streaming with event monitoring - # Access the underlying PydanticAI agent directly - from pydantic_ai.messages import ( - ModelTextResponse, - ToolCallPart, - ToolReturnPart, - ) - - logger.info("Starting Tatlock execution with event streaming") - - # Track cumulative text for delta calculation - previous_text = "" - - async with self.tatlock.agent.run_stream(user_query) as run: - # Stream all messages (includes tool calls and text) - async for message in run.stream(): - # Handle tool call events - if hasattr(message, 'parts'): - for part in message.parts: - # Check for tool call parts - if isinstance(part, ToolCallPart): - # Only emit status for web_search - if part.tool_name == "web_search": - # Extract query argument safely - query = "" - if hasattr(part, 'args') and isinstance(part.args, dict): - query = part.args.get("query", "") - - yield { - "type": "status", - "message": f"🔍 Searching the web: \"{query}\"", - "phase": "tool_execution", - "tool_name": part.tool_name - } - logger.info(f"Tool call detected: {part.tool_name}") - - # Handle text response - if isinstance(message, ModelTextResponse): - # Get current cumulative text - current_text = await run.get_text_so_far() - - # Calculate delta (new text only) - delta = current_text[len(previous_text):] - if delta: - yield {"type": "content", "content": delta} - - previous_text = current_text - - # Final finish marker - yield {"type": "content", "content": "", "finish_reason": "stop"} - logger.info("Tatlock execution complete") - - except Exception as e: - logger.error(f"Error in Tatlock execution: {e}", exc_info=True) - yield { - "type": "error", - "message": f"Error generating response: {str(e)}" - } - - # Final completion marker - yield {"type": "done"} - - async def _single_stage_chat( - self, - messages: list[dict], - conversation_id: Optional[str] = None, - stream: bool = True - ) -> AsyncGenerator[Dict[str, Any], None]: - """ - Execute single-stage chat (no steward analysis). - - Args: - messages: Conversation messages - conversation_id: Optional conversation ID - stream: Whether to stream - - Yields: - Chat response chunks - """ - if not stream: - # Non-streaming response - try: - response = await self.tatlock.chat_completion( - messages=messages, - conversation_id=conversation_id - ) - yield { - "type": "content", - "content": response, - "finish_reason": "stop" - } - yield {"type": "done"} - except Exception as e: - logger.error(f"Error in single-stage chat: {e}", exc_info=True) - yield { - "type": "error", - "message": str(e) - } - else: - # Streaming response - try: - async for chunk in self.tatlock.chat( - messages=messages, - conversation_id=conversation_id, - stream=True - ): - yield chunk - except Exception as e: - logger.error(f"Error in single-stage streaming: {e}", exc_info=True) - yield { - "type": "error", - "message": str(e) - } - - -def create_two_stage_agent( - tatlock_agent: Agent, - enable_two_stage: bool = True -) -> TwoStageAgent: - """ - Create two-stage agent orchestrator. - - Args: - tatlock_agent: The main Tatlock agent instance - enable_two_stage: Whether to enable two-stage analysis - - Returns: - TwoStageAgent instance - """ - return TwoStageAgent(tatlock_agent, enable_two_stage=enable_two_stage) diff --git a/services/OBSOLETE_core-ai/src/config.py b/services/OBSOLETE_core-ai/src/config.py deleted file mode 100644 index b89497e..0000000 --- a/services/OBSOLETE_core-ai/src/config.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Configuration for the Core AI service -""" -from pydantic_settings import BaseSettings -from functools import lru_cache - - -class Settings(BaseSettings): - """Core AI application settings""" - - # Application - app_name: str = "Core AI Service" - app_version: str = "1.0.0" - debug: bool = False - - # Server - host: str = "0.0.0.0" - port: int = 8086 # Different port to avoid conflict with core-api - - # Logging - log_level: str = "INFO" - - # Ollama Configuration (for AI orchestration) - ollama_base_url: str = "http://ollama:11434" - ollama_timeout: int = 300 # 5 minutes - - # Model Configuration - agent_model: str = "mistral-nemo:latest" # Optimized for PydanticAI tool calling - - # Multi-Stage Tool Selection Configuration - multi_stage_enabled: bool = True # Enable multi-stage steward analysis by default - use_speedy_steward: bool = False # DISABLED - Use regular steward with mistral-nemo - steward_model: str = "mistral-nemo:latest" # Steward uses same model as Tatlock (stays in VRAM) - analysis_timeout: int = 10 # Steward analysis timeout (reasonable for mistral-nemo) - max_recommended_tools: int = 5 # Maximum tools steward can recommend - min_recommended_tools: int = 0 # Minimum tools (0 = can recommend no tools) - - # Status Message Configuration - Butler-appropriate tone - enable_status_messages: bool = True # Show status messages during streaming - show_web_search_status: bool = True # Show status when web_search tool is called - status_consulting: str = "Consulting the steward on the matter..." - status_complete: str = "The steward consultation is complete" - status_no_assistance: str = "The steward advises no further assistance is required" - status_web_search: str = "Making enquiries..." # Butler tone for web search - status_calculate: str = "Calculating... (one prefers precision in mathematics)" - status_fallback: str = "The steward is unavailable. Proceeding with standard protocols..." - - # System Prompt Variants - system_prompt_variant: str = "minimal_agent" # For simple mode - pydantic_system_prompt_variant: str = "pydantic_agent" # For PydanticAI mode - - # Base URL for Core API tools (e.g., system status, services) - core_api_base_url: str = "http://core-api:8083/v1" - - # OpenAPI Tool Discovery - # Comma-separated list of OpenAPI spec URLs for dynamic tool discovery - # Example: "http://core-api:8083/openapi.json,http://automation:8080/openapi.json" - openapi_endpoints: str = "http://core-api:8083/openapi.json" - openapi_enabled: bool = True # Enable/disable OpenAPI tool discovery - - # Feature Flags - simple_enabled: bool = True # Enable simple endpoint - pydantic_enabled: bool = True # Enable PydanticAI endpoint - - # Memory System Configuration - memory_enabled: bool = True - memory_tier1_size: int = 10 # Max turns in RAM buffer - - # Qdrant Configuration (for conversation memory) - qdrant_url: str = "http://qdrant:6333" - qdrant_collection_prefix: str = "core_ai_user" # Prefix for user collections - - # Embedding Configuration - embedding_model: str = "nomic-embed-text" # Ollama embedding model - embedding_dimension: int = 768 # nomic-embed-text dimension - - # Default User (until external auth is integrated) - default_user_id: str = "llmdefault_at_schweitz_net" - - class Config: - env_file = ".env" - case_sensitive = False - - -@lru_cache() -def get_settings() -> Settings: - """Cached settings instance""" - return Settings() diff --git a/services/OBSOLETE_core-ai/src/memory/__init__.py b/services/OBSOLETE_core-ai/src/memory/__init__.py deleted file mode 100644 index 2ae813f..0000000 --- a/services/OBSOLETE_core-ai/src/memory/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -Multi-tenant memory system for conversation persistence - -Architecture: -- Tier 1: ConversationBufferMemory (in-memory, fast, last 10 turns) - per user -- Tier 2/3: QdrantConversationMemory (persistent + semantic search) - separate collection per user -- Manager: MemoryManager (orchestrates all tiers) - per user instance - -Multi-tenancy: -- Each user gets their own Qdrant collection: core_ai_user_{user_id} -- Complete data isolation between users -- Easy GDPR compliance (delete entire user collection) -""" -from .tier1_buffer import ConversationBufferMemory, get_buffer_memory -from .qdrant_memory import QdrantConversationMemory, get_qdrant_memory_for_user -from .manager import MemoryManager, get_memory_manager_for_user, clear_memory_manager_cache -from .schemas import ( - ConversationTurn, - ConversationBuffer, - ConversationMetadata, - ConversationSummary, - MemoryQuery, - MemoryResult, - MessageRole, - TokenUsage, - ConversationListResponse, - ConversationDetailResponse, - ConversationSearchRequest, - ConversationSearchResponse, -) - -__all__ = [ - # Manager (primary interface) - "MemoryManager", - "get_memory_manager_for_user", - "clear_memory_manager_cache", - # Tier 1 - "ConversationBufferMemory", - "get_buffer_memory", - # Tier 2/3 - "QdrantConversationMemory", - "get_qdrant_memory_for_user", - # Schemas - "ConversationTurn", - "ConversationBuffer", - "ConversationMetadata", - "ConversationSummary", - "MemoryQuery", - "MemoryResult", - "MessageRole", - "TokenUsage", - "ConversationListResponse", - "ConversationDetailResponse", - "ConversationSearchRequest", - "ConversationSearchResponse", -] diff --git a/services/OBSOLETE_core-ai/src/memory/base.py b/services/OBSOLETE_core-ai/src/memory/base.py deleted file mode 100644 index e119cfa..0000000 --- a/services/OBSOLETE_core-ai/src/memory/base.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Base classes for memory system -""" -from abc import ABC, abstractmethod -from typing import List, Optional -from .schemas import ConversationTurn, ConversationBuffer, MemoryQuery, MemoryResult - - -class BaseMemory(ABC): - """Base class for all memory tiers""" - - @abstractmethod - async def add_turn(self, conversation_id: str, turn: ConversationTurn) -> None: - """ - Add a new turn to memory - - Args: - conversation_id: Unique conversation identifier - turn: The conversation turn to store - """ - pass - - @abstractmethod - async def get_turns( - self, - conversation_id: str, - limit: Optional[int] = None, - offset: int = 0 - ) -> List[ConversationTurn]: - """ - Retrieve turns from memory - - Args: - conversation_id: Unique conversation identifier - limit: Maximum number of turns to retrieve - offset: Number of turns to skip - - Returns: - List of conversation turns - """ - pass - - @abstractmethod - async def clear_conversation(self, conversation_id: str) -> None: - """ - Clear all turns for a conversation - - Args: - conversation_id: Unique conversation identifier - """ - pass - - @abstractmethod - async def conversation_exists(self, conversation_id: str) -> bool: - """ - Check if a conversation exists in this memory tier - - Args: - conversation_id: Unique conversation identifier - - Returns: - True if conversation exists - """ - pass - - -class Tier1Memory(BaseMemory): - """Base class for Tier 1 (working memory)""" - - @abstractmethod - async def get_buffer(self, conversation_id: str) -> Optional[ConversationBuffer]: - """ - Get the full conversation buffer - - Args: - conversation_id: Unique conversation identifier - - Returns: - ConversationBuffer or None if not found - """ - pass - - @abstractmethod - async def prune(self, conversation_id: str, keep_last: int = 5) -> None: - """ - Prune old turns, keeping only the most recent ones - - Args: - conversation_id: Unique conversation identifier - keep_last: Number of recent turns to keep - """ - pass - - -class Tier2Memory(BaseMemory): - """Base class for Tier 2 (short-term memory with summaries)""" - - @abstractmethod - async def add_summary( - self, - conversation_id: str, - summary_text: str, - turn_range_start: int, - turn_range_end: int - ) -> None: - """ - Add a conversation summary - - Args: - conversation_id: Unique conversation identifier - summary_text: The summarized text - turn_range_start: First turn number in summary - turn_range_end: Last turn number in summary - """ - pass - - @abstractmethod - async def get_summaries(self, conversation_id: str) -> List[dict]: - """ - Get all summaries for a conversation - - Args: - conversation_id: Unique conversation identifier - - Returns: - List of summary dictionaries - """ - pass - - -class Tier3Memory(BaseMemory): - """Base class for Tier 3 (long-term vector memory)""" - - @abstractmethod - async def add_turn_with_embedding( - self, - conversation_id: str, - turn: ConversationTurn, - embedding: List[float] - ) -> None: - """ - Add a turn with its vector embedding - - Args: - conversation_id: Unique conversation identifier - turn: The conversation turn - embedding: Vector embedding of the turn content - """ - pass - - @abstractmethod - async def similarity_search( - self, - query_embedding: List[float], - conversation_id: Optional[str] = None, - limit: int = 5 - ) -> List[dict]: - """ - Perform semantic similarity search - - Args: - query_embedding: Vector embedding of the search query - conversation_id: Optional filter to specific conversation - limit: Maximum number of results - - Returns: - List of matching turns with scores - """ - pass diff --git a/services/OBSOLETE_core-ai/src/memory/manager.py b/services/OBSOLETE_core-ai/src/memory/manager.py deleted file mode 100644 index 081ef57..0000000 --- a/services/OBSOLETE_core-ai/src/memory/manager.py +++ /dev/null @@ -1,337 +0,0 @@ -""" -Multi-tenant Memory Manager: Orchestrates all memory tiers with per-user isolation - -Coordinates: -- Tier 1: ConversationBufferMemory (RAM, fast, last N turns) - per user -- Tier 2/3: QdrantConversationMemory (persistent + semantic) - separate collection per user - -Provides unified interface for memory operations with automatic -tier management and per-user data isolation. -""" -import logging -import asyncio -from typing import List, Optional, Dict, Any -from datetime import datetime - -from .tier1_buffer import ConversationBufferMemory -from .qdrant_memory import QdrantConversationMemory, get_qdrant_memory_for_user -from .schemas import ConversationTurn, MessageRole, TokenUsage -from src.config import get_settings - -logger = logging.getLogger(__name__) -settings = get_settings() - - -class MemoryManager: - """ - Multi-tenant unified memory manager orchestrating all tiers - - Features: - - Per-user data isolation (separate Qdrant collections) - - Per-user in-memory buffers - - Automatic consolidation from buffer to Qdrant - - Semantic search within user's conversations - - Memory lifecycle management - - Responsibilities: - - Add turns to appropriate tiers - - Retrieve conversation history (buffer + persistent) - - Consolidate buffer to persistent storage - - Semantic search across user's conversations - - Memory lifecycle management - """ - - def __init__( - self, - user_id: str, - buffer_max_turns: int = 10, - auto_consolidate: bool = True - ): - """ - Initialize memory manager for a specific user - - Args: - user_id: Sanitized user ID (email format: username_at_domain_com) - buffer_max_turns: Max turns to keep in RAM buffer - auto_consolidate: Automatically consolidate when buffer threshold reached - """ - self.user_id = user_id - self.auto_consolidate = auto_consolidate - - # Create user-specific buffer (in-memory) - self.buffer_memory = ConversationBufferMemory(max_turns=buffer_max_turns) - - # Create user-specific Qdrant memory (separate collection) - self.qdrant_memory = get_qdrant_memory_for_user(user_id) - - logger.info( - f"MemoryManager initialized for user '{user_id}' " - f"(auto_consolidate={auto_consolidate}, buffer_max={buffer_max_turns})" - ) - - async def add_turn( - self, - conversation_id: str, - role: MessageRole, - content: str, - tokens: Optional[TokenUsage] = None, - metadata: Optional[Dict[str, Any]] = None - ) -> ConversationTurn: - """ - Add a conversation turn to memory - - Automatically: - 1. Adds to Tier 1 (buffer) - 2. Adds to Tier 2/3 (Qdrant) immediately - 3. Auto-prunes buffer if max turns reached - - Args: - conversation_id: Unique conversation identifier - role: Message role (user, assistant, system) - content: Message content - tokens: Optional token usage - metadata: Optional metadata - - Returns: - The created conversation turn - """ - # Get current buffer to determine turn number - buffer = await self.buffer_memory.get_buffer(conversation_id) - turn_number = (buffer.metadata.turn_count + 1) if buffer else 1 - - # Create turn with user_id - turn = ConversationTurn( - role=role, - content=content, - timestamp=datetime.utcnow(), - turn_number=turn_number, - user_id=self.user_id, - tokens=tokens, - metadata=metadata or {} - ) - - # Add to Tier 1 (buffer) - fast RAM storage - await self.buffer_memory.add_turn(conversation_id, turn) - logger.debug( - f"Turn {turn_number} added to buffer for user '{self.user_id}' " - f"conversation {conversation_id}" - ) - - # Add to Tier 2/3 (Qdrant) immediately - persistent storage with embeddings - try: - await self.qdrant_memory.add_turn(conversation_id, turn) - logger.debug( - f"Turn {turn_number} added to Qdrant for user '{self.user_id}' " - f"conversation {conversation_id}" - ) - except Exception as e: - logger.error( - f"Error adding turn to Qdrant for user '{self.user_id}': {e}" - ) - # Don't fail the whole operation if Qdrant fails - # Buffer still has the turn - - return turn - - async def get_recent_turns( - self, - conversation_id: str, - limit: int = 10 - ) -> List[ConversationTurn]: - """ - Get recent conversation turns (from buffer) - - Args: - conversation_id: Unique conversation identifier - limit: Maximum number of turns to retrieve - - Returns: - List of recent conversation turns - """ - return await self.buffer_memory.get_recent_turns(conversation_id, limit) - - async def get_full_history( - self, - conversation_id: str, - include_buffer: bool = True - ) -> List[ConversationTurn]: - """ - Get complete conversation history - - Retrieves from Qdrant (Tier 2) - buffer is just a cache - - Args: - conversation_id: Unique conversation identifier - include_buffer: Ignored (kept for API compatibility) - - Returns: - Complete conversation history, sorted chronologically - """ - # Get from Qdrant (source of truth) - turns = await self.qdrant_memory.get_turns(conversation_id) - - # Sort chronologically (should already be sorted, but ensure it) - turns.sort(key=lambda t: t.turn_number) - - return turns - - async def search_conversations( - self, - query: str, - conversation_id: Optional[str] = None, - limit: int = 5 - ) -> List[Dict[str, Any]]: - """ - Semantic search across user's conversations (Tier 3 mode) - - Searches only within this user's collection. - - Args: - query: Search query - conversation_id: Optional filter to specific conversation - limit: Maximum number of results - - Returns: - List of matching turns with scores - """ - return await self.qdrant_memory.similarity_search( - query=query, - conversation_id=conversation_id, - limit=limit - ) - - async def clear_conversation( - self, - conversation_id: str, - clear_buffer: bool = True, - clear_qdrant: bool = True - ) -> None: - """ - Clear conversation from memory - - Args: - conversation_id: Unique conversation identifier - clear_buffer: Clear from Tier 1 buffer - clear_qdrant: Clear from Tier 2/3 Qdrant - """ - if clear_buffer: - await self.buffer_memory.clear_conversation(conversation_id) - logger.info( - f"Cleared buffer for user '{self.user_id}' conversation {conversation_id}" - ) - - if clear_qdrant: - await self.qdrant_memory.clear_conversation(conversation_id) - logger.info( - f"Cleared Qdrant for user '{self.user_id}' conversation {conversation_id}" - ) - - async def clear_all_user_data(self) -> None: - """ - Clear ALL data for this user (GDPR compliance) - - Deletes: - - All buffer data for this user - - Entire Qdrant collection for this user - """ - # Clear all buffers (in-memory) - conversation_ids = await self.buffer_memory.get_all_conversation_ids() - for conv_id in conversation_ids: - await self.buffer_memory.clear_conversation(conv_id) - - # Delete entire Qdrant collection - await self.qdrant_memory.clear_all_data() - - logger.info(f"Cleared ALL data for user '{self.user_id}'") - - async def get_conversation_stats( - self, - conversation_id: str - ) -> Dict[str, Any]: - """ - Get conversation statistics across all tiers - - Args: - conversation_id: Unique conversation identifier - - Returns: - Dictionary with stats from buffer and Qdrant - """ - # Get buffer stats - buffer = await self.buffer_memory.get_buffer(conversation_id) - buffer_stats = { - "buffer_turns": buffer.metadata.turn_count if buffer else 0, - "buffer_tokens": buffer.metadata.total_tokens if buffer else 0 - } - - # Get Qdrant stats - qdrant_stats = await self.qdrant_memory.get_conversation_stats(conversation_id) - - # Combine - return { - "user_id": self.user_id, - "conversation_id": conversation_id, - **buffer_stats, - "qdrant_turns": qdrant_stats["total_turns"], - "qdrant_tokens": qdrant_stats["total_tokens"], - "exists_in_buffer": buffer is not None, - "exists_in_qdrant": qdrant_stats["exists"] - } - - async def list_conversations(self) -> List[str]: - """ - List all conversation IDs for this user - - Returns: - List of conversation IDs - """ - return await self.qdrant_memory.list_conversations() - - -# Per-user memory manager cache -_memory_managers: Dict[str, MemoryManager] = {} - - -def get_memory_manager_for_user( - user_id: str, - buffer_max_turns: int = 10, - auto_consolidate: bool = True -) -> MemoryManager: - """ - Get or create memory manager instance for a specific user - - Args: - user_id: Sanitized user ID - buffer_max_turns: Max turns to keep in RAM buffer - auto_consolidate: Automatically consolidate when buffer threshold reached - - Returns: - MemoryManager instance for the user - """ - if user_id not in _memory_managers: - _memory_managers[user_id] = MemoryManager( - user_id=user_id, - buffer_max_turns=buffer_max_turns, - auto_consolidate=auto_consolidate - ) - logger.info(f"Created new MemoryManager for user '{user_id}'") - - return _memory_managers[user_id] - - -def clear_memory_manager_cache(user_id: Optional[str] = None) -> None: - """ - Clear memory manager cache - - Args: - user_id: Optional user ID to clear (None = clear all) - """ - global _memory_managers - - if user_id: - if user_id in _memory_managers: - del _memory_managers[user_id] - logger.info(f"Cleared MemoryManager cache for user '{user_id}'") - else: - _memory_managers.clear() - logger.info("Cleared all MemoryManager caches") diff --git a/services/OBSOLETE_core-ai/src/memory/qdrant_memory.py b/services/OBSOLETE_core-ai/src/memory/qdrant_memory.py deleted file mode 100644 index 23e409c..0000000 --- a/services/OBSOLETE_core-ai/src/memory/qdrant_memory.py +++ /dev/null @@ -1,465 +0,0 @@ -""" -Unified Tier 2/3: Qdrant-based conversation memory with collection-per-user - -Multi-tenant architecture: -- Each user gets their own Qdrant collection: core_ai_user_{user_id} -- Collections created on-demand -- Complete data isolation between users -- Easy GDPR compliance (delete entire collection) - -Dual-mode operation: -- Tier 2: Historical retrieval (filter by conversation_id, time-based) -- Tier 3: Semantic search (vector similarity across user's conversations) -""" -import logging -import uuid -import re -from typing import List, Optional, Dict, Any -from datetime import datetime -from qdrant_client import QdrantClient -from qdrant_client.models import ( - Distance, - VectorParams, - PointStruct, - Filter, - FieldCondition, - MatchValue, -) - -from .base import BaseMemory -from .schemas import ConversationTurn, MessageRole -from src.config import get_settings -from src.models.embeddings_ollama import get_embedding_client - -logger = logging.getLogger(__name__) -settings = get_settings() - - -class QdrantConversationMemory(BaseMemory): - """ - Multi-tenant conversation memory using Qdrant with collection-per-user. - - Each user gets a dedicated collection for complete data isolation. - Stores all conversation turns with vectors for semantic search. - """ - - def __init__( - self, - user_id: str, - collection_prefix: Optional[str] = None, - qdrant_url: Optional[str] = None - ): - """ - Initialize Qdrant memory for a specific user - - Args: - user_id: Sanitized user ID (email format: username_at_domain_com) - collection_prefix: Collection name prefix (default: core_ai_user) - qdrant_url: Qdrant connection URL (default from settings) - """ - self.user_id = user_id - self.collection_prefix = collection_prefix or "core_ai_user" - self.collection_name = self._get_collection_name(user_id) - - # Parse Qdrant URL (format: http://qdrant:6333) - qdrant_url = qdrant_url or getattr(settings, 'qdrant_url', 'http://qdrant:6333') - self.qdrant_url = qdrant_url - - # Initialize clients - self.client = QdrantClient(url=self.qdrant_url) - self.embedding_client = get_embedding_client() - - logger.info( - f"Initialized QdrantConversationMemory for user '{user_id}': " - f"{self.qdrant_url}/{self.collection_name}" - ) - - # Ensure user's collection exists - self._ensure_collection() - - def _get_collection_name(self, user_id: str) -> str: - """ - Generate collection name for user - - Args: - user_id: Sanitized user ID - - Returns: - Collection name: {prefix}_{user_id} - """ - # Sanitize user_id for collection name (should already be sanitized, but double-check) - sanitized = re.sub(r'[^a-z0-9_]', '_', user_id.lower()) - return f"{self.collection_prefix}_{sanitized}" - - def _ensure_collection(self) -> None: - """Create user's collection if it doesn't exist""" - try: - collections = self.client.get_collections().collections - collection_names = [c.name for c in collections] - - if self.collection_name not in collection_names: - logger.info(f"Creating new collection for user '{self.user_id}': {self.collection_name}") - - # Get embedding dimension from settings or default to 768 (nomic-embed-text) - embedding_dim = getattr(settings, 'embedding_dimension', 768) - - self.client.create_collection( - collection_name=self.collection_name, - vectors_config=VectorParams( - size=embedding_dim, - distance=Distance.COSINE - ) - ) - logger.info(f"✓ Collection created: {self.collection_name}") - else: - logger.info(f"✓ Collection exists: {self.collection_name}") - - except Exception as e: - logger.error(f"Error ensuring collection for user '{self.user_id}': {e}") - raise - - async def add_turn(self, conversation_id: str, turn: ConversationTurn) -> None: - """ - Add a conversation turn with its embedding - - Args: - conversation_id: Unique conversation identifier - turn: The conversation turn to store - """ - # Generate embedding - embedding = await self.embedding_client.embed_text(turn.content) - - # Create point ID: deterministic UUID from conversation_id + turn_number - point_id_str = f"{conversation_id}_{turn.turn_number}" - point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, point_id_str)) - - # Build payload (no user_id needed - collection is already user-specific) - payload = { - "conversation_id": conversation_id, - "turn_number": turn.turn_number, - "role": turn.role.value if isinstance(turn.role, MessageRole) else turn.role, - "content": turn.content, - "timestamp": turn.timestamp.isoformat(), - "metadata": turn.metadata, - } - - # Add token info if available - if turn.tokens: - payload["tokens_prompt"] = turn.tokens.prompt - payload["tokens_completion"] = turn.tokens.completion - payload["tokens_total"] = turn.tokens.total - - # Upsert to user's Qdrant collection - try: - self.client.upsert( - collection_name=self.collection_name, - points=[ - PointStruct( - id=point_id, - vector=embedding, - payload=payload - ) - ] - ) - logger.debug( - f"Stored turn {turn.turn_number} for conversation {conversation_id} " - f"(user: {self.user_id})" - ) - - except Exception as e: - logger.error(f"Error storing turn in Qdrant for user '{self.user_id}': {e}") - raise - - async def get_turns( - self, - conversation_id: str, - limit: Optional[int] = None, - offset: int = 0 - ) -> List[ConversationTurn]: - """ - Retrieve turns for a conversation (Tier 2 mode: chronological) - - Args: - conversation_id: Unique conversation identifier - limit: Maximum number of turns to retrieve - offset: Number of turns to skip - - Returns: - List of conversation turns - """ - try: - # Scroll through all points for this conversation - points, _ = self.client.scroll( - collection_name=self.collection_name, - scroll_filter=Filter( - must=[ - FieldCondition( - key="conversation_id", - match=MatchValue(value=conversation_id) - ) - ] - ), - limit=limit or 100, - offset=offset, - with_payload=True, - with_vectors=False - ) - - # Convert to ConversationTurn objects - turns = [] - for point in points: - payload = point.payload - turn = ConversationTurn( - role=MessageRole(payload["role"]), - content=payload["content"], - timestamp=datetime.fromisoformat(payload["timestamp"]), - turn_number=payload["turn_number"], - user_id=self.user_id, # User from collection context - metadata=payload.get("metadata", {}) - ) - turns.append(turn) - - # Sort by turn_number - turns.sort(key=lambda t: t.turn_number) - - return turns - - except Exception as e: - logger.error(f"Error retrieving turns from Qdrant for user '{self.user_id}': {e}") - return [] - - async def similarity_search( - self, - query: str, - conversation_id: Optional[str] = None, - limit: int = 5 - ) -> List[Dict[str, Any]]: - """ - Semantic search for relevant turns (Tier 3 mode: semantic) - - Args: - query: Search query text - conversation_id: Optional filter to specific conversation - limit: Maximum number of results - - Returns: - List of matching turns with scores - """ - try: - # Generate query embedding - query_embedding = await self.embedding_client.embed_text(query) - - # Build filter if conversation_id specified - search_filter = None - if conversation_id: - search_filter = Filter( - must=[ - FieldCondition( - key="conversation_id", - match=MatchValue(value=conversation_id) - ) - ] - ) - - # Search in user's Qdrant collection - results = self.client.search( - collection_name=self.collection_name, - query_vector=query_embedding, - query_filter=search_filter, - limit=limit, - with_payload=True - ) - - # Convert results - matches = [] - for result in results: - payload = result.payload - match = { - "conversation_id": payload["conversation_id"], - "turn_number": payload["turn_number"], - "role": payload["role"], - "content": payload["content"], - "timestamp": payload["timestamp"], - "score": result.score, - } - matches.append(match) - - logger.debug( - f"Semantic search found {len(matches)} matches for user '{self.user_id}' " - f"query: {query[:50]}..." - ) - - return matches - - except Exception as e: - logger.error(f"Error in semantic search for user '{self.user_id}': {e}") - return [] - - async def clear_conversation(self, conversation_id: str) -> None: - """ - Clear all turns for a conversation - - Args: - conversation_id: Unique conversation identifier - """ - try: - # Delete all points with this conversation_id - self.client.delete( - collection_name=self.collection_name, - points_selector=Filter( - must=[ - FieldCondition( - key="conversation_id", - match=MatchValue(value=conversation_id) - ) - ] - ) - ) - logger.info( - f"Cleared conversation {conversation_id} for user '{self.user_id}' from Qdrant" - ) - - except Exception as e: - logger.error(f"Error clearing conversation for user '{self.user_id}': {e}") - raise - - async def clear_all_data(self) -> None: - """ - Clear ALL data for this user (GDPR compliance) - - Deletes the entire collection for this user. - """ - try: - self.client.delete_collection(self.collection_name) - logger.info(f"Deleted all data for user '{self.user_id}' (collection: {self.collection_name})") - - except Exception as e: - logger.error(f"Error deleting user data for '{self.user_id}': {e}") - raise - - async def conversation_exists(self, conversation_id: str) -> bool: - """ - Check if a conversation exists - - Args: - conversation_id: Unique conversation identifier - - Returns: - True if conversation has any turns - """ - try: - points, _ = self.client.scroll( - collection_name=self.collection_name, - scroll_filter=Filter( - must=[ - FieldCondition( - key="conversation_id", - match=MatchValue(value=conversation_id) - ) - ] - ), - limit=1, - with_payload=False, - with_vectors=False - ) - return len(points) > 0 - - except Exception as e: - logger.error(f"Error checking conversation existence for user '{self.user_id}': {e}") - return False - - async def get_conversation_stats(self, conversation_id: str) -> Dict[str, Any]: - """ - Get statistics about a conversation - - Args: - conversation_id: Unique conversation identifier - - Returns: - Dictionary with stats - """ - try: - points, _ = self.client.scroll( - collection_name=self.collection_name, - scroll_filter=Filter( - must=[ - FieldCondition( - key="conversation_id", - match=MatchValue(value=conversation_id) - ) - ] - ), - limit=1000, # Get all points - with_payload=True, - with_vectors=False - ) - - total_turns = len(points) - total_tokens = sum( - point.payload.get("tokens_total", 0) for point in points - ) - - return { - "user_id": self.user_id, - "conversation_id": conversation_id, - "total_turns": total_turns, - "total_tokens": total_tokens, - "exists": total_turns > 0 - } - - except Exception as e: - logger.error(f"Error getting conversation stats for user '{self.user_id}': {e}") - return { - "user_id": self.user_id, - "conversation_id": conversation_id, - "total_turns": 0, - "total_tokens": 0, - "exists": False - } - - async def list_conversations(self) -> List[str]: - """ - List all conversation IDs for this user - - Returns: - List of conversation IDs - """ - try: - # Scroll through all points to collect unique conversation_ids - conversation_ids = set() - offset = None - - while True: - points, next_offset = self.client.scroll( - collection_name=self.collection_name, - limit=100, - offset=offset, - with_payload=True, - with_vectors=False - ) - - for point in points: - conversation_ids.add(point.payload["conversation_id"]) - - if next_offset is None: - break - offset = next_offset - - return sorted(list(conversation_ids)) - - except Exception as e: - logger.error(f"Error listing conversations for user '{self.user_id}': {e}") - return [] - - -def get_qdrant_memory_for_user(user_id: str) -> QdrantConversationMemory: - """ - Get Qdrant memory instance for a specific user - - Args: - user_id: Sanitized user ID (email format: username_at_domain_com) - - Returns: - QdrantConversationMemory instance for the user - """ - return QdrantConversationMemory(user_id=user_id) diff --git a/services/OBSOLETE_core-ai/src/memory/schemas.py b/services/OBSOLETE_core-ai/src/memory/schemas.py deleted file mode 100644 index 23fad11..0000000 --- a/services/OBSOLETE_core-ai/src/memory/schemas.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Pydantic schemas for memory system -""" -from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any -from datetime import datetime -from enum import Enum - - -class MessageRole(str, Enum): - """Message role types""" - SYSTEM = "system" - USER = "user" - ASSISTANT = "assistant" - - -class TokenUsage(BaseModel): - """Token usage information""" - prompt: int = 0 - completion: int = 0 - total: int = 0 - - -class ConversationTurn(BaseModel): - """A single turn in a conversation""" - role: MessageRole - content: str - timestamp: datetime = Field(default_factory=datetime.utcnow) - turn_number: int - user_id: str = "llmdefault_at_schweitz_net" # Multi-tenancy: user who owns this turn - tokens: Optional[TokenUsage] = None - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class ConversationMetadata(BaseModel): - """Metadata about a conversation""" - conversation_id: str - user_id: str = "llmdefault_at_schweitz_net" # Multi-tenancy: user who owns this conversation - created_at: datetime = Field(default_factory=datetime.utcnow) - last_updated: datetime = Field(default_factory=datetime.utcnow) - turn_count: int = 0 - total_tokens: int = 0 - status: str = "active" # active, archived, deleted - - -class ConversationBuffer(BaseModel): - """In-memory conversation buffer (Tier 1)""" - conversation_id: str - turns: List[ConversationTurn] = Field(default_factory=list) - metadata: ConversationMetadata - - -class ConversationSummary(BaseModel): - """Summarized conversation segment (Tier 2)""" - conversation_id: str - summary_text: str - turn_range_start: int - turn_range_end: int - created_at: datetime = Field(default_factory=datetime.utcnow) - token_count: int = 0 - - -class MemoryQuery(BaseModel): - """Query for memory retrieval""" - conversation_id: str - query: Optional[str] = None - limit: int = Field(default=10, ge=1, le=100) - include_tier1: bool = True - include_tier2: bool = True - include_tier3: bool = True - - -class MemoryResult(BaseModel): - """Result from memory retrieval""" - conversation_id: str - turns: List[ConversationTurn] = Field(default_factory=list) - summaries: List[ConversationSummary] = Field(default_factory=list) - source_tiers: List[int] = Field(default_factory=list) # Which tiers contributed - total_results: int = 0 - - -# API Request/Response Models - -class ConversationListResponse(BaseModel): - """Response for listing conversations""" - conversations: List[ConversationMetadata] - total: int - page: int = 1 - page_size: int = 50 - - -class ConversationDetailResponse(BaseModel): - """Response for conversation details""" - metadata: ConversationMetadata - recent_turns: List[ConversationTurn] - turn_count: int - - -class ConversationSearchRequest(BaseModel): - """Request for semantic search in conversation""" - query: str - limit: int = Field(default=5, ge=1, le=50) - - -class ConversationSearchResponse(BaseModel): - """Response for semantic search""" - conversation_id: str - results: List[ConversationTurn] - scores: List[float] = Field(default_factory=list) - total_results: int diff --git a/services/OBSOLETE_core-ai/src/memory/tier1_buffer.py b/services/OBSOLETE_core-ai/src/memory/tier1_buffer.py deleted file mode 100644 index f608ef3..0000000 --- a/services/OBSOLETE_core-ai/src/memory/tier1_buffer.py +++ /dev/null @@ -1,239 +0,0 @@ -""" -Tier 1: ConversationBufferMemory (In-Memory Working Memory) - -Fast in-memory storage for recent conversation turns. -- Stores last N turns in RAM -- < 1ms access time -- Ephemeral (lost on restart) -- Automatic pruning when limit reached -""" -import logging -from typing import Dict, List, Optional -from datetime import datetime -from collections import OrderedDict - -from .base import Tier1Memory -from .schemas import ( - ConversationTurn, - ConversationBuffer, - ConversationMetadata, - MessageRole, - TokenUsage -) - -logger = logging.getLogger(__name__) - - -class ConversationBufferMemory(Tier1Memory): - """ - In-memory buffer for recent conversation turns. - - Stores the last N turns of each conversation in RAM for fast access. - Automatically prunes old turns when limit is reached. - """ - - def __init__(self, max_turns: int = 10): - """ - Initialize buffer memory - - Args: - max_turns: Maximum number of turns to keep per conversation - """ - self.max_turns = max_turns - # Use OrderedDict to maintain insertion order - self._buffers: Dict[str, ConversationBuffer] = OrderedDict() - logger.info(f"Initialized ConversationBufferMemory with max_turns={max_turns}") - - async def add_turn(self, conversation_id: str, turn: ConversationTurn) -> None: - """ - Add a new turn to the buffer - - Args: - conversation_id: Unique conversation identifier - turn: The conversation turn to store - """ - # Get or create buffer - buffer = await self.get_buffer(conversation_id) - if buffer is None: - buffer = ConversationBuffer( - conversation_id=conversation_id, - turns=[], - metadata=ConversationMetadata( - conversation_id=conversation_id - ) - ) - self._buffers[conversation_id] = buffer - - # Add turn - buffer.turns.append(turn) - - # Update metadata - buffer.metadata.turn_count = len(buffer.turns) - buffer.metadata.last_updated = datetime.utcnow() - - if turn.tokens: - buffer.metadata.total_tokens += turn.tokens.total - - # Auto-prune if exceeds max turns - if len(buffer.turns) > self.max_turns: - await self.prune(conversation_id, keep_last=self.max_turns) - - logger.debug( - f"Added turn {turn.turn_number} to conversation {conversation_id}. " - f"Buffer size: {len(buffer.turns)}" - ) - - async def get_turns( - self, - conversation_id: str, - limit: Optional[int] = None, - offset: int = 0 - ) -> List[ConversationTurn]: - """ - Retrieve turns from the buffer - - Args: - conversation_id: Unique conversation identifier - limit: Maximum number of turns to retrieve - offset: Number of turns to skip - - Returns: - List of conversation turns - """ - buffer = await self.get_buffer(conversation_id) - if buffer is None: - return [] - - turns = buffer.turns[offset:] - if limit: - turns = turns[:limit] - - return turns - - async def get_recent_turns( - self, - conversation_id: str, - limit: int = 10 - ) -> List[ConversationTurn]: - """ - Get the most recent N turns - - Args: - conversation_id: Unique conversation identifier - limit: Number of recent turns to retrieve - - Returns: - List of recent turns (most recent last) - """ - buffer = await self.get_buffer(conversation_id) - if buffer is None: - return [] - - return buffer.turns[-limit:] if len(buffer.turns) > limit else buffer.turns - - async def get_buffer(self, conversation_id: str) -> Optional[ConversationBuffer]: - """ - Get the full conversation buffer - - Args: - conversation_id: Unique conversation identifier - - Returns: - ConversationBuffer or None if not found - """ - return self._buffers.get(conversation_id) - - async def clear_conversation(self, conversation_id: str) -> None: - """ - Clear all turns for a conversation - - Args: - conversation_id: Unique conversation identifier - """ - if conversation_id in self._buffers: - del self._buffers[conversation_id] - logger.info(f"Cleared buffer for conversation {conversation_id}") - - async def conversation_exists(self, conversation_id: str) -> bool: - """ - Check if a conversation exists in the buffer - - Args: - conversation_id: Unique conversation identifier - - Returns: - True if conversation exists - """ - return conversation_id in self._buffers - - async def prune(self, conversation_id: str, keep_last: int = 5) -> None: - """ - Prune old turns, keeping only the most recent ones - - Args: - conversation_id: Unique conversation identifier - keep_last: Number of recent turns to keep - """ - buffer = await self.get_buffer(conversation_id) - if buffer is None: - return - - if len(buffer.turns) > keep_last: - removed_count = len(buffer.turns) - keep_last - buffer.turns = buffer.turns[-keep_last:] - buffer.metadata.turn_count = len(buffer.turns) - - logger.debug( - f"Pruned {removed_count} turns from conversation {conversation_id}. " - f"Kept last {keep_last} turns." - ) - - async def get_all_conversation_ids(self) -> List[str]: - """ - Get list of all conversation IDs in memory - - Returns: - List of conversation IDs - """ - return list(self._buffers.keys()) - - async def get_buffer_stats(self) -> dict: - """ - Get statistics about buffer memory usage - - Returns: - Dictionary with stats - """ - total_conversations = len(self._buffers) - total_turns = sum(len(buf.turns) for buf in self._buffers.values()) - total_tokens = sum(buf.metadata.total_tokens for buf in self._buffers.values()) - - return { - "total_conversations": total_conversations, - "total_turns": total_turns, - "total_tokens": total_tokens, - "max_turns_per_conversation": self.max_turns, - "avg_turns_per_conversation": ( - total_turns / total_conversations if total_conversations > 0 else 0 - ) - } - - -# Global instance -_buffer_memory: Optional[ConversationBufferMemory] = None - - -def get_buffer_memory(max_turns: int = 10) -> ConversationBufferMemory: - """ - Get or create the global buffer memory instance - - Args: - max_turns: Maximum turns per conversation - - Returns: - ConversationBufferMemory instance - """ - global _buffer_memory - if _buffer_memory is None: - _buffer_memory = ConversationBufferMemory(max_turns=max_turns) - return _buffer_memory diff --git a/services/OBSOLETE_core-ai/src/metrics/__init__.py b/services/OBSOLETE_core-ai/src/metrics/__init__.py deleted file mode 100644 index 7d545da..0000000 --- a/services/OBSOLETE_core-ai/src/metrics/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Metrics collection and monitoring for Core-AI service. - -Provides lightweight, in-memory performance tracking for: -- Agent request/response metrics -- Tool execution statistics -- Memory system performance -- Error tracking -""" -from .collector import MetricsCollector, get_metrics_collector - -__all__ = ['MetricsCollector', 'get_metrics_collector'] diff --git a/services/OBSOLETE_core-ai/src/metrics/collector.py b/services/OBSOLETE_core-ai/src/metrics/collector.py deleted file mode 100644 index cf5c3ae..0000000 --- a/services/OBSOLETE_core-ai/src/metrics/collector.py +++ /dev/null @@ -1,314 +0,0 @@ -""" -Metrics Collector - In-memory performance metrics storage. - -Tracks agent performance, tool execution, and memory system statistics -with thread-safe counters and sliding window storage. -""" -import time -import threading -from typing import Dict, List, Any, Optional -from collections import defaultdict, deque -from datetime import datetime, timedelta -import statistics - - -class MetricsCollector: - """ - Thread-safe in-memory metrics collector. - - Stores metrics in sliding windows: - - Last 1 hour: detailed per-request data - - Last 24 hours: aggregated statistics - """ - - def __init__(self, detailed_window_hours: int = 1, aggregated_window_hours: int = 24): - self.lock = threading.Lock() - self.start_time = time.time() - - # Time windows - self.detailed_window_seconds = detailed_window_hours * 3600 - self.aggregated_window_seconds = aggregated_window_hours * 3600 - - # Agent request metrics - self.total_requests = 0 - self.requests_by_agent = defaultdict(int) - self.request_durations = deque(maxlen=1000) # Last 1000 requests - self.request_errors = deque(maxlen=100) # Last 100 errors - - # Tool execution metrics - self.total_tool_calls = 0 - self.tool_calls_by_name = defaultdict(int) - self.tool_successes_by_name = defaultdict(int) - self.tool_failures_by_name = defaultdict(int) - self.tool_durations_by_name = defaultdict(lambda: deque(maxlen=100)) - self.recent_tool_failures = deque(maxlen=50) - - # Memory system metrics - self.memory_tier1_hits = 0 - self.memory_tier1_misses = 0 - self.memory_tier2_queries = 0 - self.memory_tier2_durations = deque(maxlen=100) - self.memory_consolidations = 0 - self.active_users = set() - - # Request pattern metrics - self.streaming_requests = 0 - self.non_streaming_requests = 0 - self.requests_by_user = defaultdict(int) - self.concurrent_requests = 0 - self.max_concurrent_requests = 0 - - # Timestamped events for rate calculation - self.request_timestamps = deque(maxlen=1000) - - def _cleanup_old_data(self): - """Remove data older than retention windows.""" - current_time = time.time() - cutoff_time = current_time - self.detailed_window_seconds - - # Clean up request timestamps - while self.request_timestamps and self.request_timestamps[0] < cutoff_time: - self.request_timestamps.popleft() - - def record_request(self, agent_type: str, duration_ms: float, success: bool, - streaming: bool = False, user_id: Optional[str] = None, - error: Optional[str] = None): - """ - Record an agent request. - - Args: - agent_type: Type of agent used (pydantic, simple, ollama-native) - duration_ms: Request duration in milliseconds - success: Whether request completed successfully - streaming: Whether this was a streaming request - user_id: User identifier (optional) - error: Error message if failed (optional) - """ - with self.lock: - self.total_requests += 1 - self.requests_by_agent[agent_type] += 1 - self.request_durations.append((time.time(), duration_ms)) - self.request_timestamps.append(time.time()) - - if streaming: - self.streaming_requests += 1 - else: - self.non_streaming_requests += 1 - - if user_id: - self.requests_by_user[user_id] += 1 - self.active_users.add(user_id) - - if not success and error: - self.request_errors.append({ - 'timestamp': time.time(), - 'agent_type': agent_type, - 'error': error, - 'duration_ms': duration_ms - }) - - self._cleanup_old_data() - - def record_tool_execution(self, tool_name: str, duration_ms: float, success: bool, - error: Optional[str] = None): - """ - Record a tool execution. - - Args: - tool_name: Name of the tool - duration_ms: Execution duration in milliseconds - success: Whether execution succeeded - error: Error message if failed (optional) - """ - with self.lock: - self.total_tool_calls += 1 - self.tool_calls_by_name[tool_name] += 1 - self.tool_durations_by_name[tool_name].append(duration_ms) - - if success: - self.tool_successes_by_name[tool_name] += 1 - else: - self.tool_failures_by_name[tool_name] += 1 - if error: - self.recent_tool_failures.append({ - 'timestamp': time.time(), - 'tool_name': tool_name, - 'error': error, - 'duration_ms': duration_ms - }) - - def record_memory_access(self, tier: str, hit: bool, duration_ms: Optional[float] = None): - """ - Record a memory system access. - - Args: - tier: Memory tier (tier1, tier2) - hit: Whether it was a cache hit - duration_ms: Access duration in milliseconds (optional) - """ - with self.lock: - if tier == "tier1": - if hit: - self.memory_tier1_hits += 1 - else: - self.memory_tier1_misses += 1 - elif tier == "tier2": - self.memory_tier2_queries += 1 - if duration_ms is not None: - self.memory_tier2_durations.append(duration_ms) - - def record_memory_consolidation(self): - """Record a memory consolidation event.""" - with self.lock: - self.memory_consolidations += 1 - - def increment_concurrent_requests(self): - """Increment concurrent request counter.""" - with self.lock: - self.concurrent_requests += 1 - if self.concurrent_requests > self.max_concurrent_requests: - self.max_concurrent_requests = self.concurrent_requests - - def decrement_concurrent_requests(self): - """Decrement concurrent request counter.""" - with self.lock: - self.concurrent_requests = max(0, self.concurrent_requests - 1) - - def get_metrics(self) -> Dict[str, Any]: - """ - Get comprehensive metrics snapshot. - - Returns: - Dictionary with all collected metrics - """ - with self.lock: - current_time = time.time() - uptime_seconds = current_time - self.start_time - - # Calculate response time percentiles - recent_durations = [d for _, d in self.request_durations] - if recent_durations: - avg_response_time = statistics.mean(recent_durations) - p50 = statistics.median(recent_durations) - sorted_durations = sorted(recent_durations) - p95_idx = int(len(sorted_durations) * 0.95) - p99_idx = int(len(sorted_durations) * 0.99) - p95 = sorted_durations[p95_idx] if p95_idx < len(sorted_durations) else sorted_durations[-1] - p99 = sorted_durations[p99_idx] if p99_idx < len(sorted_durations) else sorted_durations[-1] - else: - avg_response_time = p50 = p95 = p99 = 0 - - # Calculate requests per minute (last 5 minutes) - five_min_ago = current_time - 300 - recent_requests = sum(1 for ts in self.request_timestamps if ts >= five_min_ago) - requests_per_minute = (recent_requests / 5) if recent_requests > 0 else 0 - - # Calculate tool statistics - tool_stats = {} - for tool_name in self.tool_calls_by_name.keys(): - total_calls = self.tool_calls_by_name[tool_name] - successes = self.tool_successes_by_name[tool_name] - failures = self.tool_failures_by_name[tool_name] - durations = list(self.tool_durations_by_name[tool_name]) - - tool_stats[tool_name] = { - 'calls': total_calls, - 'successes': successes, - 'failures': failures, - 'success_rate': successes / total_calls if total_calls > 0 else 0, - 'avg_duration_ms': statistics.mean(durations) if durations else 0, - 'p95_duration_ms': sorted(durations)[int(len(durations) * 0.95)] if len(durations) > 1 else (durations[0] if durations else 0) - } - - # Sort tools by usage - top_tools = dict(sorted(tool_stats.items(), key=lambda x: x[1]['calls'], reverse=True)[:10]) - - # Calculate memory hit rate - total_tier1_accesses = self.memory_tier1_hits + self.memory_tier1_misses - tier1_hit_rate = self.memory_tier1_hits / total_tier1_accesses if total_tier1_accesses > 0 else 0 - - tier2_durations = list(self.memory_tier2_durations) - tier2_avg_latency = statistics.mean(tier2_durations) if tier2_durations else 0 - - return { - 'uptime_seconds': int(uptime_seconds), - 'timestamp': datetime.utcnow().isoformat() + 'Z', - 'agent': { - 'total_requests': self.total_requests, - 'requests_by_agent': dict(self.requests_by_agent), - 'avg_response_time_ms': round(avg_response_time, 2), - 'p50_response_time_ms': round(p50, 2), - 'p95_response_time_ms': round(p95, 2), - 'p99_response_time_ms': round(p99, 2), - 'errors_total': len(self.request_errors), - 'requests_per_minute': round(requests_per_minute, 2), - 'streaming_requests': self.streaming_requests, - 'non_streaming_requests': self.non_streaming_requests - }, - 'concurrency': { - 'current': self.concurrent_requests, - 'max': self.max_concurrent_requests - }, - 'tools': { - 'total_calls': self.total_tool_calls, - 'success_rate': self.tool_successes_by_name and sum(self.tool_successes_by_name.values()) / self.total_tool_calls if self.total_tool_calls > 0 else 0, - 'top_tools': top_tools, - 'total_unique_tools': len(self.tool_calls_by_name) - }, - 'memory': { - 'tier1_hits': self.memory_tier1_hits, - 'tier1_misses': self.memory_tier1_misses, - 'tier1_hit_rate': round(tier1_hit_rate, 3), - 'tier2_queries': self.memory_tier2_queries, - 'tier2_avg_latency_ms': round(tier2_avg_latency, 2), - 'total_consolidations': self.memory_consolidations, - 'active_users': len(self.active_users) - }, - 'users': { - 'total_active': len(self.active_users), - 'top_users': dict(sorted(self.requests_by_user.items(), key=lambda x: x[1], reverse=True)[:5]) - } - } - - def get_recent_errors(self, limit: int = 20) -> List[Dict[str, Any]]: - """Get recent errors.""" - with self.lock: - return [ - { - 'timestamp': datetime.fromtimestamp(e['timestamp']).isoformat() + 'Z', - 'agent_type': e['agent_type'], - 'error': e['error'], - 'duration_ms': e['duration_ms'] - } - for e in list(self.request_errors)[-limit:] - ] - - def get_recent_tool_failures(self, limit: int = 20) -> List[Dict[str, Any]]: - """Get recent tool failures.""" - with self.lock: - return [ - { - 'timestamp': datetime.fromtimestamp(f['timestamp']).isoformat() + 'Z', - 'tool_name': f['tool_name'], - 'error': f['error'], - 'duration_ms': f['duration_ms'] - } - for f in list(self.recent_tool_failures)[-limit:] - ] - - def reset(self): - """Clear all metrics.""" - with self.lock: - self.__init__() - - -# Global metrics collector instance -_metrics_collector: Optional[MetricsCollector] = None - - -def get_metrics_collector() -> MetricsCollector: - """Get the global metrics collector instance.""" - global _metrics_collector - if _metrics_collector is None: - _metrics_collector = MetricsCollector() - return _metrics_collector diff --git a/services/OBSOLETE_core-ai/src/metrics/decorators.py b/services/OBSOLETE_core-ai/src/metrics/decorators.py deleted file mode 100644 index a0bcc8e..0000000 --- a/services/OBSOLETE_core-ai/src/metrics/decorators.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Metrics decorators for easy instrumentation. - -Provides decorators to automatically track performance metrics -for functions and async functions. -""" -import time -import functools -import logging -from typing import Callable, Any -from .collector import get_metrics_collector - -logger = logging.getLogger(__name__) - - -def track_tool_execution(func: Callable) -> Callable: - """ - Decorator to track tool execution metrics. - - Automatically records: - - Tool name - - Execution duration - - Success/failure status - - Error messages on failure - - Usage: - @track_tool_execution - async def my_tool(arg1, arg2): - ... - """ - @functools.wraps(func) - async def async_wrapper(*args, **kwargs): - metrics = get_metrics_collector() - tool_name = func.__name__ - start_time = time.time() - success = False - error = None - - try: - result = await func(*args, **kwargs) - success = True - return result - except Exception as e: - error = str(e) - raise - finally: - duration_ms = (time.time() - start_time) * 1000 - metrics.record_tool_execution( - tool_name=tool_name, - duration_ms=duration_ms, - success=success, - error=error - ) - if not success: - logger.warning(f"Tool {tool_name} failed after {duration_ms:.1f}ms: {error}") - - @functools.wraps(func) - def sync_wrapper(*args, **kwargs): - metrics = get_metrics_collector() - tool_name = func.__name__ - start_time = time.time() - success = False - error = None - - try: - result = func(*args, **kwargs) - success = True - return result - except Exception as e: - error = str(e) - raise - finally: - duration_ms = (time.time() - start_time) * 1000 - metrics.record_tool_execution( - tool_name=tool_name, - duration_ms=duration_ms, - success=success, - error=error - ) - if not success: - logger.warning(f"Tool {tool_name} failed after {duration_ms:.1f}ms: {error}") - - # Return appropriate wrapper based on whether function is async - import inspect - if inspect.iscoroutinefunction(func): - return async_wrapper - else: - return sync_wrapper - - -def track_duration(metric_name: str): - """ - Decorator to track function execution duration. - - Args: - metric_name: Name to use for the metric - - Usage: - @track_duration("database_query") - async def query_database(): - ... - """ - def decorator(func: Callable) -> Callable: - @functools.wraps(func) - async def async_wrapper(*args, **kwargs): - start_time = time.time() - try: - return await func(*args, **kwargs) - finally: - duration_ms = (time.time() - start_time) * 1000 - logger.debug(f"{metric_name}: {duration_ms:.1f}ms") - - @functools.wraps(func) - def sync_wrapper(*args, **kwargs): - start_time = time.time() - try: - return func(*args, **kwargs) - finally: - duration_ms = (time.time() - start_time) * 1000 - logger.debug(f"{metric_name}: {duration_ms:.1f}ms") - - import inspect - if inspect.iscoroutinefunction(func): - return async_wrapper - else: - return sync_wrapper - - return decorator diff --git a/services/OBSOLETE_core-ai/src/models/__init__.py b/services/OBSOLETE_core-ai/src/models/__init__.py deleted file mode 100644 index 417d909..0000000 --- a/services/OBSOLETE_core-ai/src/models/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Models for core-ai service""" - -from .embeddings_ollama import ( - OllamaEmbeddingClient, - get_embedding_client, - embed_text_async, - embed_batch_async -) - -__all__ = [ - "OllamaEmbeddingClient", - "get_embedding_client", - "embed_text_async", - "embed_batch_async", -] diff --git a/services/OBSOLETE_core-ai/src/models/embeddings_ollama.py b/services/OBSOLETE_core-ai/src/models/embeddings_ollama.py deleted file mode 100644 index eca12e6..0000000 --- a/services/OBSOLETE_core-ai/src/models/embeddings_ollama.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Ollama-based embedding client for text vectorization - -Uses Ollama's embedding API instead of local sentence-transformers. -This eliminates the need for PyTorch and heavy ML dependencies. -""" -import logging -import httpx -from typing import List, Optional -from src.config import get_settings - -logger = logging.getLogger(__name__) -settings = get_settings() - - -class OllamaEmbeddingClient: - """Client for generating text embeddings using Ollama""" - - def __init__( - self, - model_name: Optional[str] = None, - base_url: Optional[str] = None, - timeout: int = 30 - ): - """ - Initialize Ollama embedding client - - Args: - model_name: Embedding model name (default: nomic-embed-text) - base_url: Ollama base URL (default from settings) - timeout: Request timeout in seconds - """ - self.model_name = model_name or settings.embedding_model - self.base_url = (base_url or settings.ollama_base_url).rstrip("/") - self.timeout = timeout - self.dimension = settings.embedding_dimension - - logger.info(f"Initializing OllamaEmbeddingClient with model: {self.model_name}") - logger.info(f"Ollama URL: {self.base_url}") - - async def embed_text(self, text: str) -> List[float]: - """ - Generate embedding for a single text using Ollama - - Args: - text: Input text to embed - - Returns: - List of floats representing the embedding vector - """ - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.post( - f"{self.base_url}/api/embeddings", - json={ - "model": self.model_name, - "prompt": text - } - ) - response.raise_for_status() - result = response.json() - return result["embedding"] - - except Exception as e: - logger.error(f"Error generating embedding via Ollama: {e}") - raise - - async def embed_batch(self, texts: List[str]) -> List[List[float]]: - """ - Generate embeddings for multiple texts - - Args: - texts: List of input texts - - Returns: - List of embedding vectors - """ - embeddings = [] - for text in texts: - embedding = await self.embed_text(text) - embeddings.append(embedding) - return embeddings - - def get_dimension(self) -> int: - """ - Get embedding dimension - - Returns: - Embedding vector dimension - """ - return self.dimension - - -# Global instance -_embedding_client: Optional[OllamaEmbeddingClient] = None - - -def get_embedding_client() -> OllamaEmbeddingClient: - """ - Get or create global Ollama embedding client instance - - Returns: - OllamaEmbeddingClient instance - """ - global _embedding_client - if _embedding_client is None: - _embedding_client = OllamaEmbeddingClient() - return _embedding_client - - -async def embed_text_async(text: str) -> List[float]: - """ - Async wrapper for embedding text - - Args: - text: Input text - - Returns: - Embedding vector - """ - client = get_embedding_client() - return await client.embed_text(text) - - -async def embed_batch_async(texts: List[str]) -> List[List[float]]: - """ - Async wrapper for batch embedding - - Args: - texts: List of input texts - - Returns: - List of embedding vectors - """ - client = get_embedding_client() - return await client.embed_batch(texts) diff --git a/services/OBSOLETE_core-ai/src/prompts.py b/services/OBSOLETE_core-ai/src/prompts.py deleted file mode 100644 index 75fcbc9..0000000 --- a/services/OBSOLETE_core-ai/src/prompts.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -System Prompt Variants for Core AI - -This file contains minimal, clean prompts for the Core AI service. -""" - -PROMPTS = { - "minimal_agent": """You are a helpful assistant. You can answer questions. If you need information, use the available tools.""", - - "pydantic_agent": """You are Tatlock, a helpful personal assistant with the demeanor of a British butler. Address users as "sir" and maintain a formal yet personable tone. You are not overly apologetic and may be slightly snarky when appropriate. If an opportunity for a pun presents itself, you cannot resist. - -Your core responsibility: Verify facts before presenting them as truth. - -**IMPORTANT: The Steward's Counsel** -When a query is prefixed with "[Steward's analysis: ...]", this represents the household steward's expert assessment of the matter. The steward is highly experienced and their recommendations regarding which tools to employ are exceptionally valuable. While you maintain final discretion, the steward's counsel should be given considerable weight in your deliberations. If the steward recommends specific tools, there is typically sound reasoning behind the suggestion. - -You have access to two categories of tools: - -**Core Tools** (always available): -- web_search: ONLY for current events, news, research, or information not available through other tools -- calculate: Mathematical operations (precision is paramount) -- get_current_time/get_current_date: Time/date queries -- add_days_to_date/calculate_date_difference: Date calculations - -**Infrastructure Tools** (discovered from core-api, prefixed with "core_api__"): -These tools provide DIRECT system access. PREFER these over web searches when available: - -- DNS queries: core_api__dns_lookup_tools_dns_lookup_post - * Use for: A, AAAA, MX, TXT, CNAME, NS, SOA, PTR records - * Example: "lookup A records for github.com" → use DNS tool, NOT web_search - -- Web scraping: core_api__scrape_website_tools_scrape_post - * Use for: Extract content from specific URLs - -- Docker services: core_api__list_services*, core_api__get_service*, core_api__start_service*, core_api__stop_service* - * Use for: Manage sir's containerized services - -- Network management: core_api__list_domains*, core_api__list_ports*, core_api__get_proxy_host* - * Use for: Infrastructure configuration - -- Monitoring: core_api__list_monitors*, core_api__get_monitor*, core_api__create_monitor* - * Use for: System health checks - -**Tool Selection Priority:** -1. If an infrastructure tool exists for the task → use it (more reliable than web search) -2. If no infrastructure tool exists → use web_search -3. For general knowledge → answer directly (no tool needed) - -Be concise unless details are specifically requested. When using tools, acknowledge them naturally in your dignified manner.""" -} - - -def get_prompt(variant: str = "minimal_agent") -> str: - """ - Get a system prompt variant. - """ - return PROMPTS.get(variant, PROMPTS["minimal_agent"]) \ No newline at end of file diff --git a/services/OBSOLETE_core-ai/src/tools/__init__.py b/services/OBSOLETE_core-ai/src/tools/__init__.py deleted file mode 100644 index 10b49ef..0000000 --- a/services/OBSOLETE_core-ai/src/tools/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Tools module for Core-AI ADK agent. - -This module provides tool registration and management for the ADK agent. -Tools can make REST calls to core-api or operate independently. -""" -from src.tools.registry import ( - get_agent_tools, - register_tool, - get_all_tools, - discover_and_register_tools, - clear_registry -) - -# Import local tools to trigger registration -# This must happen before get_agent_tools() is called -import src.tools.local # noqa: F401 - -__all__ = [ - "get_agent_tools", - "register_tool", - "get_all_tools", - "discover_and_register_tools", - "clear_registry", -] diff --git a/services/OBSOLETE_core-ai/src/tools/infrastructure/__init__.py b/services/OBSOLETE_core-ai/src/tools/infrastructure/__init__.py deleted file mode 100644 index c902c53..0000000 --- a/services/OBSOLETE_core-ai/src/tools/infrastructure/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Infrastructure Tools - Docker container and service management tools. - -This package provides tools for managing infrastructure systems: -- containers.py: Docker container lifecycle management (4 tools) -- services.py: Docker Compose service management (3 tools) -- monitoring.py: System and container resource monitoring (2 tools) -""" -from src.tools.infrastructure.containers import ( - docker_list_containers, - docker_manage_container, - docker_inspect_container, - docker_container_logs, -) - -from src.tools.infrastructure.services import ( - list_services, - manage_service, - service_status, -) - -from src.tools.infrastructure.monitoring import ( - system_resources, - container_resources, -) - -__all__ = [ - # Container tools - "docker_list_containers", - "docker_manage_container", - "docker_inspect_container", - "docker_container_logs", - # Service tools - "list_services", - "manage_service", - "service_status", - # Monitoring tools - "system_resources", - "container_resources", -] diff --git a/services/OBSOLETE_core-ai/src/tools/infrastructure/containers.py b/services/OBSOLETE_core-ai/src/tools/infrastructure/containers.py deleted file mode 100644 index b76b955..0000000 --- a/services/OBSOLETE_core-ai/src/tools/infrastructure/containers.py +++ /dev/null @@ -1,490 +0,0 @@ -""" -Docker Container Management Tools - -Provides tools for container lifecycle operations and diagnostics. -All operations go through core-api for centralized logging. -""" -import logging -import httpx -from typing import Optional, Literal -from datetime import datetime -from src.config import get_settings -from src.tools.registry import register_tool - -logger = logging.getLogger(__name__) -settings = get_settings() - - -def _format_uptime(started_at: str) -> str: - """ - Convert ISO timestamp to human-readable uptime. - - Args: - started_at: ISO timestamp string - - Returns: - Human-readable uptime (e.g., "3 days", "7 hours", "45 minutes") - """ - try: - # Handle both formats: with and without timezone - if 'Z' in started_at: - start_time = datetime.fromisoformat(started_at.replace('Z', '+00:00')) - elif '+' in started_at or started_at.endswith('00:00'): - start_time = datetime.fromisoformat(started_at) - else: - start_time = datetime.fromisoformat(started_at + '+00:00') - - uptime_delta = datetime.now(start_time.tzinfo) - start_time - - days = uptime_delta.days - hours = uptime_delta.seconds // 3600 - minutes = (uptime_delta.seconds % 3600) // 60 - - if days > 0: - return f"{days} day{'s' if days != 1 else ''}" - elif hours > 0: - return f"{hours} hour{'s' if hours != 1 else ''}" - else: - return f"{minutes} minute{'s' if minutes != 1 else ''}" - except Exception as e: - logger.warning(f"Failed to parse uptime from '{started_at}': {e}") - return "unknown" - - -def _format_ports(ports: list) -> str: - """ - Format container port bindings for display. - - Args: - ports: Docker API port bindings - - Returns: - Formatted port string (e.g., "80→8080, 443→8443") - """ - if not ports: - return "none" - - port_mappings = [] - for port_data in ports: - if isinstance(port_data, dict): - private_port = port_data.get('PrivatePort') - public_port = port_data.get('PublicPort') - if public_port and private_port: - port_mappings.append(f"{private_port}→{public_port}") - elif private_port: - port_mappings.append(f"{private_port} (internal)") - - return ", ".join(port_mappings) if port_mappings else "none" - - -@register_tool -async def docker_list_containers( - status: Optional[Literal["all", "running", "stopped", "paused"]] = "running" -) -> str: - """ - List Docker containers with status and resource usage. - - Args: - status: Filter by status - "all", "running", "stopped", or "paused" - Defaults to "running" to show only active containers. - - Returns: - Formatted list of containers with details including: - - Container name and status - - Uptime (for running containers) - - Port mappings - - Image information - - Examples: - docker_list_containers("all") # All containers - docker_list_containers("running") # Only running (default) - docker_list_containers("stopped") # Only stopped - """ - logger.info(f"Listing Docker containers (status filter: {status})") - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - f"{settings.core_api_base_url}/infrastructure/containers", - params={"status": status} - ) - response.raise_for_status() - containers = response.json() - - if not containers: - return f"No {status} containers found." - - # Count by status - running_count = sum(1 for c in containers if c.get('State') == 'running') - stopped_count = len(containers) - running_count - - # Build formatted output - lines = [f"Containers ({running_count} running, {stopped_count} stopped):"] - lines.append("") - - for container in containers: - # Extract container name (strip leading '/') - names = container.get('Names', ['unknown']) - name = names[0].lstrip('/') if names else 'unknown' - - state = container.get('State', 'unknown') - status_info = container.get('Status', '') - - lines.append(f"• {name}") - lines.append(f" Status: {state}") - - # Add status info - if status_info: - lines.append(f" Info: {status_info}") - - # Port mappings - ports = _format_ports(container.get('Ports', [])) - if ports != "none": - lines.append(f" Ports: {ports}") - - # Image - image = container.get('Image', 'unknown') - if image != 'unknown': - # Shorten long image names - if len(image) > 50: - image = image[:47] + "..." - lines.append(f" Image: {image}") - - lines.append("") # Blank line between containers - - return "\n".join(lines) - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to list containers: HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error listing containers: {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg - - -@register_tool -async def docker_manage_container( - container: str, - action: Literal["start", "stop", "restart"] -) -> str: - """ - Manage Docker container state. - - This high-level tool handles container lifecycle operations. - - Args: - container: Container name or ID (e.g., "nginx", "core-ai") - action: Action to perform: - - start: Start a stopped container - - stop: Stop a running container - - restart: Stop and start a container - - Returns: - Success message or error details - - Examples: - docker_manage_container("nginx", "restart") - docker_manage_container("core-ai", "stop") - docker_manage_container("ollama", "start") - - Error Handling: - - Container not found → Returns error with suggestion to check docker_list_containers() - - Already in target state → Reports current state - - Permission denied → Reports error for user escalation - """ - logger.info(f"Managing container '{container}': {action}") - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{settings.core_api_base_url}/infrastructure/containers/{container}/{action}" - ) - - if response.status_code == 404: - return f"Container '{container}' not found. Use docker_list_containers() to see available containers." - elif response.status_code == 304: - return f"Container '{container}' is already in the target state for action '{action}'." - elif response.status_code == 409: - return f"Cannot {action} container '{container}': state conflict (may already be stopped/started)." - elif response.status_code == 501: - return f"Action '{action}' is not yet implemented on the server." - - response.raise_for_status() - result = response.json() - - message = result.get('message', f"Action '{action}' completed for container '{container}'.") - return f"✓ {message}" - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to {action} container '{container}': HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error managing container '{container}': {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg - - -@register_tool -async def docker_inspect_container( - container: str, - details: Literal["summary", "full", "resources"] = "summary" -) -> str: - """ - Get detailed container information. - - Args: - container: Container name or ID (e.g., "nginx", "core-ai") - details: Level of detail to return: - - summary: Name, status, uptime, ports, image (default) - - full: Add environment vars, mounts, network config - - resources: Focus on resource limits and configuration - - Returns: - Formatted container inspection based on detail level - - Examples: - docker_inspect_container("nginx") # Quick summary - docker_inspect_container("nginx", "full") # Complete details - docker_inspect_container("core-ai", "resources") # Resource limits - """ - logger.info(f"Inspecting container '{container}' (detail level: {details})") - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - f"{settings.core_api_base_url}/infrastructure/containers/{container}", - params={"details": details} - ) - - if response.status_code == 404: - return f"Container '{container}' not found. Use docker_list_containers() to see available containers." - - response.raise_for_status() - info = response.json() - - # Extract key information - state = info.get('State', {}) - config = info.get('Config', {}) - network_settings = info.get('NetworkSettings', {}) - host_config = info.get('HostConfig', {}) - - name = info.get('Name', '').lstrip('/') - status = state.get('Status', 'unknown') - running = state.get('Running', False) - - lines = [f"Container: {name}"] - lines.append(f"Status: {status}") - - if details == "summary": - # Summary: Basic info - if running and 'StartedAt' in state: - uptime = _format_uptime(state['StartedAt']) - lines.append(f"Uptime: {uptime}") - elif 'FinishedAt' in state and state.get('ExitCode') is not None: - exit_code = state.get('ExitCode') - lines.append(f"Exit Code: {exit_code}") - - # Ports - ports = network_settings.get('Ports', {}) - if ports: - lines.append(f"\nPorts:") - for internal_port, bindings in ports.items(): - if bindings: - for binding in bindings: - host_ip = binding.get('HostIp', '0.0.0.0') - host_port = binding.get('HostPort') - lines.append(f" {internal_port} → {host_ip}:{host_port}") - else: - lines.append(f" {internal_port} (not exposed)") - - # Image - image = config.get('Image', 'unknown') - lines.append(f"\nImage: {image}") - - elif details == "full": - # Full: Everything - if running and 'StartedAt' in state: - uptime = _format_uptime(state['StartedAt']) - lines.append(f"Uptime: {uptime}") - elif 'FinishedAt' in state: - lines.append(f"Exit Code: {state.get('ExitCode', 'N/A')}") - - # Image - image = config.get('Image', 'unknown') - lines.append(f"\nImage: {image}") - - # Ports - ports = network_settings.get('Ports', {}) - if ports: - lines.append(f"\nPorts:") - for internal_port, bindings in ports.items(): - if bindings: - for binding in bindings: - host_ip = binding.get('HostIp', '0.0.0.0') - host_port = binding.get('HostPort') - lines.append(f" {internal_port} → {host_ip}:{host_port}") - - # Environment (show only non-sensitive keys) - env = config.get('Env', []) - if env: - lines.append(f"\nEnvironment ({len(env)} variables):") - # Show first 10 variable names only (not values, for security) - for var in env[:10]: - if '=' in var: - key = var.split('=')[0] - lines.append(f" {key}") - if len(env) > 10: - lines.append(f" ... and {len(env) - 10} more") - - # Mounts - mounts = info.get('Mounts', []) - if mounts: - lines.append(f"\nMounts ({len(mounts)}):") - for mount in mounts[:5]: - mount_type = mount.get('Type', 'unknown') - source = mount.get('Source', '')[:40] # Truncate long paths - destination = mount.get('Destination', '') - lines.append(f" {mount_type}: {source} → {destination}") - if len(mounts) > 5: - lines.append(f" ... and {len(mounts) - 5} more") - - # Networks - networks = network_settings.get('Networks', {}) - if networks: - lines.append(f"\nNetworks:") - for net_name, net_config in networks.items(): - ip = net_config.get('IPAddress', 'N/A') - gateway = net_config.get('Gateway', 'N/A') - lines.append(f" {net_name}:") - lines.append(f" IP: {ip}") - lines.append(f" Gateway: {gateway}") - - elif details == "resources": - # Resources: Limits and configuration - lines.append(f"\nResource Configuration:") - - # Memory - memory_limit = host_config.get('Memory', 0) - if memory_limit > 0: - # Format bytes - memory_mb = memory_limit / (1024 * 1024) - lines.append(f" Memory Limit: {memory_mb:.1f} MB") - else: - lines.append(f" Memory Limit: unlimited") - - memory_reservation = host_config.get('MemoryReservation', 0) - if memory_reservation > 0: - mem_res_mb = memory_reservation / (1024 * 1024) - lines.append(f" Memory Reservation: {mem_res_mb:.1f} MB") - - # CPU - cpu_shares = host_config.get('CpuShares', 0) - if cpu_shares > 0: - lines.append(f" CPU Shares: {cpu_shares}") - - nano_cpus = host_config.get('NanoCpus', 0) - if nano_cpus > 0: - cpus = nano_cpus / 1_000_000_000 - lines.append(f" CPU Limit: {cpus:.2f} CPUs") - - cpu_quota = host_config.get('CpuQuota', 0) - if cpu_quota > 0: - lines.append(f" CPU Quota: {cpu_quota}") - - # Restart policy - restart_policy = host_config.get('RestartPolicy', {}) - policy_name = restart_policy.get('Name', 'no') - lines.append(f"\n Restart Policy: {policy_name}") - - # Image - image = config.get('Image', 'unknown') - lines.append(f"\nImage: {image}") - - return "\n".join(lines) - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to inspect container '{container}': HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error inspecting container '{container}': {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg - - -@register_tool -async def docker_container_logs( - container: str, - lines: int = 50, - since: Optional[str] = None -) -> str: - """ - Retrieve Docker container logs. - - Args: - container: Container name or ID (e.g., "nginx", "core-ai") - lines: Number of recent log lines to retrieve - Default: 50, Maximum: 500 (to avoid overwhelming output) - since: Optional time filter for logs (not yet fully implemented): - - "1h" = last hour - - "30m" = last 30 minutes - - Note: Currently ignored server-side, uses line limit only - - Returns: - Container logs with timestamps - Each line prefixed with timestamp if available - - Examples: - docker_container_logs("nginx") # Last 50 lines - docker_container_logs("nginx", lines=100) # Last 100 lines - docker_container_logs("core-ai", lines=200) # Last 200 lines - """ - logger.info(f"Retrieving logs for container '{container}' (lines={lines})") - - # Clamp lines to reasonable limit - lines = min(max(1, lines), 500) - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=15.0) as client: - params = {"lines": lines} - if since: - params["since"] = since - - response = await client.get( - f"{settings.core_api_base_url}/infrastructure/containers/{container}/logs", - params=params - ) - - if response.status_code == 404: - return f"Container '{container}' not found. Use docker_list_containers() to see available containers." - - response.raise_for_status() - result = response.json() - - logs = result.get('logs', '') - - if not logs or logs.strip() == '': - return f"No logs found for container '{container}' (container may be newly started or have no output)." - - # Format header - time_filter = f" (since {since})" if since else "" - header = f"Container '{container}' logs (last {lines} lines{time_filter}):\n" - header += "=" * 60 + "\n\n" - - return header + logs - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to retrieve logs for container '{container}': HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error retrieving logs for container '{container}': {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg diff --git a/services/OBSOLETE_core-ai/src/tools/infrastructure/monitoring.py b/services/OBSOLETE_core-ai/src/tools/infrastructure/monitoring.py deleted file mode 100644 index e28f50e..0000000 --- a/services/OBSOLETE_core-ai/src/tools/infrastructure/monitoring.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -System and Container Monitoring Tools - -Provides tools for monitoring resource usage at system and container levels. -All operations go through core-api for centralized logging. -""" -import logging -import httpx -from typing import Optional -from src.config import get_settings -from src.tools.registry import register_tool - -logger = logging.getLogger(__name__) -settings = get_settings() - - -@register_tool -async def system_resources() -> str: - """ - Get overall system resource usage. - - Provides comprehensive system-level metrics including: - - CPU usage and core count - - Memory usage (total, used, available) - - Disk usage (total, used, available) - - Network statistics (if available) - - Returns: - Formatted system resource report - - Examples: - system_resources() # Get current system metrics - """ - logger.info("Getting system resources") - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - f"{settings.core_api_base_url}/infrastructure/resources/system" - ) - response.raise_for_status() - resources = response.json() - - # Build formatted output - lines = ["System Resources:"] - lines.append("") - - # CPU - cpu = resources.get('cpu', {}) - if cpu: - cores = cpu.get('cores') - usage = cpu.get('usage_percent') - load_avg = cpu.get('load_average', []) - - lines.append("CPU:") - if cores: - lines.append(f" Cores: {cores}") - if usage is not None: - lines.append(f" Usage: {usage:.1f}%") - if load_avg: - load_str = ", ".join(f"{l:.2f}" for l in load_avg) - lines.append(f" Load Average: {load_str}") - lines.append("") - - # Memory - memory = resources.get('memory', {}) - if memory: - total = memory.get('total_bytes') - used = memory.get('used_bytes') - available = memory.get('available_bytes') - usage_pct = memory.get('usage_percent') - - lines.append("Memory:") - if total: - total_gb = total / (1024**3) - lines.append(f" Total: {total_gb:.1f} GB") - if used: - used_gb = used / (1024**3) - lines.append(f" Used: {used_gb:.1f} GB") - if available: - avail_gb = available / (1024**3) - lines.append(f" Available: {avail_gb:.1f} GB") - if usage_pct is not None: - lines.append(f" Usage: {usage_pct:.1f}%") - lines.append("") - - # Disk - disk = resources.get('disk', {}) - if disk: - total = disk.get('total_bytes') - used = disk.get('used_bytes') - available = disk.get('available_bytes') - usage_pct = disk.get('usage_percent') - - lines.append("Disk:") - if total: - total_gb = total / (1024**3) - lines.append(f" Total: {total_gb:.1f} GB") - if used: - used_gb = used / (1024**3) - lines.append(f" Used: {used_gb:.1f} GB") - if available: - avail_gb = available / (1024**3) - lines.append(f" Available: {avail_gb:.1f} GB") - if usage_pct is not None: - lines.append(f" Usage: {usage_pct:.1f}%") - lines.append("") - - # Network - network = resources.get('network', {}) - if network: - interfaces = network.get('interfaces', {}) - if interfaces: - lines.append("Network:") - for iface_name, iface_data in interfaces.items(): - rx = iface_data.get('rx_bytes', 0) - tx = iface_data.get('tx_bytes', 0) - rx_gb = rx / (1024**3) - tx_gb = tx / (1024**3) - lines.append(f" {iface_name}:") - lines.append(f" RX: {rx_gb:.2f} GB") - lines.append(f" TX: {tx_gb:.2f} GB") - - return "\n".join(lines) - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to get system resources: HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error getting system resources: {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg - - -@register_tool -async def container_resources( - container: Optional[str] = None -) -> str: - """ - Get container-specific resource usage. - - Provides real-time resource metrics for containers including: - - CPU usage percentage - - Memory usage (current, limit, percentage) - - Network I/O (received, transmitted) - - Block I/O (read, write) - - Args: - container: Specific container name or ID (optional) - If omitted, returns stats for all running containers - - Returns: - Formatted container resource report - - Examples: - container_resources() # All containers - container_resources("nginx") # Specific container - container_resources("core-ai") # Another specific container - """ - logger.info(f"Getting container resources (container={container})") - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=15.0) as client: - params = {} - if container: - params["container"] = container - - response = await client.get( - f"{settings.core_api_base_url}/infrastructure/resources/containers", - params=params - ) - - if response.status_code == 404: - return f"Container '{container}' not found. Use docker_list_containers() to see available containers." - - response.raise_for_status() - resources = response.json() - - if not resources: - if container: - return f"No resource data available for container '{container}'." - else: - return "No containers are currently running." - - # Build formatted output - if container: - lines = [f"Container '{container}' Resources:"] - else: - lines = [f"Container Resources ({len(resources)} containers):"] - lines.append("") - - for res in resources: - name = res.get('name', 'unknown') - cpu = res.get('cpu_percent') - mem_usage = res.get('memory_usage_bytes') - mem_limit = res.get('memory_limit_bytes') - mem_pct = res.get('memory_percent') - net_rx = res.get('network_rx_bytes') - net_tx = res.get('network_tx_bytes') - block_read = res.get('block_read_bytes') - block_write = res.get('block_write_bytes') - - lines.append(f"• {name}") - - # CPU - if cpu is not None: - lines.append(f" CPU: {cpu:.1f}%") - - # Memory - if mem_usage is not None and mem_limit is not None: - mem_usage_mb = mem_usage / (1024**2) - mem_limit_mb = mem_limit / (1024**2) - mem_line = f" Memory: {mem_usage_mb:.1f} MB / {mem_limit_mb:.1f} MB" - if mem_pct is not None: - mem_line += f" ({mem_pct:.1f}%)" - lines.append(mem_line) - elif mem_usage is not None: - mem_usage_mb = mem_usage / (1024**2) - lines.append(f" Memory: {mem_usage_mb:.1f} MB") - - # Network - if net_rx is not None and net_tx is not None: - net_rx_mb = net_rx / (1024**2) - net_tx_mb = net_tx / (1024**2) - lines.append(f" Network: RX {net_rx_mb:.1f} MB / TX {net_tx_mb:.1f} MB") - - # Block I/O - if block_read is not None and block_write is not None: - block_read_mb = block_read / (1024**2) - block_write_mb = block_write / (1024**2) - lines.append(f" Block I/O: Read {block_read_mb:.1f} MB / Write {block_write_mb:.1f} MB") - - lines.append("") # Blank line between containers - - return "\n".join(lines) - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to get container resources: HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error getting container resources: {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg diff --git a/services/OBSOLETE_core-ai/src/tools/infrastructure/services.py b/services/OBSOLETE_core-ai/src/tools/infrastructure/services.py deleted file mode 100644 index 366c212..0000000 --- a/services/OBSOLETE_core-ai/src/tools/infrastructure/services.py +++ /dev/null @@ -1,283 +0,0 @@ -""" -Docker Service Management Tools - -Provides tools for managing Docker Compose services/stacks. -All operations go through core-api for centralized logging. -""" -import logging -import httpx -from typing import Optional, Literal -from src.config import get_settings -from src.tools.registry import register_tool - -logger = logging.getLogger(__name__) -settings = get_settings() - - -@register_tool -async def list_services( - stack: Optional[str] = None -) -> str: - """ - List Docker Compose services (stacks). - - Args: - stack: Optional filter by stack name (case-insensitive) - If provided, returns only matching stack. - If omitted, returns all stacks. - - Returns: - Formatted list of services with: - - Service/stack name and status - - Container counts (running/total) - - Exposed ports - - Configured domains (from reverse proxy) - - Examples: - list_services() # All services - list_services("portainer") # Specific stack - list_services("core") # Stacks matching "core" - """ - logger.info(f"Listing services (stack filter: {stack})") - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=10.0) as client: - params = {} - if stack: - params["stack"] = stack - - response = await client.get( - f"{settings.core_api_base_url}/infrastructure/services", - params=params - ) - response.raise_for_status() - services = response.json() - - if not services: - if stack: - return f"No services found matching '{stack}'." - else: - return "No services found." - - # Count active services - active_count = sum(1 for s in services if s.get('status') == 'active') - total_count = len(services) - - # Build formatted output - lines = [f"Services ({active_count} active, {total_count} total):"] - lines.append("") - - for service in services: - name = service.get('name', 'unknown') - status = service.get('status', 'unknown') - running = service.get('containers_running', 0) - total = service.get('containers_total', 0) - - lines.append(f"• {name}") - lines.append(f" Status: {status}") - lines.append(f" Containers: {running}/{total} running") - - # Ports - ports = service.get('ports', []) - if ports: - port_str = ", ".join(str(p) for p in ports) - lines.append(f" Ports: {port_str}") - - # Domains - domains = service.get('domains', []) - if domains: - domain_str = ", ".join(domains) - lines.append(f" Domains: {domain_str}") - - lines.append("") # Blank line between services - - return "\n".join(lines) - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to list services: HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error listing services: {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg - - -@register_tool -async def manage_service( - service: str, - action: Literal["start", "stop", "restart", "scale"], - replicas: Optional[int] = None -) -> str: - """ - Manage Docker service lifecycle and scaling. - - Args: - service: Service/stack name (e.g., "portainer", "core-ai") - action: Action to perform: - - start: Start all containers in the service - - stop: Stop all containers in the service - - restart: Stop and start the service - - scale: Change number of replicas (requires replicas parameter) - replicas: Number of replicas (required only for scale action) - - Returns: - Success message or error details - - Examples: - manage_service("web", "restart") - manage_service("worker", "scale", replicas=3) - manage_service("portainer", "stop") - - Error Handling: - - Service not found → Returns error with suggestion to check list_services() - - Scale without replicas → Returns error asking for replicas parameter - - Invalid action → Returns error with valid actions list - """ - logger.info(f"Managing service '{service}': action={action}, replicas={replicas}") - - # Validate scale action has replicas - if action == "scale" and replicas is None: - return "Error: 'scale' action requires 'replicas' parameter. Example: manage_service('web', 'scale', replicas=3)" - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=60.0) as client: - # Build request body - body = {"action": action} - if replicas is not None: - body["replicas"] = replicas - - response = await client.post( - f"{settings.core_api_base_url}/infrastructure/services/{service}/manage", - json=body - ) - - if response.status_code == 404: - return f"Service '{service}' not found. Use list_services() to see available services." - elif response.status_code == 400: - error_detail = response.json().get('detail', 'Bad request') - return f"Invalid request: {error_detail}" - elif response.status_code == 501: - return f"Action '{action}' is not yet implemented on the server." - - response.raise_for_status() - result = response.json() - - message = result.get('message', f"Action '{action}' completed for service '{service}'.") - return f"✓ {message}" - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to {action} service '{service}': HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error managing service '{service}': {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg - - -@register_tool -async def service_status( - service: str -) -> str: - """ - Get detailed service status and health. - - Provides comprehensive information about a service including: - - Overall status and replica health - - Individual container statuses - - Resource usage summary - - Recent events (if available) - - Args: - service: Service/stack name (e.g., "portainer", "nginx") - - Returns: - Detailed service status report - - Examples: - service_status("web") - service_status("portainer") - service_status("core-ai") - """ - logger.info(f"Getting status for service '{service}'") - - try: - # Call core-api infrastructure endpoint - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - f"{settings.core_api_base_url}/infrastructure/services/{service}/status" - ) - - if response.status_code == 404: - return f"Service '{service}' not found. Use list_services() to see available services." - - response.raise_for_status() - status = response.json() - - # Build formatted output - lines = [f"Service: {status.get('name', service)}"] - lines.append(f"Status: {status.get('status', 'unknown')}") - - # Stack info - stack_id = status.get('stack_id') - if stack_id: - lines.append(f"Stack ID: {stack_id}") - - # Replica status - replica_status = status.get('replica_status') - if replica_status: - lines.append(f"Replicas: {replica_status}") - - # Containers - containers = status.get('containers', []) - if containers: - lines.append(f"\nContainers ({len(containers)}):") - for container in containers: - name = container.get('name', 'unknown') - state = container.get('status', 'unknown') - health = container.get('health', 'N/A') - uptime = container.get('uptime', 'N/A') - - lines.append(f" • {name}") - lines.append(f" Status: {state}") - if health != 'N/A': - lines.append(f" Health: {health}") - if uptime != 'N/A': - lines.append(f" Uptime: {uptime}") - - # Resources - resources = status.get('resources', {}) - if resources: - lines.append(f"\nResource Usage:") - memory = resources.get('memory_total') - if memory: - lines.append(f" Memory: {memory}") - cpu = resources.get('cpu_usage') - if cpu: - lines.append(f" CPU: {cpu}") - - # Recent events - events = status.get('recent_events', []) - if events: - lines.append(f"\nRecent Events ({len(events)}):") - for event in events[:5]: # Show max 5 events - time = event.get('time', 'unknown') - action = event.get('action', 'unknown') - target = event.get('container', 'unknown') - lines.append(f" • {time} - {action} ({target})") - if len(events) > 5: - lines.append(f" ... and {len(events) - 5} more events") - - return "\n".join(lines) - - except httpx.HTTPStatusError as e: - error_msg = f"Failed to get status for service '{service}': HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error getting service status '{service}': {str(e)}" - logger.error(error_msg, exc_info=True) - return error_msg diff --git a/services/OBSOLETE_core-ai/src/tools/local.py b/services/OBSOLETE_core-ai/src/tools/local.py deleted file mode 100644 index a29a5a1..0000000 --- a/services/OBSOLETE_core-ai/src/tools/local.py +++ /dev/null @@ -1,267 +0,0 @@ -""" -Local utility tools for the AI agent. - -These tools run locally in core-ai and don't require REST calls. -They provide basic utilities like time, date, calculations, and web search. -""" -import logging -from datetime import datetime, timedelta -from typing import Optional -import pytz -import httpx -from src.tools.registry import register_tool - -logger = logging.getLogger(__name__) - - -@register_tool -async def get_current_time(timezone: str = "UTC") -> str: - """ - Get the current time in a specific timezone. - - Args: - timezone: Timezone name (e.g., "UTC", "Europe/Amsterdam", "America/New_York", "Asia/Tokyo") - Use IANA timezone database names. Defaults to "UTC". - - Returns: - Current time as formatted string with timezone information - - Examples: - - get_current_time("Europe/Amsterdam") -> "2025-11-30 15:30:45 CET" - - get_current_time("America/New_York") -> "2025-11-30 09:30:45 EST" - - get_current_time() -> "2025-11-30 14:30:45 UTC" - """ - logger.info(f"Getting current time in timezone: {timezone}") - - try: - # Get timezone object - tz = pytz.timezone(timezone) - - # Get current time in that timezone - now = datetime.now(tz) - - # Format: "2025-11-30 15:30:45 CET" - formatted_time = now.strftime("%Y-%m-%d %H:%M:%S %Z") - - logger.info(f"Current time in {timezone}: {formatted_time}") - return formatted_time - - except pytz.exceptions.UnknownTimeZoneError: - error_msg = f"Error: Unknown timezone '{timezone}'. Use IANA timezone names like 'Europe/Amsterdam', 'America/New_York', 'Asia/Tokyo', etc." - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Error getting time: {str(e)}" - logger.error(error_msg) - return error_msg - - -@register_tool -async def get_current_date() -> str: - """ - Get the current date. - - Returns: - Current date in YYYY-MM-DD format - """ - logger.info("Getting current date") - return datetime.utcnow().date().isoformat() - - -@register_tool -async def calculate_date_difference(date1: str, date2: str) -> str: - """ - Calculate the difference between two dates. - - Args: - date1: First date in YYYY-MM-DD format - date2: Second date in YYYY-MM-DD format - - Returns: - Human-readable description of the difference - """ - logger.info(f"Calculating difference between {date1} and {date2}") - - try: - d1 = datetime.fromisoformat(date1) - d2 = datetime.fromisoformat(date2) - - diff = abs((d2 - d1).days) - - if diff == 0: - return "The dates are the same day" - elif diff == 1: - return "1 day apart" - else: - return f"{diff} days apart" - - except ValueError as e: - logger.error(f"Invalid date format: {e}") - return f"Error: Invalid date format. Please use YYYY-MM-DD format." - - -@register_tool -async def add_days_to_date(date: str, days: int) -> str: - """ - Add or subtract days from a date. - - Args: - date: Starting date in YYYY-MM-DD format - days: Number of days to add (negative to subtract) - - Returns: - Resulting date in YYYY-MM-DD format - """ - logger.info(f"Adding {days} days to {date}") - - try: - d = datetime.fromisoformat(date) - result = d + timedelta(days=days) - return result.date().isoformat() - except ValueError as e: - logger.error(f"Invalid date format: {e}") - return f"Error: Invalid date format. Please use YYYY-MM-DD format." - - -@register_tool -async def calculate(expression: str) -> str: - """ - Perform basic mathematical calculations. - - Supports: +, -, *, /, //, %, ** (power), parentheses - - Args: - expression: Mathematical expression to evaluate (e.g., "2 + 2", "10 * (5 + 3)") - - Returns: - Result of the calculation as a string - """ - logger.info(f"Calculating: {expression}") - - try: - # Security: Only allow safe mathematical operations - # Using eval() with restricted namespace - allowed_names = { - "abs": abs, - "round": round, - "min": min, - "max": max, - "sum": sum, - } - - # Remove any potentially dangerous characters - dangerous_chars = ["_", "import", "exec", "eval", "open", "file", "__"] - for char in dangerous_chars: - if char in expression: - return f"Error: Invalid expression - contains forbidden pattern '{char}'" - - # Evaluate the expression - result = eval(expression, {"__builtins__": {}}, allowed_names) - - logger.info(f"Calculation result: {result}") - return str(result) - - except SyntaxError: - return "Error: Invalid mathematical expression syntax" - except ZeroDivisionError: - return "Error: Division by zero" - except Exception as e: - logger.error(f"Calculation error: {e}") - return f"Error: Could not evaluate expression - {type(e).__name__}" - - -@register_tool -async def web_search(query: str, category: str = "general", max_results: int = 10) -> str: - """ - Search the web using SearXNG metasearch engine. - - Aggregates results from multiple search engines (Google, Bing, DuckDuckGo, etc.) - while maintaining privacy - no tracking or data collection. - - Args: - query: Search query string (e.g., "Python programming best practices") - category: Search category - options: - "general" (default) - Web search - "images" - Image search - "videos" - Video search - "news" - News articles - "it" - Programming/technical (StackOverflow, GitHub, docs) - "science" - Academic (arXiv, PubMed, Semantic Scholar) - "map" - Geographic/location - "music" - Music/audio - "files" - File repositories - max_results: Maximum number of results to return (default: 5, max: 20) - - Returns: - Formatted search results with titles, URLs, and descriptions - - Examples: - - web_search("kubernetes deployment strategies") - - web_search("docker best practices", category="it") - - web_search("climate change research", category="science") - """ - logger.info(f"Web search: query='{query}', category='{category}', max_results={max_results}") - - # Emit tool call event for status tracking - try: - from src.agents.tool_events import get_tool_emitter - emitter = get_tool_emitter() - emitter.emit("web_search", {"query": query, "category": category}) - except Exception as e: - logger.warning(f"Failed to emit tool event: {e}") - - try: - # Limit max_results to prevent overwhelming responses - max_results = min(max_results, 20) - - # Call SearXNG JSON API - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - "http://searxng:8080/search", - params={ - "q": query, - "format": "json", - "categories": category - } - ) - response.raise_for_status() - - data = response.json() - results = data.get("results", []) - - if not results: - return f"No results found for: {query}" - - # Format results for LLM consumption - formatted_results = [] - for i, result in enumerate(results[:max_results], 1): - title = result.get("title", "No title") - url = result.get("url", "") - content = result.get("content", "No description available") - engine = result.get("engine", "unknown") - - formatted_results.append( - f"{i}. **{title}**\n" - f" URL: {url}\n" - f" {content}\n" - f" (Source: {engine})" - ) - - summary = f"Found {len(results)} total results for '{query}' (showing top {len(formatted_results)}):\n\n" - summary += "\n\n".join(formatted_results) - - logger.info(f"Web search completed: {len(formatted_results)} results returned") - return summary - - except httpx.TimeoutException: - error_msg = "Web search timed out. The search engine may be slow or unavailable." - logger.error(error_msg) - return error_msg - except httpx.HTTPStatusError as e: - error_msg = f"Web search failed with HTTP {e.response.status_code}" - logger.error(error_msg) - return error_msg - except Exception as e: - error_msg = f"Web search error: {str(e)}" - logger.error(error_msg) - return error_msg diff --git a/services/OBSOLETE_core-ai/src/tools/openapi_discovery.py b/services/OBSOLETE_core-ai/src/tools/openapi_discovery.py deleted file mode 100644 index b54518e..0000000 --- a/services/OBSOLETE_core-ai/src/tools/openapi_discovery.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -OpenAPI Tool Discovery - -Dynamically discovers and creates tools from core-api's OpenAPI specification. -This allows core-ai to automatically use infrastructure management endpoints -without manual tool definition. - -Architecture: -- Core tools (local.py): Essential tools always available (web_search, calculate, etc.) -- OpenAPI tools (this module): Infrastructure/automation endpoints from core-api -""" -import httpx -import logging -from typing import Dict, List, Any, Optional, Callable -from functools import lru_cache -import asyncio - -logger = logging.getLogger(__name__) - - -class OpenAPIToolDiscovery: - """ - Discovers and creates executable tools from OpenAPI specifications. - """ - - def __init__(self, openapi_url: str = "http://core-api:8083/openapi.json"): - self.openapi_url = openapi_url - self.spec = None - self.tools = {} - - async def fetch_spec(self) -> Dict[str, Any]: - """Fetch OpenAPI specification from core-api""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(self.openapi_url) - response.raise_for_status() - self.spec = response.json() - logger.info(f"Fetched OpenAPI spec: {self.spec['info']['title']} " - f"with {len(self.spec.get('paths', {}))} endpoints") - return self.spec - except Exception as e: - logger.error(f"Failed to fetch OpenAPI spec from {self.openapi_url}: {e}") - return {} - - def _extract_endpoint_info(self, path: str, method: str, operation: Dict) -> Dict[str, Any]: - """ - Extract relevant information from an OpenAPI operation. - - Returns: - { - "name": "get_containers_list", - "description": "List all Docker containers", - "path": "/infrastructure/containers", - "method": "GET", - "parameters": [...], - "summary": "..." - } - """ - # Generate tool name from operationId or path - operation_id = operation.get("operationId") - if operation_id: - # Convert operationId to snake_case - tool_name = operation_id.replace("-", "_").replace(" ", "_").lower() - else: - # Generate from path and method - path_parts = path.strip("/").replace("/", "_").replace("{", "").replace("}", "") - tool_name = f"{method.lower()}_{path_parts}" - - # Get description - description = operation.get("summary") or operation.get("description") or f"{method} {path}" - - # Extract parameters - parameters = operation.get("parameters", []) - request_body = operation.get("requestBody") - - return { - "name": tool_name, - "description": description, - "path": path, - "method": method.upper(), - "parameters": parameters, - "request_body": request_body, - "summary": operation.get("summary", ""), - "tags": operation.get("tags", []) - } - - def _create_tool_function(self, endpoint_info: Dict[str, Any]) -> Callable: - """ - Create an executable async function for an API endpoint. - - The function will make HTTP requests to core-api when called. - """ - path = endpoint_info["path"] - method = endpoint_info["method"] - description = endpoint_info["description"] - - async def tool_function(**kwargs) -> str: - """ - Dynamically generated function that calls core-api endpoint. - """ - try: - url = f"http://core-api:8083{path}" - - # Replace path parameters - for key, value in kwargs.items(): - url = url.replace(f"{{{key}}}", str(value)) - - # Build request - async with httpx.AsyncClient(timeout=30.0) as client: - if method == "GET": - response = await client.get(url, params=kwargs) - elif method == "POST": - response = await client.post(url, json=kwargs) - elif method == "PUT": - response = await client.put(url, json=kwargs) - elif method == "DELETE": - response = await client.delete(url, params=kwargs) - else: - return f"Unsupported HTTP method: {method}" - - response.raise_for_status() - - # Return JSON if possible, otherwise text - try: - result = response.json() - # Format nicely for LLM - if isinstance(result, list): - return f"Found {len(result)} items:\n" + "\n".join( - [f"- {item}" for item in result[:10]] # Limit to 10 items - ) - elif isinstance(result, dict): - return str(result) - else: - return str(result) - except: - return response.text - - except httpx.HTTPStatusError as e: - return f"HTTP Error {e.response.status_code}: {e.response.text}" - except Exception as e: - return f"Error calling {method} {path}: {str(e)}" - - # Set function metadata - tool_function.__name__ = endpoint_info["name"] - tool_function.__doc__ = f"{description}\n\nEndpoint: {method} {path}" - - return tool_function - - async def discover_tools( - self, - include_tags: Optional[List[str]] = None, - exclude_tags: Optional[List[str]] = None, - method_filter: Optional[List[str]] = None - ) -> Dict[str, Callable]: - """ - Discover and create tools from OpenAPI spec. - - Args: - include_tags: Only include endpoints with these tags - exclude_tags: Exclude endpoints with these tags - method_filter: Only include these HTTP methods (e.g., ["GET", "POST"]) - - Returns: - Dictionary of tool_name -> async function - """ - if not self.spec: - await self.fetch_spec() - - if not self.spec or "paths" not in self.spec: - logger.warning("No OpenAPI spec available") - return {} - - discovered_tools = {} - - for path, path_item in self.spec["paths"].items(): - for method in ["get", "post", "put", "delete", "patch"]: - if method not in path_item: - continue - - operation = path_item[method] - - # Apply filters - if method_filter and method.upper() not in method_filter: - continue - - tags = operation.get("tags", []) - if include_tags and not any(tag in include_tags for tag in tags): - continue - if exclude_tags and any(tag in exclude_tags for tag in tags): - continue - - # Extract endpoint info - endpoint_info = self._extract_endpoint_info(path, method, operation) - - # Create executable function - tool_func = self._create_tool_function(endpoint_info) - - discovered_tools[endpoint_info["name"]] = tool_func - - logger.debug(f"Discovered tool: {endpoint_info['name']} ({method.upper()} {path})") - - logger.info(f"Discovered {len(discovered_tools)} tools from OpenAPI spec") - return discovered_tools - - def get_tool_descriptions(self) -> List[Dict[str, str]]: - """ - Get human-readable descriptions of all discovered tools. - - Useful for logging/debugging. - """ - descriptions = [] - for name, func in self.tools.items(): - descriptions.append({ - "name": name, - "description": func.__doc__ or "No description" - }) - return descriptions - - -# Global instances for multiple OpenAPI sources -_discovery_instances: Dict[str, OpenAPIToolDiscovery] = {} - - -async def get_openapi_tools( - endpoints: Optional[List[str]] = None, - include_tags: Optional[List[str]] = None, - exclude_tags: Optional[List[str]] = None, - refresh: bool = False -) -> Dict[str, Callable]: - """ - Get dynamically discovered tools from one or more OpenAPI specifications. - - Args: - endpoints: List of OpenAPI spec URLs. If None, uses default (core-api) - Example: ["http://core-api:8083/openapi.json", "http://automation:8080/openapi.json"] - include_tags: Only include endpoints with these tags (e.g., ["infrastructure", "automation"]) - exclude_tags: Exclude endpoints with these tags (e.g., ["internal", "admin"]) - refresh: Force re-fetch of OpenAPI specs - - Returns: - Dictionary of tool_name -> async function (combined from all sources) - """ - global _discovery_instances - - # Default to core-api if no endpoints specified - if endpoints is None: - endpoints = ["http://core-api:8083/openapi.json"] - - all_tools = {} - - for endpoint_url in endpoints: - # Get or create discovery instance for this endpoint - if endpoint_url not in _discovery_instances or refresh: - _discovery_instances[endpoint_url] = OpenAPIToolDiscovery(openapi_url=endpoint_url) - - instance = _discovery_instances[endpoint_url] - - # Discover tools from this endpoint - try: - tools = await instance.discover_tools( - include_tags=include_tags, - exclude_tags=exclude_tags - ) - - # Add source prefix to avoid name conflicts between APIs - # Extract service name from URL (e.g., "core-api" from "http://core-api:8083/...") - service_name = endpoint_url.split("//")[1].split(":")[0].split(".")[0] - - for tool_name, tool_func in tools.items(): - # Prefix tool name with service (e.g., "core_api__list_containers") - prefixed_name = f"{service_name}__{tool_name}" - all_tools[prefixed_name] = tool_func - - instance.tools = tools - logger.info(f"Loaded {len(tools)} tools from {service_name}") - - except Exception as e: - logger.error(f"Failed to discover tools from {endpoint_url}: {e}") - continue - - logger.info(f"Total OpenAPI tools discovered: {len(all_tools)} from {len(endpoints)} source(s)") - return all_tools - - -async def get_openapi_tool_descriptions(endpoints: Optional[List[str]] = None) -> List[Dict[str, str]]: - """ - Get descriptions of all discovered OpenAPI tools. - - Args: - endpoints: List of OpenAPI spec URLs (same as get_openapi_tools) - - Returns: - List of tool descriptions - """ - global _discovery_instances - - if not _discovery_instances: - await get_openapi_tools(endpoints=endpoints) - - all_descriptions = [] - for endpoint_url, instance in _discovery_instances.items(): - service_name = endpoint_url.split("//")[1].split(":")[0].split(".")[0] - for desc in instance.get_tool_descriptions(): - desc["source"] = service_name - all_descriptions.append(desc) - - return all_descriptions diff --git a/services/OBSOLETE_core-ai/src/tools/registry.py b/services/OBSOLETE_core-ai/src/tools/registry.py deleted file mode 100644 index cf1c761..0000000 --- a/services/OBSOLETE_core-ai/src/tools/registry.py +++ /dev/null @@ -1,403 +0,0 @@ -""" -Tool Registry - Manages tool registration and discovery for AI agents. - -This module provides a central registry for tools. -Tools can be registered, discovered, and provided to AI agents. -""" -import logging -import functools -import inspect -from typing import List, Dict, Any, Callable -import httpx - -from src.config import get_settings - -logger = logging.getLogger(__name__) - -# Initialize settings once -settings = get_settings() -CORE_API_BASE_URL = settings.core_api_base_url - -# HTTP client for REST calls to core-api -http_client = httpx.AsyncClient() - - -# ============================================================================ -# Legacy ADK Integration (deprecated - kept for backwards compatibility) -# ============================================================================ - -try: - from google.adk.tools import FunctionTool - ADK_AVAILABLE = True -except ImportError: - ADK_AVAILABLE = False - FunctionTool = None - - -# ============================================================================ -# Tool Registry -# ============================================================================ - -# Global registry of tools -_TOOL_REGISTRY: Dict[str, Callable] = {} - - -def log_tool_call(func): - """Decorator to log tool calls and track metrics""" - @functools.wraps(func) - async def wrapper(*args, **kwargs): - import time - from src.metrics import get_metrics_collector - - metrics = get_metrics_collector() - tool_name = func.__name__ - start_time = time.time() - success = False - error_msg = None - - params_str = ", ".join( - [f"{arg}" for arg in args] + - [f"{k}={repr(v)}" for k, v in kwargs.items()] - ) - logger.info(f"🔧 TOOL CALL: {tool_name}({params_str})") - - try: - # Filter kwargs to only include valid parameters - sig = inspect.signature(func) - valid_kwargs = { - key: value for key, value in kwargs.items() - if key in sig.parameters - } - result = await func(*args, **valid_kwargs) - result_preview = str(result)[:200] if result else "None" - logger.info(f"✅ TOOL RESULT: {tool_name} → {result_preview}...") - success = True - return result - except Exception as e: - error_msg = f"{type(e).__name__}: {str(e)}" - logger.error( - f"❌ TOOL ERROR: {tool_name} failed with {type(e).__name__}: {e}", - exc_info=True - ) - raise - finally: - duration_ms = (time.time() - start_time) * 1000 - metrics.record_tool_execution( - tool_name=tool_name, - duration_ms=duration_ms, - success=success, - error=error_msg - ) - return wrapper - - -def register_tool(func: Callable) -> Callable: - """ - Register a tool function for use with AI agents. - - Usage: - @register_tool - async def my_tool(param: str) -> str: - '''Tool description''' - return "result" - - Args: - func: Async function to register as a tool - - Returns: - The decorated function - """ - _TOOL_REGISTRY[func.__name__] = func - logger.info(f"📝 Registered tool: {func.__name__}") - return log_tool_call(func) - - -def get_all_tools(include_openapi: bool = False) -> Dict[str, Callable]: - """ - Get all registered tools. - - Args: - include_openapi: If True, also include dynamically discovered OpenAPI tools - - Returns: - Dictionary mapping tool names to functions - """ - tools = _TOOL_REGISTRY.copy() - - # Add OpenAPI tools if requested - if include_openapi: - try: - import asyncio - from src.tools.openapi_discovery import get_openapi_tools - - # Get or create event loop - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Fetch OpenAPI tools - openapi_tools = loop.run_until_complete(get_openapi_tools()) - tools.update(openapi_tools) - logger.info(f"Added {len(openapi_tools)} OpenAPI tools to registry") - except Exception as e: - logger.warning(f"Failed to load OpenAPI tools: {e}") - - return tools - - -def get_agent_tools() -> List: - """ - DEPRECATED: Get all tools as ADK FunctionTool objects. - - This function is kept for backwards compatibility but is no longer used. - Use get_all_tools() instead for PydanticAI agents. - - Returns: - List of FunctionTool objects for legacy ADK agent - """ - if not ADK_AVAILABLE: - logger.warning("ADK not available - returning empty tool list") - return [] - - tools = [] - for name, func in _TOOL_REGISTRY.items(): - try: - # Create ADK FunctionTool from the registered function - tool = FunctionTool(func) - tools.append(tool) - logger.info(f"✓ Created ADK tool: {name}") - except Exception as e: - logger.error(f"Failed to create ADK tool for {name}: {e}") - - logger.info(f"📦 Providing {len(tools)} tools to ADK agent") - return tools - - -def clear_registry(): - """Clear all registered tools (useful for testing)""" - _TOOL_REGISTRY.clear() - logger.info("🗑️ Tool registry cleared") - - -# ============================================================================ -# Swagger/OpenAPI Dynamic Tool Discovery -# ============================================================================ - -async def fetch_openapi_spec(base_url: str) -> Dict[str, Any]: - """ - Fetch the OpenAPI/Swagger specification from core-api. - - Args: - base_url: Base URL of the API (e.g., http://core-api:8000) - - Returns: - OpenAPI spec as dictionary - - Raises: - Exception: If fetching fails - """ - try: - # Try common OpenAPI spec endpoints - endpoints = [ - f"{base_url}/openapi.json", - f"{base_url}/api/openapi.json", - f"{base_url}/docs/openapi.json", - f"{base_url}/swagger.json", - ] - - for endpoint in endpoints: - try: - logger.info(f"Attempting to fetch OpenAPI spec from: {endpoint}") - response = await http_client.get(endpoint, timeout=5.0) - if response.status_code == 200: - spec = response.json() - logger.info(f"✓ Successfully fetched OpenAPI spec from {endpoint}") - return spec - except Exception as e: - logger.debug(f"Failed to fetch from {endpoint}: {e}") - continue - - raise Exception(f"Could not fetch OpenAPI spec from any endpoint at {base_url}") - - except Exception as e: - logger.error(f"Failed to fetch OpenAPI spec: {e}") - raise - - -def create_rest_tool( - operation_id: str, - path: str, - method: str, - description: str, - parameters: List[Dict[str, Any]], - base_url: str -) -> Callable: - """ - Create a dynamic REST tool function from OpenAPI operation. - - Args: - operation_id: Unique identifier for the operation - path: API path (e.g., /api/v1/containers) - method: HTTP method (GET, POST, etc.) - description: Tool description from OpenAPI - parameters: List of parameter specifications - base_url: Base URL for API calls - - Returns: - Async function that calls the REST endpoint - """ - # Create parameter list for function signature - param_names = [p["name"] for p in parameters] - - async def rest_tool(**kwargs): - """ - Dynamically created REST tool. - """ - # Build request - url = f"{base_url}{path}" - - # Substitute path parameters - for param in parameters: - if param.get("in") == "path": - param_name = param["name"] - if param_name in kwargs: - url = url.replace(f"{{{param_name}}}", str(kwargs[param_name])) - - # Build query parameters - query_params = {} - for param in parameters: - if param.get("in") == "query": - param_name = param["name"] - if param_name in kwargs: - query_params[param_name] = kwargs[param_name] - - # Build request body - body = None - for param in parameters: - if param.get("in") == "body": - param_name = param["name"] - if param_name in kwargs: - body = kwargs[param_name] - - logger.info(f"REST Tool: {method} {url}") - - try: - # Make the REST call - if method.upper() == "GET": - response = await http_client.get(url, params=query_params) - elif method.upper() == "POST": - response = await http_client.post(url, json=body, params=query_params) - elif method.upper() == "PUT": - response = await http_client.put(url, json=body, params=query_params) - elif method.upper() == "DELETE": - response = await http_client.delete(url, params=query_params) - else: - return f"Error: Unsupported HTTP method {method}" - - response.raise_for_status() - - # Return response - try: - return response.json() - except Exception: - return response.text - - except httpx.HTTPStatusError as e: - logger.error(f"REST tool HTTP error: {e}") - return f"Error: HTTP {e.response.status_code} - {e.response.text}" - except Exception as e: - logger.error(f"REST tool error: {e}") - return f"Error: {type(e).__name__} - {str(e)}" - - # Set function metadata for ADK - rest_tool.__name__ = operation_id - rest_tool.__doc__ = description - - # Add annotations for ADK type checking - annotations = {} - for param in parameters: - param_name = param["name"] - param_type = param.get("schema", {}).get("type", "string") - - # Map OpenAPI types to Python types - type_mapping = { - "string": str, - "integer": int, - "number": float, - "boolean": bool, - "array": list, - "object": dict, - } - annotations[param_name] = type_mapping.get(param_type, str) - - annotations["return"] = str - rest_tool.__annotations__ = annotations - - return rest_tool - - -async def discover_and_register_tools(base_url: str = None) -> int: - """ - Discover tools from core-api's OpenAPI spec and register them. - - Args: - base_url: Base URL of core-api (default: from settings) - - Returns: - Number of tools registered - - Raises: - Exception: If discovery fails - """ - if base_url is None: - base_url = CORE_API_BASE_URL - - logger.info(f"🔍 Discovering tools from {base_url}") - - try: - # Fetch OpenAPI spec - spec = await fetch_openapi_spec(base_url) - - paths = spec.get("paths", {}) - tools_registered = 0 - - # Iterate through all paths and operations - for path, path_item in paths.items(): - for method, operation in path_item.items(): - if method.lower() not in ["get", "post", "put", "delete", "patch"]: - continue - - # Extract operation details - operation_id = operation.get("operationId") - if not operation_id: - # Generate operation ID from path and method - operation_id = f"{method}_{path.replace('/', '_').strip('_')}" - - description = operation.get("summary", operation.get("description", f"{method.upper()} {path}")) - - # Extract parameters - parameters = operation.get("parameters", []) - - # Create and register the tool - tool_func = create_rest_tool( - operation_id=operation_id, - path=path, - method=method, - description=description, - parameters=parameters, - base_url=base_url - ) - - # Register the tool - _TOOL_REGISTRY[operation_id] = log_tool_call(tool_func) - logger.info(f"📝 Registered REST tool: {operation_id} ({method.upper()} {path})") - tools_registered += 1 - - logger.info(f"✓ Discovered and registered {tools_registered} tools from core-api") - return tools_registered - - except Exception as e: - logger.error(f"Failed to discover tools: {e}", exc_info=True) - raise diff --git a/services/OBSOLETE_core-ai/src/utils.py b/services/OBSOLETE_core-ai/src/utils.py deleted file mode 100644 index 13af6e3..0000000 --- a/services/OBSOLETE_core-ai/src/utils.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Utility functions for core-ai service. -""" -import re -from typing import Optional - - -# Default user for requests without user_id -DEFAULT_USER_ID = "llmdefault_at_schweitz.net" - - -def sanitize_email_to_user_id(email: Optional[str] = None) -> str: - """ - Convert email address to standardized user_id format. - - Format: username_at_domain_com (lowercase, @ → _at_) - - Examples: - john@example.com → john_at_example_com - Alice.Smith@Company.ORG → alice_smith_at_company_org - None → llmdefault_at_schweitz.net (default) - - Args: - email: Email address to convert (None uses default user) - - Returns: - Sanitized user_id string safe for Qdrant collection names - """ - if not email: - return DEFAULT_USER_ID - - # Convert to lowercase - email = email.lower().strip() - - # Validate email format (basic check) - if '@' not in email: - # Invalid email, return default - return DEFAULT_USER_ID - - # Replace @ with _at_ - user_id = email.replace('@', '_at_') - - # Replace any non-alphanumeric characters (except underscores) with underscores - # This handles dots, hyphens, etc. in email addresses - user_id = re.sub(r'[^a-z0-9_]', '_', user_id) - - # Remove any duplicate underscores - user_id = re.sub(r'_+', '_', user_id) - - # Remove leading/trailing underscores - user_id = user_id.strip('_') - - return user_id - - -def get_collection_name_for_user(user_id: str, prefix: str = "core_ai_user") -> str: - """ - Generate Qdrant collection name for a user. - - Args: - user_id: Sanitized user ID (from sanitize_email_to_user_id) - prefix: Collection prefix (default: core_ai_user) - - Returns: - Full collection name: {prefix}_{user_id} - - Examples: - john_at_example_com → core_ai_user_john_at_example_com - llmdefault_at_schweitz_net → core_ai_user_llmdefault_at_schweitz_net - """ - return f"{prefix}_{user_id}" - - -def extract_user_id_from_request(data: dict) -> str: - """ - Extract and sanitize user_id from request data. - - Priority: - 1. data.get("user_id") - if provided, sanitize it - 2. data.get("user_email") - convert to user_id format - 3. DEFAULT_USER_ID - fallback to default user - - Args: - data: Request JSON data - - Returns: - Sanitized user_id string - """ - # Check for explicit user_id - if user_id := data.get("user_id"): - # If it's already in our format, use it - if "_at_" in user_id: - return user_id - # Otherwise treat it as an email - return sanitize_email_to_user_id(user_id) - - # Check for user_email - if user_email := data.get("user_email"): - return sanitize_email_to_user_id(user_email) - - # Fallback to default - return DEFAULT_USER_ID