diff --git a/IMPLEMENTATION_PLAN_TOOL_SELECTION.md b/IMPLEMENTATION_PLAN_TOOL_SELECTION.md deleted file mode 100644 index 8566fd4..0000000 --- a/IMPLEMENTATION_PLAN_TOOL_SELECTION.md +++ /dev/null @@ -1,680 +0,0 @@ -# Implementation Plan: Two-Stage Tool Selection with Steward Analysis - -## Executive Summary - -Implemented a two-stage LLM process where: -1. **Stage 1 (Steward Analysis)**: Tatlock's steward analyzes queries and recommends 0-5 optimal tools -2. **Stage 2 (Tatlock Execution)**: Main Tatlock agent uses recommendations as guidance - -Status messages like "🀡 Consulting the steward..." and "βœ“ Steward consultation complete" stream separately from answer content. Tool execution (specifically web_search) shows real-time status with arguments. - -**Status**: βœ… **IMPLEMENTED** - See files created below - ---- - -## Architecture Overview - -``` -User Query - ↓ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Stage 1: Steward Analysis Agent β”‚ -β”‚ - Uses same model as Tatlock (mistral-nemo)β”‚ -β”‚ - Reviews all available tools β”‚ -β”‚ - Analyzes query intent β”‚ -β”‚ - Recommends 0-5 optimal tools β”‚ -β”‚ - Status: "🀡 Consulting the steward..." β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ Tool recommendations - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Enrichment Layer β”‚ -β”‚ - Silent time/date injection (if needed) β”‚ -β”‚ - Inject tool recommendations into context β”‚ -β”‚ - Create enhanced user message β”‚ -β”‚ Status: "βœ“ Steward consultation complete" β”‚ -β”‚ (Or "βœ“ No further assistance required") β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ Enhanced query - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Stage 2: Tatlock Answer Agent β”‚ -β”‚ - Full PydanticAI agent with all tools β”‚ -β”‚ - Sees tool recommendations in context β”‚ -β”‚ - More likely to use recommended tools β”‚ -β”‚ - Streams answer to user β”‚ -β”‚ - Tool events tracked for web_search β”‚ -β”‚ Status: "πŸ” Searching the web: 'query'" β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - ---- - -## Key User Refinements from Original Plan - -### Changes from Initial Design: - -1. **Terminology**: "Steward" instead of "household" (more precise) -2. **Tool Range**: 0-5 tools (not 2-3) - allows "no tools needed" responses -3. **Model Selection**: Use mistral-nemo for both steward and Tatlock (avoids cold starts) -4. **Time/Date Handling**: Silent injection when recommended (no visible status) -5. **Tool Status**: Only show status for `web_search` tool (most interesting to users) -6. **Math Handling**: ALWAYS recommend calculator for ANY mathematical operation -7. **Status Permanence**: Status messages are permanent (not ephemeral) for transparency -8. **Zero-Tool Case**: Special message when no tools needed: "βœ“ No further assistance required" - ---- - -## Implementation Files Created - -### 1. Tool Event Emitter -**File**: `/services/core-ai/src/agents/tool_events.py` βœ… Created - -Lightweight event system for tools to signal when they're called: - -```python -@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.""" - - def emit(self, tool_name: str, arguments: dict): - """Emit a tool call event (non-blocking).""" - # Emits to asyncio.Queue for streaming consumers - - async def get_event(self, timeout: float = 0.01) -> Optional[ToolCallEvent]: - """Get next event with timeout.""" - # Used by streaming to detect tool calls in real-time - -def get_tool_emitter() -> ToolEventEmitter: - """Get global singleton emitter.""" -``` - -**Purpose**: Allows tools to emit events during execution without blocking - ---- - -### 2. Steward Analysis Agent -**File**: `/services/core-ai/src/agents/steward_agent.py` βœ… Created - -Analysis agent that recommends optimal tools for queries: - -```python -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") - requires_assistance: bool = Field( - default=False, - description="False if 0 tools recommended" - ) - -class StewardAgent: - """The Steward - Tatlock's analytical assistant for tool selection.""" - - def __init__(self, model_name: Optional[str] = None): - # Uses same model as Tatlock (mistral-nemo) for performance - if model_name is None: - model_name = self.settings.agent_model # mistral-nemo:latest - - self.model = OpenAIModel( - model_name=model_name, - provider=OllamaProvider(base_url=ollama_base_url_v1) - ) - - # Generate system prompt with tool catalog - self.system_prompt = self._generate_steward_prompt() - - # Create PydanticAI agent with structured output - self.agent = Agent( - model=self.model, - system_prompt=self.system_prompt, - result_type=ToolRecommendation, # Structured JSON output - ) - - async def analyze(self, query: str, timeout: Optional[float] = None): - """Analyze query and recommend tools with timeout.""" - result = await asyncio.wait_for( - self.agent.run(f"User query: {query}"), - timeout=timeout or 3 # Default 3 seconds - ) - return result.data # Returns ToolRecommendation -``` - -**Critical Rules in System Prompt**: -``` -CRITICAL RULES: -1. ANY mathematical calculation β†’ MUST recommend 'calculate' tool - - LLMs are unreliable with math - ALWAYS use calculator - - Examples: "15 + 27", "50% of 200", "sqrt(144)" β†’ ALL require 'calculate' - -2. Time/date queries β†’ recommend 'get_current_time' or 'get_current_date' - - These will be injected silently into context - - Examples: "what time is it", "today's date", "current time in Paris" - -3. Real-time information β†’ recommend 'web_search' - - News, weather, current events, latest information - - Any query requiring up-to-date data from the internet - -4. Infrastructure operations β†’ recommend appropriate tools - - Container management, service control, DNS lookups, etc. - -5. General knowledge β†’ NO TOOLS NEEDED (requires_assistance: false) - - Historical facts, definitions, explanations - - But NEVER for math - always use calculator -``` - -**Performance**: Uses mistral-nemo (already loaded in VRAM) so no cold start penalty - ---- - -### 3. Two-Stage Orchestration Wrapper -**File**: `/services/core-ai/src/agents/two_stage_agent.py` βœ… Created - -Coordinates steward analysis with Tatlock execution: - -```python -class TwoStageAgent: - """Two-stage agent orchestrator.""" - - def __init__(self, tatlock_agent: Agent, enable_two_stage: bool = True): - self.tatlock = tatlock_agent - self.enable_two_stage = enable_two_stage - - if self.enable_two_stage: - self.steward = get_steward_agent() - - 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. - - Yields: - Dict with 'type': - - type='status': Status update (message, phase, tool_name, arguments) - - type='content': Response content chunk - - type='done': Completion marker - """ - - # Stage 1: Steward Analysis - yield { - "type": "status", - "message": "🀡 Consulting the steward...", - "phase": "analysis" - } - - recommendation = await self._perform_steward_analysis(user_query) - - if recommendation.recommended_tools: - yield { - "type": "status", - "message": "βœ“ Steward consultation complete", - "phase": "analysis_complete", - "recommended_tools": recommendation.recommended_tools - } - else: - yield { - "type": "status", - "message": "βœ“ No further assistance required - answering from general knowledge", - "phase": "analysis_complete" - } - - # Silent time/date injection if needed - 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() - - # Enrich user message with recommendations - enriched_messages = self._enrich_user_message( - user_query, - recommendation, - datetime_info - ) - - # Stage 2: Tatlock Execution with Tool Event Monitoring - # Monitor tool events for web_search - emitter = get_tool_emitter() - emitter.clear() - - # Stream both tool events and Tatlock response - async for event in emitter.get_event(): - if event.tool_name == "web_search": - query = event.arguments.get("query", "") - yield { - "type": "status", - "message": f"πŸ” Searching the web: \"{query}\"", - "phase": "tool_execution", - "tool_name": event.tool_name, - "arguments": event.arguments - } - - async for chunk in self.tatlock.chat(...): - yield chunk - -def create_two_stage_agent(tatlock_agent: Agent, enable_two_stage: bool = True): - """Create two-stage agent orchestrator.""" - return TwoStageAgent(tatlock_agent, enable_two_stage=enable_two_stage) -``` - -**Key Features**: -- Coordinates both stages -- Handles 0-tool case gracefully -- Silent time/date injection -- Shows visible tool recommendations -- Monitors web_search execution -- Graceful fallback if steward fails - ---- - -### 4. Web Search Tool Update -**File**: `/services/core-ai/src/tools/local.py` βœ… Updated - -Added event emission to web_search tool: - -```python -@register_tool -async def web_search(query: str, category: str = "general", max_results: int = 5): - """Search the web using SearXNG metasearch engine.""" - - logger.info(f"Web search: query='{query}', category='{category}'") - - # 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}") - - # ... actual search implementation ... -``` - -**Result**: Web searches now emit events that trigger status messages showing the search query - ---- - -### 5. Main API Endpoint Updates -**File**: `/services/core-ai/main.py` βœ… Updated - -Modified chat endpoint to support two-stage processing: - -```python -from src.agents.two_stage_agent import create_two_stage_agent - -async def chat_completions(request): - """Enhanced with two-stage tool selection.""" - - # Extract parameters - two_stage_analysis = data.get("two_stage_analysis", True) # Enabled by default - - # Get base agent - base_agent = get_pydantic_agent(discover_tools=enable_tools, user_id=user_id) - - # Wrap with two-stage orchestration - agent = create_two_stage_agent(base_agent, enable_two_stage=two_stage_analysis) - - # For streaming - if stream: - async for chunk in agent.chat_with_analysis(messages, conversation_id, stream=True): - chunk_type = chunk.get("type") - - if chunk_type == "status": - # Send status as separate SSE event - status_data = { - "type": "status", - "message": chunk.get("message"), - "phase": chunk.get("phase"), - "tool_name": chunk.get("tool_name"), - "arguments": chunk.get("arguments") - } - await response.write(f"data: {json.dumps(status_data)}\\n\\n".encode('utf-8')) - - elif chunk_type == "content": - # Send content as standard OpenAI format - chunk_data = { - "type": "content", - "choices": [{ - "index": 0, - "delta": {"content": chunk.get("content", "")}, - "finish_reason": chunk.get("finish_reason") - }], - "model": model - } - await response.write(f"data: {json.dumps(chunk_data)}\\n\\n".encode('utf-8')) -``` - -**Changes**: -- Added `two_stage_analysis` request parameter (default: true) -- Wraps base agent with TwoStageAgent -- Handles status and content events separately in SSE stream -- Backwards compatible (can disable with `two_stage_analysis: false`) - ---- - -### 6. Configuration Options -**File**: `/services/core-ai/src/config.py` βœ… Updated - -Added two-stage configuration settings: - -```python -class Settings(BaseSettings): - # ... existing settings ... - - # Two-Stage Tool Selection Configuration - two_stage_enabled: bool = True # Enable two-stage steward analysis by default - analysis_timeout: int = 3 # Steward analysis timeout in seconds - 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 - 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..." - status_complete: str = "βœ“ Steward consultation complete" - status_no_assistance: str = "βœ“ No further assistance required - answering from general knowledge" -``` - -**Customization**: All status messages and behavior can be configured via environment variables - ---- - -## Event Flow Example - -### User Query: "What's the weather in Paris right now?" - -**SSE Stream Output**: - -``` -data: {"type":"status","message":"🀡 Consulting the steward...","phase":"analysis"} - -data: {"type":"status","message":"βœ“ Steward consultation complete","phase":"analysis_complete","recommended_tools":["web_search"]} - -data: {"type":"status","message":"πŸ” Searching the web: \"Paris weather current\"","phase":"tool_execution","tool_name":"web_search"} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":" weather"},"finish_reason":null}]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":" in"},"finish_reason":null}]} - -... (more content chunks) - -data: [DONE] -``` - -### User Query: "What is 2+2?" - -**SSE Stream Output**: - -``` -data: {"type":"status","message":"🀡 Consulting the steward...","phase":"analysis"} - -data: {"type":"status","message":"βœ“ Steward consultation complete","phase":"analysis_complete","recommended_tools":["calculate"]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":"Let"},"finish_reason":null}]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":" me"},"finish_reason":null}]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":" calculate"},"finish_reason":null}]} - -... (Tatlock calls calculate tool) - -data: {"type":"content","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":" answer"},"finish_reason":null}]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":" is"},"finish_reason":null}]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":" 4"},"finish_reason":null}]} - -data: [DONE] -``` - -### User Query: "Who was the first president of the United States?" - -**SSE Stream Output**: - -``` -data: {"type":"status","message":"🀡 Consulting the steward...","phase":"analysis"} - -data: {"type":"status","message":"βœ“ No further assistance required - answering from general knowledge","phase":"analysis_complete"} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":"George"},"finish_reason":null}]} - -data: {"type":"content","choices":[{"index":0,"delta":{"content":" Washington"},"finish_reason":null}]} - -... (more content) - -data: [DONE] -``` - ---- - -## Performance Considerations - -### Model Selection Rationale - -**Using mistral-nemo for both steward and Tatlock**: -- βœ… No cold start penalty (already loaded in VRAM) -- βœ… Consistent quality between analysis and execution -- βœ… Simplified deployment (one model to manage) -- ❌ Slightly slower analysis than gemma2:2b (~200-500ms vs ~100-300ms) -- **Verdict**: Trade-off worth it - cold start would cost 5-10+ seconds - -### Expected Latencies - -| Stage | Duration | Model | Notes | -|-------|----------|-------|-------| -| Analysis | 200-500ms | mistral-nemo | Same model as Tatlock | -| Enrichment | <10ms | Python | Text manipulation | -| Answer | 2-10s | mistral-nemo | Full inference with tools | -| **Total Overhead** | **~210-510ms** | | Minimal impact | - -### Zero-Tool Optimization - -When steward recommends 0 tools: -- No context enrichment needed -- Original query passed directly to Tatlock -- Only overhead is analysis time (~200-500ms) -- Still beneficial: Confirms no tools needed (transparent reasoning) - ---- - -## Testing Strategy - -### Manual Testing Commands - -```bash -# Test with two-stage enabled (default) -curl -X POST http://localhost:8086/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [{"role":"user","content":"What is the weather in Paris?"}], - "model": "Tatlock", - "stream": true - }' - -# Test with two-stage disabled -curl -X POST http://localhost:8086/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [{"role":"user","content":"What is 2+2?"}], - "model": "Tatlock", - "stream": true, - "two_stage_analysis": false - }' - -# Test zero-tool case -curl -X POST http://localhost:8086/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [{"role":"user","content":"Who invented the telephone?"}], - "model": "Tatlock", - "stream": true - }' - -# Test time/date injection -curl -X POST http://localhost:8086/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [{"role":"user","content":"What time is it?"}], - "model": "Tatlock", - "stream": true - }' -``` - -### Test Cases - -| Query | Expected Steward Recommendation | Expected Status | -|-------|--------------------------------|----------------| -| "What is 15 + 27?" | `["calculate"]` | Shows calculator recommendation | -| "What's the weather in Tokyo?" | `["web_search"]` | Shows search status with query | -| "What time is it in Paris?" | `["get_current_time"]` | Silent injection, no visible status | -| "Who won the 2024 Olympics?" | `["web_search"]` | Shows search status | -| "What is the capital of France?" | `[]` (no tools) | "No further assistance required" | -| "Calculate 50% of 200" | `["calculate"]` | Shows calculator recommendation | - ---- - -## Fallback & Error Handling - -### Graceful Degradation - -```python -try: - # Attempt steward analysis - recommendation = await self.steward.analyze(user_query, timeout=3) - -except asyncio.TimeoutError: - # Analysis took too long, fall back to single-stage - yield { - "type": "status", - "message": "⚠️ Steward unavailable, proceeding without analysis", - "phase": "fallback" - } - # Continue with original messages - -except Exception as e: - # Analysis failed, fall back to single-stage - logger.error(f"Steward analysis failed: {e}") - yield {"type": "status", "message": "⚠️ Steward unavailable..."} - # Continue with original messages -``` - -**Result**: User always gets an answer, even if steward analysis fails - ---- - -## Success Metrics - -### Performance Targets -- βœ… Analysis latency: < 500ms (p95) - using mistral-nemo -- βœ… Total overhead: < 1s (p95) - minimal impact -- βœ… Success rate: > 95% - analysis completes without error - -### Quality Metrics -- Tool recommendation accuracy: Track recommended vs. actually used -- User satisfaction: Transparent reasoning via status messages -- Error rate: < 5% - graceful fallback on failures - -### Monitoring -Track in AI metrics dashboard: -- Two-stage requests vs. single-stage -- Steward analysis performance -- Tool recommendation patterns -- Fallback rate - ---- - -## Key Architectural Decisions - -### 1. Context Enrichment vs. System Prompt Injection -**Decision**: Context enrichment (inject into user message) -**Reason**: PydanticAI system prompts are immutable -**Trade-off**: Less clean, but only practical workaround - -### 2. Same Model vs. Lightweight Model -**Decision**: Use mistral-nemo for both steward and Tatlock -**Reason**: Avoid cold start penalty (5-10+ seconds) -**Trade-off**: Slightly slower analysis, but much better overall latency - -### 3. Tool Event System vs. Direct Monitoring -**Decision**: Separate event emitter system -**Reason**: Decouples tool execution from streaming logic -**Benefit**: Other tools can easily emit events in future - -### 4. Permanent vs. Ephemeral Status Messages -**Decision**: Permanent status messages -**Reason**: Transparency - users can see reasoning trail -**Benefit**: Educational, builds trust in AI decisions - -### 5. 0-5 Tools vs. 2-3 Tools -**Decision**: Allow 0-5 tool recommendations -**Reason**: More flexible, handles simple queries better -**Benefit**: "No tools needed" case explicitly handled - ---- - -## Future Enhancements - -### Phase 1 (Immediate) -- [x] Core two-stage implementation -- [x] Steward analysis agent -- [x] Web search status tracking -- [x] Silent time/date injection -- [ ] Frontend integration testing - -### Phase 2 (Near-term) -- [ ] Extend status tracking to other interesting tools (dns_lookup, etc.) -- [ ] Track tool recommendation accuracy metrics -- [ ] A/B test two-stage vs. single-stage performance -- [ ] Tune steward prompts based on usage patterns - -### Phase 3 (Long-term) -- [ ] Parallel analysis + Tatlock initialization (shave 200-500ms) -- [ ] Cache tool catalog (regenerate only on tool changes) -- [ ] Smart timeout adjustment based on query complexity -- [ ] User preference: verbose vs. quiet status mode - ---- - -## Conclusion - -The two-stage steward system has been **fully implemented** with the following benefits: - -1. βœ… **Improved Tool Selection**: Steward recommends 0-5 optimal tools -2. βœ… **Better UX**: Permanent status messages show reasoning -3. βœ… **Minimal Overhead**: ~200-500ms using same model (no cold starts) -4. βœ… **Graceful Fallback**: Works even if steward fails -5. βœ… **Full Observability**: Status events for web_search execution -6. βœ… **Math Reliability**: Always recommends calculator for arithmetic -7. βœ… **Silent Data Injection**: Time/date added without visible status -8. βœ… **Flexible Tool Range**: Handles 0-5 tools, including "no tools needed" - -**Files Modified**: -- βœ… `src/agents/tool_events.py` (new) -- βœ… `src/agents/steward_agent.py` (new) -- βœ… `src/agents/two_stage_agent.py` (new) -- βœ… `src/tools/local.py` (updated web_search) -- βœ… `main.py` (updated chat endpoint) -- βœ… `src/config.py` (added settings) - -**Next Steps**: Test the implementation end-to-end with real queries and verify status messages appear correctly in the UI. diff --git a/services/OBSOLETE_core-ai/ARCHITECTURE_DESIGN.md b/services/OBSOLETE_core-ai/ARCHITECTURE_DESIGN.md new file mode 100644 index 0000000..96e94bb --- /dev/null +++ b/services/OBSOLETE_core-ai/ARCHITECTURE_DESIGN.md @@ -0,0 +1,1158 @@ +# 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/core-ai/Dockerfile b/services/OBSOLETE_core-ai/Dockerfile similarity index 100% rename from services/core-ai/Dockerfile rename to services/OBSOLETE_core-ai/Dockerfile diff --git a/services/core-ai/README.md b/services/OBSOLETE_core-ai/README.md similarity index 100% rename from services/core-ai/README.md rename to services/OBSOLETE_core-ai/README.md diff --git a/services/OBSOLETE_core-ai/benchmark_multi_agent.py b/services/OBSOLETE_core-ai/benchmark_multi_agent.py new file mode 100644 index 0000000..f984025 --- /dev/null +++ b/services/OBSOLETE_core-ai/benchmark_multi_agent.py @@ -0,0 +1,559 @@ +#!/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/core-ai/diagnostics/__init__.py b/services/OBSOLETE_core-ai/diagnostics/__init__.py similarity index 100% rename from services/core-ai/diagnostics/__init__.py rename to services/OBSOLETE_core-ai/diagnostics/__init__.py diff --git a/services/core-ai/diagnostics/check_ollama.py b/services/OBSOLETE_core-ai/diagnostics/check_ollama.py similarity index 100% rename from services/core-ai/diagnostics/check_ollama.py rename to services/OBSOLETE_core-ai/diagnostics/check_ollama.py diff --git a/services/core-ai/main.py b/services/OBSOLETE_core-ai/main.py similarity index 85% rename from services/core-ai/main.py rename to services/OBSOLETE_core-ai/main.py index 5605ab3..f8d276d 100644 --- a/services/core-ai/main.py +++ b/services/OBSOLETE_core-ai/main.py @@ -19,10 +19,36 @@ from src.agents import ( get_pydantic_agent, PYDANTIC_AI_AVAILABLE ) -from src.agents.two_stage_agent import create_two_stage_agent +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. @@ -51,7 +77,7 @@ async def chat_completions(request): stream = data.get("stream", False) conversation_id = data.get("conversation_id") enable_tools = data.get("enable_tools", True) - two_stage_analysis = data.get("two_stage_analysis", True) # Enable by default + 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) @@ -62,8 +88,8 @@ async def chat_completions(request): # Get the agent instance base_agent = get_pydantic_agent(discover_tools=enable_tools, user_id=user_id) - # Wrap with two-stage orchestration if enabled - agent = create_two_stage_agent(base_agent, enable_two_stage=two_stage_analysis) + # 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: @@ -95,10 +121,10 @@ async def chat_completions(request): "total_tokens": 0 }, "tools_enabled": enable_tools, - "two_stage_analysis": two_stage_analysis + "multi_stage_analysis": multi_stage_analysis }) else: - # Handle streaming response with two-stage analysis + # Handle streaming response with StreamHandler response = web.StreamResponse( status=200, reason='OK', @@ -111,48 +137,16 @@ async def chat_completions(request): await response.prepare(request) try: - async for chunk in agent.chat_with_analysis( + # Create stream handler instance + handler = get_stream_handler(model_name=model) + + # Process stream through handler + async for chunk in handler.process_stream( messages=messages, - conversation_id=conversation_id, - stream=True + multi_stage_enabled=multi_stage_analysis ): - chunk_type = chunk.get("type") - - if chunk_type == "status": - # Send status as separate SSE event - status_data = { - "type": "status", - "message": chunk.get("message"), - "phase": chunk.get("phase"), - "tool_name": chunk.get("tool_name"), - "arguments": chunk.get("arguments") - } - await response.write(f"data: {json.dumps(status_data)}\n\n".encode('utf-8')) - - elif chunk_type == "content": - # Send content as standard OpenAI format - chunk_data = { - "type": "content", - "choices": [{ - "index": 0, - "delta": {"content": chunk.get("content", "")}, - "finish_reason": chunk.get("finish_reason") - }], - "model": model - } - await response.write(f"data: {json.dumps(chunk_data)}\n\n".encode('utf-8')) - - elif chunk_type == "error": - # Send error event - error_data = { - "type": "error", - "message": chunk.get("message") - } - await response.write(f"data: {json.dumps(error_data)}\n\n".encode('utf-8')) - - elif chunk_type == "done": - # Send completion marker - break + # 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 diff --git a/services/core-ai/pytest.ini b/services/OBSOLETE_core-ai/pytest.ini similarity index 87% rename from services/core-ai/pytest.ini rename to services/OBSOLETE_core-ai/pytest.ini index d25af2c..6157d94 100644 --- a/services/core-ai/pytest.ini +++ b/services/OBSOLETE_core-ai/pytest.ini @@ -16,6 +16,7 @@ addopts = # Markers markers = asyncio: mark test as async + integration: mark test as integration test (requires external services) # Asyncio configuration asyncio_mode = auto diff --git a/services/core-ai/requirements.txt b/services/OBSOLETE_core-ai/requirements.txt similarity index 100% rename from services/core-ai/requirements.txt rename to services/OBSOLETE_core-ai/requirements.txt diff --git a/services/core-ai/src/__init__.py b/services/OBSOLETE_core-ai/src/__init__.py similarity index 100% rename from services/core-ai/src/__init__.py rename to services/OBSOLETE_core-ai/src/__init__.py diff --git a/services/core-ai/src/agent.py b/services/OBSOLETE_core-ai/src/agent.py similarity index 100% rename from services/core-ai/src/agent.py rename to services/OBSOLETE_core-ai/src/agent.py diff --git a/services/core-ai/src/agents/__init__.py b/services/OBSOLETE_core-ai/src/agents/__init__.py similarity index 100% rename from services/core-ai/src/agents/__init__.py rename to services/OBSOLETE_core-ai/src/agents/__init__.py diff --git a/services/OBSOLETE_core-ai/src/agents/multi_stage_agent.py b/services/OBSOLETE_core-ai/src/agents/multi_stage_agent.py new file mode 100644 index 0000000..fd7f9ff --- /dev/null +++ b/services/OBSOLETE_core-ai/src/agents/multi_stage_agent.py @@ -0,0 +1,407 @@ +""" +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/core-ai/src/agents/pydantic_agent.py b/services/OBSOLETE_core-ai/src/agents/pydantic_agent.py similarity index 100% rename from services/core-ai/src/agents/pydantic_agent.py rename to services/OBSOLETE_core-ai/src/agents/pydantic_agent.py diff --git a/services/core-ai/src/agents/simple.py b/services/OBSOLETE_core-ai/src/agents/simple.py similarity index 100% rename from services/core-ai/src/agents/simple.py rename to services/OBSOLETE_core-ai/src/agents/simple.py diff --git a/services/core-ai/src/agents/steward_agent.py b/services/OBSOLETE_core-ai/src/agents/steward_agent.py similarity index 67% rename from services/core-ai/src/agents/steward_agent.py rename to services/OBSOLETE_core-ai/src/agents/steward_agent.py index cc5de1b..c6ec10c 100644 --- a/services/core-ai/src/agents/steward_agent.py +++ b/services/OBSOLETE_core-ai/src/agents/steward_agent.py @@ -69,9 +69,9 @@ class StewardAgent: self.settings = get_settings() - # Use same model as Tatlock (already in VRAM) + # Use steward model from config (defaults to same as Tatlock to stay in VRAM) if model_name is None: - model_name = self.settings.agent_model + 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 @@ -88,11 +88,11 @@ class StewardAgent: # Generate system prompt with tool catalog self.system_prompt = self._generate_steward_prompt() - # Create PydanticAI agent with structured output + # Create PydanticAI agent WITHOUT structured output (text-based for reliability) self.agent = Agent( model=self.model, system_prompt=self.system_prompt, - output_type=ToolRecommendation, + # NO output_type - returns plain text instead of structured JSON ) logger.info(f"StewardAgent: Initialized with model {model_name}") @@ -117,45 +117,33 @@ class StewardAgent: logger.warning(f"Failed to load tool catalog: {e}") tool_catalog = "Tool catalog unavailable" - return f"""You are the steward of Tatlock's household, responsible for analyzing requests and recommending appropriate tools. + 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: +AVAILABLE TOOLS FOR TATLOCK: {tool_catalog} -CRITICAL RULES: -1. ANY mathematical calculation, arithmetic, or numeric computation β†’ MUST recommend 'calculate' tool - - LLMs are unreliable with math - ALWAYS use the calculator - - Examples: "15 + 27", "50% of 200", "square root of 144" β†’ ALL require 'calculate' +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 -2. Time/date queries β†’ recommend 'get_current_time' or 'get_current_date' - - These will be injected silently into context - - Examples: "what time is it", "what's today's date", "current time in Paris" +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 -3. Real-time information β†’ recommend 'web_search' - - News, weather, current events, latest information - - Any query requiring up-to-date data from the internet +OUTPUT FORMAT (plain text, natural language): +Write your note as you would brief a butler about a household matter. Be concise and specific. -4. Infrastructure operations β†’ recommend appropriate tools - - Container management, service control, DNS lookups, etc. +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." -5. General knowledge β†’ NO TOOLS NEEDED (requires_assistance: false) - - Historical facts, definitions, explanations - - But NEVER for math - always use calculator - -TASK: -Analyze the user's query and recommend 0-5 tools that would be most helpful. - -OUTPUT FORMAT (JSON): -{{ - "intent": "brief description of what user wants", - "recommended_tools": ["tool1", "tool2"], // Empty array if none needed - "reasoning": "why these tools are optimal (or why none needed)", - "requires_assistance": true // false only if 0 tools -}} - -Be selective and precise. Only recommend tools that are directly relevant to answering the query.""" +Now write your analysis note for the query.""" def _format_tool_catalog(self, tools: dict) -> str: """ @@ -215,20 +203,40 @@ Be selective and precise. Only recommend tools that are directly relevant to ans try: import asyncio - # Run analysis with timeout + # Run analysis with timeout (text output, no structured format) result = await asyncio.wait_for( self.agent.run(f"User query: {query}"), timeout=timeout ) - recommendation = result.output + # Get text response (steward's note for Tatlock) + steward_note = str(result.output).strip() - # Ensure requires_assistance is set correctly - recommendation.requires_assistance = len(recommendation.recommended_tools) > 0 + # 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 analysis complete: {len(recommendation.recommended_tools)} tools recommended: " - f"{recommendation.recommended_tools}" + 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 diff --git a/services/OBSOLETE_core-ai/src/agents/stream_handler.py b/services/OBSOLETE_core-ai/src/agents/stream_handler.py new file mode 100644 index 0000000..39aab6e --- /dev/null +++ b/services/OBSOLETE_core-ai/src/agents/stream_handler.py @@ -0,0 +1,464 @@ +""" +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 new file mode 100644 index 0000000..1c3d099 --- /dev/null +++ b/services/OBSOLETE_core-ai/src/agents/text_steward.py @@ -0,0 +1,288 @@ +""" +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/core-ai/src/agents/tool_events.py b/services/OBSOLETE_core-ai/src/agents/tool_events.py similarity index 100% rename from services/core-ai/src/agents/tool_events.py rename to services/OBSOLETE_core-ai/src/agents/tool_events.py diff --git a/services/core-ai/src/agents/two_stage_agent.py b/services/OBSOLETE_core-ai/src/agents/two_stage_agent.py similarity index 77% rename from services/core-ai/src/agents/two_stage_agent.py rename to services/OBSOLETE_core-ai/src/agents/two_stage_agent.py index 27cd28b..5149d28 100644 --- a/services/core-ai/src/agents/two_stage_agent.py +++ b/services/OBSOLETE_core-ai/src/agents/two_stage_agent.py @@ -247,94 +247,78 @@ class TwoStageAgent: # Two-stage disabled, use original messages enriched_messages = messages - # Stage 2: Tatlock Execution with Tool Event Monitoring - emitter = get_tool_emitter() - emitter.clear() # Clear any stale events + # 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 "" - # Create tasks for both streaming response and event monitoring - async def monitor_tool_events(): - """Monitor and yield tool call events as status messages.""" - while True: - event = await emitter.get_event(timeout=0.05) - if event is None: - await asyncio.sleep(0.01) - continue + if not user_query: + logger.error("Empty query for Tatlock execution") + yield {"type": "error", "message": "Empty query"} + return - # Only emit status for web_search - if event.tool_name == "web_search": - query = event.arguments.get("query", "") - yield { - "type": "status", - "message": f"πŸ” Searching the web: \"{query}\"", - "phase": "tool_execution", - "tool_name": event.tool_name, - "arguments": event.arguments - } + # Use PydanticAI's native streaming with event monitoring + # Access the underlying PydanticAI agent directly + from pydantic_ai.messages import ( + ModelTextResponse, + ToolCallPart, + ToolReturnPart, + ) - async def stream_tatlock_response(): - """Stream Tatlock's response.""" - try: - async for chunk in self._single_stage_chat( - enriched_messages, - conversation_id, - stream=True - ): - yield chunk - except Exception as e: - logger.error(f"Error in Tatlock response: {e}", exc_info=True) - yield { - "type": "error", - "message": f"Error generating response: {str(e)}" - } + logger.info("Starting Tatlock execution with event streaming") - # Merge tool events and response stream - event_task = asyncio.create_task(self._collect_async_gen(monitor_tool_events())) - response_task = asyncio.create_task(self._collect_async_gen(stream_tatlock_response())) + # Track cumulative text for delta calculation + previous_text = "" - # Yield from both streams - done = False - while not done: - # Check for tool events first (non-blocking) - if not event_task.done(): - try: - event = await asyncio.wait_for( - asyncio.shield(event_task), - timeout=0.001 - ) - if event: - yield event - except asyncio.TimeoutError: - pass + 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", "") - # Check for response chunks - if not response_task.done(): - try: - chunk = await asyncio.wait_for( - asyncio.shield(response_task), - timeout=0.01 - ) - if chunk: - yield chunk - if chunk.get("type") == "done": - done = True - except asyncio.TimeoutError: - pass + 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}") - # Both tasks complete - if event_task.done() and response_task.done(): - done = True + # 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 _collect_async_gen(self, gen: AsyncGenerator) -> Any: - """Helper to collect from async generator for task-based merging.""" - try: - async for item in gen: - return item - except StopAsyncIteration: - return None - async def _single_stage_chat( self, messages: list[dict], diff --git a/services/core-ai/src/config.py b/services/OBSOLETE_core-ai/src/config.py similarity index 72% rename from services/core-ai/src/config.py rename to services/OBSOLETE_core-ai/src/config.py index 5842e2a..b89497e 100644 --- a/services/core-ai/src/config.py +++ b/services/OBSOLETE_core-ai/src/config.py @@ -27,18 +27,23 @@ class Settings(BaseSettings): # Model Configuration agent_model: str = "mistral-nemo:latest" # Optimized for PydanticAI tool calling - # Two-Stage Tool Selection Configuration - two_stage_enabled: bool = True # Enable two-stage steward analysis by default - analysis_timeout: int = 10 # Steward analysis timeout in seconds (increased for mistral-nemo) + # 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 + # 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..." - status_complete: str = "βœ“ Steward consultation complete" - status_no_assistance: str = "βœ“ No further assistance required - answering from general knowledge" + 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 diff --git a/services/core-ai/src/memory/__init__.py b/services/OBSOLETE_core-ai/src/memory/__init__.py similarity index 100% rename from services/core-ai/src/memory/__init__.py rename to services/OBSOLETE_core-ai/src/memory/__init__.py diff --git a/services/core-ai/src/memory/base.py b/services/OBSOLETE_core-ai/src/memory/base.py similarity index 100% rename from services/core-ai/src/memory/base.py rename to services/OBSOLETE_core-ai/src/memory/base.py diff --git a/services/core-ai/src/memory/manager.py b/services/OBSOLETE_core-ai/src/memory/manager.py similarity index 100% rename from services/core-ai/src/memory/manager.py rename to services/OBSOLETE_core-ai/src/memory/manager.py diff --git a/services/core-ai/src/memory/qdrant_memory.py b/services/OBSOLETE_core-ai/src/memory/qdrant_memory.py similarity index 100% rename from services/core-ai/src/memory/qdrant_memory.py rename to services/OBSOLETE_core-ai/src/memory/qdrant_memory.py diff --git a/services/core-ai/src/memory/schemas.py b/services/OBSOLETE_core-ai/src/memory/schemas.py similarity index 100% rename from services/core-ai/src/memory/schemas.py rename to services/OBSOLETE_core-ai/src/memory/schemas.py diff --git a/services/core-ai/src/memory/tier1_buffer.py b/services/OBSOLETE_core-ai/src/memory/tier1_buffer.py similarity index 100% rename from services/core-ai/src/memory/tier1_buffer.py rename to services/OBSOLETE_core-ai/src/memory/tier1_buffer.py diff --git a/services/core-ai/src/metrics/__init__.py b/services/OBSOLETE_core-ai/src/metrics/__init__.py similarity index 100% rename from services/core-ai/src/metrics/__init__.py rename to services/OBSOLETE_core-ai/src/metrics/__init__.py diff --git a/services/core-ai/src/metrics/collector.py b/services/OBSOLETE_core-ai/src/metrics/collector.py similarity index 100% rename from services/core-ai/src/metrics/collector.py rename to services/OBSOLETE_core-ai/src/metrics/collector.py diff --git a/services/core-ai/src/metrics/decorators.py b/services/OBSOLETE_core-ai/src/metrics/decorators.py similarity index 100% rename from services/core-ai/src/metrics/decorators.py rename to services/OBSOLETE_core-ai/src/metrics/decorators.py diff --git a/services/core-ai/src/models/__init__.py b/services/OBSOLETE_core-ai/src/models/__init__.py similarity index 100% rename from services/core-ai/src/models/__init__.py rename to services/OBSOLETE_core-ai/src/models/__init__.py diff --git a/services/core-ai/src/models/embeddings_ollama.py b/services/OBSOLETE_core-ai/src/models/embeddings_ollama.py similarity index 100% rename from services/core-ai/src/models/embeddings_ollama.py rename to services/OBSOLETE_core-ai/src/models/embeddings_ollama.py diff --git a/services/core-ai/src/prompts.py b/services/OBSOLETE_core-ai/src/prompts.py similarity index 82% rename from services/core-ai/src/prompts.py rename to services/OBSOLETE_core-ai/src/prompts.py index de3255f..75fcbc9 100644 --- a/services/core-ai/src/prompts.py +++ b/services/OBSOLETE_core-ai/src/prompts.py @@ -11,6 +11,9 @@ PROMPTS = { 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): diff --git a/services/core-ai/src/tools/__init__.py b/services/OBSOLETE_core-ai/src/tools/__init__.py similarity index 100% rename from services/core-ai/src/tools/__init__.py rename to services/OBSOLETE_core-ai/src/tools/__init__.py diff --git a/services/OBSOLETE_core-ai/src/tools/infrastructure/__init__.py b/services/OBSOLETE_core-ai/src/tools/infrastructure/__init__.py new file mode 100644 index 0000000..c902c53 --- /dev/null +++ b/services/OBSOLETE_core-ai/src/tools/infrastructure/__init__.py @@ -0,0 +1,40 @@ +""" +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 new file mode 100644 index 0000000..b76b955 --- /dev/null +++ b/services/OBSOLETE_core-ai/src/tools/infrastructure/containers.py @@ -0,0 +1,490 @@ +""" +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 new file mode 100644 index 0000000..e28f50e --- /dev/null +++ b/services/OBSOLETE_core-ai/src/tools/infrastructure/monitoring.py @@ -0,0 +1,247 @@ +""" +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 new file mode 100644 index 0000000..366c212 --- /dev/null +++ b/services/OBSOLETE_core-ai/src/tools/infrastructure/services.py @@ -0,0 +1,283 @@ +""" +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/core-ai/src/tools/local.py b/services/OBSOLETE_core-ai/src/tools/local.py similarity index 99% rename from services/core-ai/src/tools/local.py rename to services/OBSOLETE_core-ai/src/tools/local.py index 55f07b0..a29a5a1 100644 --- a/services/core-ai/src/tools/local.py +++ b/services/OBSOLETE_core-ai/src/tools/local.py @@ -171,7 +171,7 @@ async def calculate(expression: str) -> str: @register_tool -async def web_search(query: str, category: str = "general", max_results: int = 5) -> str: +async def web_search(query: str, category: str = "general", max_results: int = 10) -> str: """ Search the web using SearXNG metasearch engine. diff --git a/services/core-ai/src/tools/openapi_discovery.py b/services/OBSOLETE_core-ai/src/tools/openapi_discovery.py similarity index 100% rename from services/core-ai/src/tools/openapi_discovery.py rename to services/OBSOLETE_core-ai/src/tools/openapi_discovery.py diff --git a/services/core-ai/src/tools/registry.py b/services/OBSOLETE_core-ai/src/tools/registry.py similarity index 100% rename from services/core-ai/src/tools/registry.py rename to services/OBSOLETE_core-ai/src/tools/registry.py diff --git a/services/core-ai/src/utils.py b/services/OBSOLETE_core-ai/src/utils.py similarity index 100% rename from services/core-ai/src/utils.py rename to services/OBSOLETE_core-ai/src/utils.py diff --git a/services/core-ai/tests/README.md b/services/core-ai/tests/README.md deleted file mode 100644 index c88c739..0000000 --- a/services/core-ai/tests/README.md +++ /dev/null @@ -1,232 +0,0 @@ -# Core-AI Test Suite - -Layered testing approach to diagnose and validate the core-ai service. - -## Quick Start - -```bash -# Run all tests in sequence -bash tests/run_all_tests.sh - -# Or run individual layers -pytest tests/test_01_environment.py -v -s -pytest tests/test_02_litellm_raw.py -v -s -pytest tests/test_03_message_format.py -v -s -pytest tests/test_04_agent.py -v -s -pytest tests/test_05_api.py -v -s # Requires service running -``` - -## Test Layers - -### Layer 1: Environment & Configuration -**File:** `test_01_environment.py` - -Tests basic configuration and environment setup: -- βœ“ Settings load correctly -- βœ“ Required environment variables are set -- βœ“ Ollama is reachable -- βœ“ Target model is available in Ollama -- βœ“ System prompt variant exists - -**When this fails:** Check environment variables, Ollama connectivity, model availability - -### Layer 2: Raw LiteLLM Connection -**File:** `test_02_litellm_raw.py` - -Tests direct LiteLLM β†’ Ollama communication without any wrappers: -- βœ“ Simple completion works -- βœ“ System prompt is respected -- βœ“ Streaming mode works -- βœ“ Can answer "What is the capital of France?" - -**When this fails:** Issue is in LiteLLM/Ollama integration, not the agent wrapper - -### Layer 3: Message Formatting & Prompts -**File:** `test_03_message_format.py` - -Tests prompt management and message structure: -- βœ“ Prompts are defined correctly -- βœ“ System prompt injection works -- βœ“ Messages are formatted properly -- βœ“ No duplicate system prompts - -**When this fails:** Check prompts.py and message formatting logic - -### Layer 4: Agent Logic -**File:** `test_04_agent.py` - -Tests the SimpleLiteLLMAgent class: -- βœ“ Agent initializes correctly -- βœ“ Streaming chat works -- βœ“ Non-streaming completion works -- βœ“ System prompt is injected -- βœ“ Can answer "What is the capital of France?" - -**When this fails:** Issue is in the agent wrapper (src/agent.py) - -### Layer 5: API Integration -**File:** `test_05_api.py` - -Tests the HTTP API endpoints (requires service running): -- βœ“ Health check works -- βœ“ Non-streaming API works -- βœ“ Streaming API works -- βœ“ OpenAI-compatible format -- βœ“ Error handling - -**When this fails:** Issue is in the API layer (main.py) - -## Diagnostic Tools - -### Check Ollama -```bash -python diagnostics/check_ollama.py -``` - -Quick script to verify: -- Ollama connectivity -- Available models -- Basic text generation - -### Test LiteLLM Direct -```bash -python diagnostics/test_litellm_direct.py -``` - -Standalone test that bypasses all abstractions and tests raw LiteLLM β†’ Ollama. - -## Running Tests - -### All tests in sequence (recommended) -```bash -bash tests/run_all_tests.sh -``` - -This runs all layers and stops at the first failure, helping you identify exactly where the issue is. - -### Individual test layers -```bash -# Install dependencies first -pip install -r requirements.txt - -# Run specific layer -pytest tests/test_01_environment.py -v -s -``` - -### With Docker - -If running in Docker, exec into the container: -```bash -docker exec -it core-ai bash -cd /app -bash tests/run_all_tests.sh -``` - -## Understanding Test Results - -### βœ“ All tests pass -The foundation is solid. If the service still doesn't work, check: -- Application logs -- Request/response formatting -- Client integration - -### βœ— Layer 1 fails -**Problem:** Environment or configuration issue -**Fix:** -- Check environment variables -- Verify Ollama is running: `docker ps | grep ollama` -- Check model is available: `docker exec ollama ollama list` - -### βœ— Layer 2 fails -**Problem:** LiteLLM/Ollama integration issue -**Fix:** -- Check Ollama logs: `docker logs ollama` -- Verify model works directly: `docker exec ollama ollama run gemma2:9b-instruct-q5_K_M "test"` -- Check LiteLLM version compatibility - -### βœ— Layer 3 fails -**Problem:** Prompt configuration issue -**Fix:** -- Check `src/prompts.py` has required variants -- Verify `SYSTEM_PROMPT_VARIANT` env var matches a defined prompt - -### βœ— Layer 4 fails -**Problem:** Agent wrapper issue -**Fix:** -- Check `src/agent.py` for bugs -- Review message formatting logic -- Check system prompt injection - -### βœ— Layer 5 fails -**Problem:** API layer issue -**Fix:** -- Ensure service is running: `python main.py` -- Check logs for errors -- Verify request/response format - -## Adding New Tests - -Follow the layered approach: -1. Add test to appropriate layer file -2. Use descriptive test names: `test_` -3. Add clear assertions with messages -4. Print useful debug info for when tests pass - -Example: -```python -@pytest.mark.asyncio -async def test_new_feature(): - """Test that new feature works""" - # Setup - agent = get_simple_litellm_agent() - - # Execute - result = await agent.some_method() - - # Assert - assert result is not None, "Result should not be None" - print(f"βœ“ Feature works: {result}") -``` - -## Troubleshooting - -### Tests hang or timeout -- Increase timeout in test -- Check Ollama is responding: `curl http://ollama:11434/api/tags` -- Model may be loading on first run (can take 30-60s) - -### Import errors -```bash -pip install -r requirements.txt -``` - -### Pytest not found -```bash -pip install pytest pytest-asyncio -``` - -### Can't connect to Ollama -- Check docker network: `docker network ls` -- Verify services are on same network -- Try using IP instead of hostname - -## Next Steps After Tests Pass - -1. **Start the service:** - ```bash - python main.py - ``` - -2. **Test manually:** - ```bash - curl -X POST http://localhost:8086/v1/chat/completions \ - -H 'Content-Type: application/json' \ - -d '{"messages": [{"role": "user", "content": "What is the capital of France?"}]}' - ``` - -3. **Deploy in Docker:** - ```bash - docker-compose up core-ai - ``` - -4. **Integrate with other services** diff --git a/services/core-ai/tests/__init__.py b/services/core-ai/tests/__init__.py deleted file mode 100644 index 4c85571..0000000 --- a/services/core-ai/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Core-AI test suite - layered testing approach""" diff --git a/services/core-ai/tests/run_all_tests.sh b/services/core-ai/tests/run_all_tests.sh deleted file mode 100755 index b7c9921..0000000 --- a/services/core-ai/tests/run_all_tests.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/bin/bash -# Run all core-ai tests in sequence, stopping at first failure - -set -e # Exit on first error - -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -PROJECT_DIR="$( cd "$SCRIPT_DIR/.." && pwd )" - -echo "========================================================================" -echo "CORE-AI LAYERED TEST SUITE" -echo "========================================================================" -echo "" -echo "Project directory: $PROJECT_DIR" -echo "" - -# Colors for output -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Function to run a test layer -run_layer() { - local layer_num=$1 - local layer_name=$2 - local test_file=$3 - - echo "" - echo "========================================================================" - echo "Layer $layer_num: $layer_name" - echo "========================================================================" - - if [ -f "$PROJECT_DIR/$test_file" ]; then - cd "$PROJECT_DIR" - if pytest "$test_file" -v -s; then - echo -e "${GREEN}βœ“ Layer $layer_num PASSED${NC}" - return 0 - else - echo -e "${RED}βœ— Layer $layer_num FAILED${NC}" - echo "" - echo "The test suite stops at the first failure to help you identify" - echo "exactly which layer is causing the problem." - echo "" - echo "Fix this layer before proceeding to the next one." - return 1 - fi - else - echo -e "${RED}βœ— Test file not found: $test_file${NC}" - return 1 - fi -} - -# Check if pytest is installed -if ! command -v pytest &> /dev/null; then - echo -e "${RED}βœ— pytest not found. Installing...${NC}" - pip install pytest pytest-asyncio -fi - -# Run diagnostic tools first (optional, non-blocking) -echo "========================================================================" -echo "Pre-flight Diagnostics (optional)" -echo "========================================================================" -echo "" -echo -e "${YELLOW}β†’ Running Ollama connectivity check...${NC}" -if python "$PROJECT_DIR/diagnostics/check_ollama.py"; then - echo -e "${GREEN}βœ“ Ollama diagnostics passed${NC}" -else - echo -e "${YELLOW}⚠ Ollama diagnostics failed - tests may fail${NC}" - echo "Continue anyway? (y/n)" - read -r response - if [[ ! "$response" =~ ^[Yy]$ ]]; then - exit 1 - fi -fi - -echo "" -echo -e "${YELLOW}β†’ Running direct LiteLLM test...${NC}" -if python "$PROJECT_DIR/diagnostics/test_litellm_direct.py"; then - echo -e "${GREEN}βœ“ LiteLLM diagnostics passed${NC}" -else - echo -e "${YELLOW}⚠ LiteLLM diagnostics failed - tests may fail${NC}" - echo "Continue anyway? (y/n)" - read -r response - if [[ ! "$response" =~ ^[Yy]$ ]]; then - exit 1 - fi -fi - -# Run test layers in sequence -run_layer 1 "Environment & Configuration" "tests/test_01_environment.py" || exit 1 -run_layer 2 "Raw LiteLLM Connection" "tests/test_02_litellm_raw.py" || exit 1 -run_layer 3 "Message Formatting & Prompts" "tests/test_03_message_format.py" || exit 1 -run_layer 4 "Agent Logic" "tests/test_04_agent.py" || exit 1 - -# Layer 5 requires the service to be running -echo "" -echo "========================================================================" -echo "Layer 5: API Integration (requires service running)" -echo "========================================================================" -echo "" -echo -e "${YELLOW}Layer 5 requires the core-ai service to be running.${NC}" -echo "Is the service running? (y/n/skip)" -read -r response - -if [[ "$response" =~ ^[Yy]$ ]]; then - run_layer 5 "API Integration" "tests/test_05_api.py" || exit 1 -elif [[ "$response" =~ ^[Ss].*$ ]]; then - echo -e "${YELLOW}⊘ Layer 5 skipped${NC}" -else - echo "" - echo "To run Layer 5:" - echo " 1. Start the service: python main.py" - echo " 2. In another terminal, run: pytest tests/test_05_api.py -v -s" -fi - -# Summary -echo "" -echo "========================================================================" -echo -e "${GREEN}βœ“ ALL ENABLED TEST LAYERS PASSED!${NC}" -echo "========================================================================" -echo "" -echo "Next steps:" -echo " - If tests passed but the service still doesn't work, check logs" -echo " - Run the service: python main.py" -echo " - Test manually: curl -X POST http://localhost:8086/v1/chat/completions \\" -echo " -H 'Content-Type: application/json' \\" -echo " -d '{\"messages\": [{\"role\": \"user\", \"content\": \"What is the capital of France?\"}]}'" -echo "" diff --git a/services/core-ai/tests/test_01_environment.py b/services/core-ai/tests/test_01_environment.py deleted file mode 100644 index faee978..0000000 --- a/services/core-ai/tests/test_01_environment.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -""" -Layer 1: Environment & Configuration Tests -Tests that all environment variables and configuration are correct. -""" -import pytest -import httpx -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.config import get_settings - - -def test_settings_load(): - """Test that settings load correctly""" - settings = get_settings() - assert settings is not None - print(f"βœ“ Settings loaded") - - -def test_required_settings(): - """Test that all required settings are present""" - settings = get_settings() - - # Check required fields - assert settings.ollama_base_url, "OLLAMA_BASE_URL not set" - assert settings.agent_model, "AGENT_MODEL not set" - assert settings.system_prompt_variant, "SYSTEM_PROMPT_VARIANT not set" - - print(f"βœ“ Ollama URL: {settings.ollama_base_url}") - print(f"βœ“ Model: {settings.agent_model}") - print(f"βœ“ Prompt variant: {settings.system_prompt_variant}") - - -@pytest.mark.asyncio -async def test_ollama_reachable(): - """Test that Ollama is reachable at the configured URL""" - settings = get_settings() - - async with httpx.AsyncClient(timeout=30.0) as client: - try: - response = await client.get(f"{settings.ollama_base_url}/api/tags") - assert response.status_code == 200, f"Ollama returned status {response.status_code}" - print(f"βœ“ Ollama is reachable at {settings.ollama_base_url}") - except httpx.ConnectError as e: - pytest.fail(f"Cannot connect to Ollama: {e}") - - -@pytest.mark.asyncio -async def test_model_available(): - """Test that the configured model is available in Ollama""" - settings = get_settings() - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get(f"{settings.ollama_base_url}/api/tags") - data = response.json() - models = data.get("models", []) - - model_names = [m.get("name", "") for m in models] - model_found = any(settings.agent_model in name for name in model_names) - - assert model_found, f"Model '{settings.agent_model}' not found in Ollama. Available: {model_names}" - print(f"βœ“ Model '{settings.agent_model}' is available") - - -def test_prompt_variant_exists(): - """Test that the configured prompt variant exists""" - from src.prompts import get_prompt - settings = get_settings() - - prompt = get_prompt(settings.system_prompt_variant) - assert prompt is not None, f"Prompt variant '{settings.system_prompt_variant}' not found" - assert len(prompt) > 0, "Prompt is empty" - print(f"βœ“ Prompt variant '{settings.system_prompt_variant}' exists") - print(f" Prompt: {prompt[:100]}...") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/core-ai/tests/test_04_agent.py b/services/core-ai/tests/test_04_agent.py deleted file mode 100644 index 863c100..0000000 --- a/services/core-ai/tests/test_04_agent.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 -""" -Layer 4: Agent Logic Tests -Tests the SimpleLiteLLMAgent class and its methods. -""" -import pytest -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.agent import SimpleLiteLLMAgent, get_simple_litellm_agent - - -def test_agent_initialization(): - """Test that agent initializes correctly""" - agent = SimpleLiteLLMAgent() - assert agent is not None, "Agent failed to initialize" - assert agent.settings is not None, "Settings not loaded" - assert agent.system_prompt is not None, "System prompt not loaded" - assert agent.model_params is not None, "Model params not set" - - print(f"βœ“ Agent initialized") - print(f" Model: {agent.model_params['model']}") - print(f" API base: {agent.model_params['api_base']}") - - -def test_agent_singleton(): - """Test that get_simple_litellm_agent returns cached instance""" - agent1 = get_simple_litellm_agent() - agent2 = get_simple_litellm_agent() - - assert agent1 is agent2, "Agent should be singleton" - print(f"βœ“ Agent singleton working") - - -@pytest.mark.asyncio -async def test_agent_chat_streaming(): - """Test agent chat method in streaming mode""" - agent = get_simple_litellm_agent() - - messages = [{"role": "user", "content": "What is 1+1? Answer with just the number."}] - - chunks = [] - chunk_count = 0 - final_reason = None - - print(f"\nβ†’ Testing agent.chat() streaming...") - async for chunk in agent.chat(messages=messages, stream=True): - chunk_count += 1 - if chunk.get("type") == "content": - content = chunk.get("content", "") - if content: - chunks.append(content) - if chunk.get("finish_reason"): - final_reason = chunk["finish_reason"] - - full_content = "".join(chunks) - assert chunk_count > 0, "No chunks received" - assert len(full_content) > 0, "No content received" - assert final_reason == "stop", f"Expected finish_reason='stop', got '{final_reason}'" - - print(f"βœ“ Received {chunk_count} chunks") - print(f"βœ“ Content: {full_content}") - - -@pytest.mark.asyncio -async def test_agent_chat_completion(): - """Test agent chat_completion method (non-streaming)""" - agent = get_simple_litellm_agent() - - messages = [{"role": "user", "content": "What is 2+2? Answer with just the number."}] - - print(f"\nβ†’ Testing agent.chat_completion()...") - response = await agent.chat_completion(messages=messages) - - assert response is not None, "No response received" - assert len(response) > 0, "Empty response" - assert response != "I couldn't generate a response.", "Agent returned fallback message" - - print(f"βœ“ Response: {response}") - - -@pytest.mark.asyncio -async def test_agent_capital_of_france(): - """Test the actual failing query through the agent""" - agent = get_simple_litellm_agent() - - messages = [{"role": "user", "content": "What is the capital of France?"}] - - print(f"\nβ†’ Testing 'What is the capital of France?' through agent...") - response = await agent.chat_completion(messages=messages) - - assert response is not None, "No response received" - assert len(response) > 0, "Empty response" - assert "paris" in response.lower(), f"Expected 'Paris' in answer, got: {response}" - - print(f"βœ“ Correct answer: {response}") - - -@pytest.mark.asyncio -async def test_agent_error_handling(): - """Test agent error handling with invalid input""" - agent = get_simple_litellm_agent() - - # Test with empty messages (should still work due to system prompt injection) - messages = [] - - try: - response = await agent.chat_completion(messages=messages) - # If this succeeds, it means system prompt was injected - print(f"βœ“ Agent handled empty messages: {response[:50]}...") - except Exception as e: - # If it fails, that's also acceptable behavior - print(f"βœ“ Agent raised error for empty messages: {type(e).__name__}") - - -@pytest.mark.asyncio -async def test_agent_system_prompt_injection(): - """Test that agent injects system prompt correctly""" - agent = get_simple_litellm_agent() - - # Message without system prompt - messages = [{"role": "user", "content": "Hello"}] - - # We can't directly inspect the messages sent to LiteLLM, - # but we can verify the agent has a system prompt - assert agent.system_prompt is not None, "Agent has no system prompt" - assert len(agent.system_prompt) > 0, "System prompt is empty" - - print(f"βœ“ Agent has system prompt: {agent.system_prompt[:80]}...") - - # Test a completion to ensure it works - response = await agent.chat_completion(messages=messages) - assert len(response) > 0, "No response received" - print(f"βœ“ System prompt injection working (response received)") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/core-ai/tests/test_05_api.py b/services/core-ai/tests/test_05_api.py deleted file mode 100644 index a62cf52..0000000 --- a/services/core-ai/tests/test_05_api.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python3 -""" -Layer 5: API Integration Tests -Tests the HTTP API endpoints (requires the service to be running). -""" -import pytest -import httpx -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.config import get_settings - - -# Note: These tests require the core-ai service to be running -# If running locally: python main.py -# If in Docker: docker-compose up core-ai - - -@pytest.mark.asyncio -async def test_health_endpoint(): - """Test the health check endpoint""" - settings = get_settings() - api_url = f"http://{settings.host}:{settings.port}" - - async with httpx.AsyncClient(timeout=10.0) as client: - try: - response = await client.get(f"{api_url}/health") - assert response.status_code == 200, f"Health check returned {response.status_code}" - - data = response.json() - assert data.get("status") == "ok", f"Health status not ok: {data}" - - print(f"βœ“ Health check passed: {data}") - except httpx.ConnectError: - pytest.skip("Core-AI service not running. Start it with: python main.py") - - -@pytest.mark.asyncio -async def test_chat_completions_non_streaming(): - """Test /v1/chat/completions endpoint (non-streaming)""" - settings = get_settings() - api_url = f"http://{settings.host}:{settings.port}" - - payload = { - "model": "test", - "messages": [ - {"role": "user", "content": "What is 2+2? Answer with just the number."} - ], - "stream": False - } - - async with httpx.AsyncClient(timeout=60.0) as client: - try: - print(f"\nβ†’ Testing non-streaming chat completion...") - response = await client.post(f"{api_url}/v1/chat/completions", json=payload) - assert response.status_code == 200, f"API returned {response.status_code}: {response.text}" - - data = response.json() - - # Validate OpenAI-compatible response format - assert "id" in data, "Missing 'id' field" - assert "object" in data, "Missing 'object' field" - assert "choices" in data, "Missing 'choices' field" - assert len(data["choices"]) > 0, "No choices in response" - - choice = data["choices"][0] - assert "message" in choice, "Missing 'message' in choice" - assert "content" in choice["message"], "Missing 'content' in message" - - content = choice["message"]["content"] - assert len(content) > 0, "Empty content" - - print(f"βœ“ Response received: {content}") - - except httpx.ConnectError: - pytest.skip("Core-AI service not running. Start it with: python main.py") - - -@pytest.mark.asyncio -async def test_chat_completions_streaming(): - """Test /v1/chat/completions endpoint (streaming)""" - settings = get_settings() - api_url = f"http://{settings.host}:{settings.port}" - - payload = { - "model": "test", - "messages": [ - {"role": "user", "content": "Count from 1 to 3."} - ], - "stream": True - } - - async with httpx.AsyncClient(timeout=60.0) as client: - try: - print(f"\nβ†’ Testing streaming chat completion...") - async with client.stream("POST", f"{api_url}/v1/chat/completions", json=payload) as response: - assert response.status_code == 200, f"API returned {response.status_code}" - - chunks_received = 0 - async for line in response.aiter_lines(): - if line.startswith("data: "): - chunks_received += 1 - if line == "data: [DONE]": - break - - assert chunks_received > 0, "No streaming chunks received" - print(f"βœ“ Received {chunks_received} streaming chunks") - - except httpx.ConnectError: - pytest.skip("Core-AI service not running. Start it with: python main.py") - - -@pytest.mark.asyncio -async def test_chat_completions_capital_of_france(): - """Test the actual failing query through the API""" - settings = get_settings() - api_url = f"http://{settings.host}:{settings.port}" - - payload = { - "model": "test", - "messages": [ - {"role": "user", "content": "What is the capital of France?"} - ], - "stream": False - } - - async with httpx.AsyncClient(timeout=60.0) as client: - try: - print(f"\nβ†’ Testing 'What is the capital of France?' through API...") - response = await client.post(f"{api_url}/v1/chat/completions", json=payload) - assert response.status_code == 200, f"API returned {response.status_code}: {response.text}" - - data = response.json() - content = data["choices"][0]["message"]["content"] - - assert "paris" in content.lower(), f"Expected 'Paris' in answer, got: {content}" - print(f"βœ“ Correct answer: {content}") - - except httpx.ConnectError: - pytest.skip("Core-AI service not running. Start it with: python main.py") - - -@pytest.mark.asyncio -async def test_chat_completions_error_handling(): - """Test API error handling""" - settings = get_settings() - api_url = f"http://{settings.host}:{settings.port}" - - # Test with missing messages field - payload = { - "model": "test", - "stream": False - # Missing 'messages' field - } - - async with httpx.AsyncClient(timeout=10.0) as client: - try: - response = await client.post(f"{api_url}/v1/chat/completions", json=payload) - assert response.status_code == 400, f"Expected 400, got {response.status_code}" - - data = response.json() - assert "error" in data, "Error response should have 'error' field" - - print(f"βœ“ Error handling works: {data['error']}") - - except httpx.ConnectError: - pytest.skip("Core-AI service not running. Start it with: python main.py") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/core-ai/tests/test_06_pydantic_setup.py b/services/core-ai/tests/test_06_pydantic_setup.py deleted file mode 100644 index 3a9dd9c..0000000 --- a/services/core-ai/tests/test_06_pydantic_setup.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -""" -Layer 6: PydanticAI Setup Tests -Tests that PydanticAI initializes correctly and can handle basic completions. -""" -import pytest -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.config import get_settings -from src.prompts import get_prompt -from src.agents import PYDANTIC_AI_AVAILABLE - -if not PYDANTIC_AI_AVAILABLE: - pytest.skip("PydanticAI not available", allow_module_level=True) - -from src.agents import PydanticAgent - - -def test_pydantic_import(): - """Test that PydanticAI can be imported""" - assert PYDANTIC_AI_AVAILABLE, "PydanticAI should be available" - print("βœ“ PydanticAI imports successful") - - -def test_pydantic_prompt_exists(): - """Test that PydanticAI prompt variant exists""" - settings = get_settings() - prompt = get_prompt(settings.pydantic_system_prompt_variant) - - assert prompt is not None, "PydanticAI prompt should exist" - assert len(prompt) > 0, "PydanticAI prompt should not be empty" - assert "assistant" in prompt.lower() or "tools" in prompt.lower(), "PydanticAI prompt should mention tools/assistant" - - print(f"βœ“ PydanticAI prompt variant '{settings.pydantic_system_prompt_variant}' exists") - print(f" Prompt: {prompt[:100]}...") - - -def test_pydantic_agent_initialization(): - """Test that PydanticAI agent can be initialized without tools""" - try: - agent = PydanticAgent(tools=[], enable_memory=False) - assert agent is not None, "Agent should be initialized" - assert agent.model is not None, "Model should be initialized" - assert agent.agent is not None, "PydanticAI agent should be initialized" - assert agent.tools == [], "Tools should be empty" - - print("βœ“ PydanticAI agent initialized successfully") - print(f" Model: {agent.model}") - print(f" Tools: {len(agent.tools)}") - - except Exception as e: - pytest.fail(f"PydanticAI agent initialization failed: {e}") - - -@pytest.mark.asyncio -async def test_pydantic_simple_completion(): - """Test PydanticAI agent with a simple question (no tools needed)""" - agent = PydanticAgent(tools=[], enable_memory=False) - - messages = [{"role": "user", "content": "What is 2+2? Answer with just the number."}] - - print(f"\nβ†’ Testing PydanticAI completion...") - response = await agent.chat_completion(messages=messages) - - assert response is not None, "Response should not be None" - assert len(response) > 0, "Response should not be empty" - assert "4" in response, f"Expected '4' in response, got: {response}" - - print(f"βœ“ PydanticAI response: {response}") - - -@pytest.mark.asyncio -async def test_pydantic_streaming(): - """Test PydanticAI agent streaming mode""" - agent = PydanticAgent(tools=[], enable_memory=False) - - messages = [{"role": "user", "content": "Count from 1 to 3. Just the numbers."}] - - chunks = [] - event_count = 0 - - print(f"\nβ†’ Testing PydanticAI streaming...") - async for chunk in agent.chat(messages=messages, stream=True): - event_count += 1 - if chunk.get("type") == "content" and chunk.get("content"): - chunks.append(chunk["content"]) - - full_content = "".join(chunks) - - assert event_count > 0, "Should receive events" - assert len(full_content) > 0, "Should receive content" - - print(f"βœ“ Received {event_count} events") - print(f"βœ“ Content: {full_content}") - - -@pytest.mark.asyncio -async def test_pydantic_capital_of_france(): - """Test PydanticAI with the standard 'capital of France' question""" - agent = PydanticAgent(tools=[], enable_memory=False) - - messages = [{"role": "user", "content": "What is the capital of France?"}] - - print(f"\nβ†’ Testing 'What is the capital of France?' with PydanticAI...") - response = await agent.chat_completion(messages=messages) - - assert response is not None, "Response should not be None" - assert len(response) > 0, "Response should not be empty" - assert "paris" in response.lower(), f"Expected 'Paris' in answer, got: {response}" - - print(f"βœ“ Correct answer: {response}") - - -def test_pydantic_system_prompt_loading(): - """Test that PydanticAI agent loads correct system prompt""" - agent = PydanticAgent(tools=[], enable_memory=False) - - settings = get_settings() - expected_prompt = get_prompt(settings.pydantic_system_prompt_variant) - - assert agent.system_prompt == expected_prompt, "System prompt should match config" - print(f"βœ“ System prompt loaded correctly") - print(f" Variant: {settings.pydantic_system_prompt_variant}") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/core-ai/tests/test_07_pydantic_tools.py b/services/core-ai/tests/test_07_pydantic_tools.py deleted file mode 100644 index 028b894..0000000 --- a/services/core-ai/tests/test_07_pydantic_tools.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -""" -Layer 7: PydanticAI Tools Tests -Tests that local tools are registered and work with the PydanticAI agent. -""" -import pytest -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.config import get_settings -from src.tools.registry import get_all_tools, clear_registry -from src.agents import PYDANTIC_AI_AVAILABLE - -if not PYDANTIC_AI_AVAILABLE: - pytest.skip("PydanticAI not available", allow_module_level=True) - -from src.agents import PydanticAgent - - -def test_local_tools_registered(): - """Test that local tools are automatically registered""" - tools = get_all_tools() - - # Expected local tools - expected_tools = [ - "get_current_time", - "get_current_date", - "calculate_date_difference", - "add_days_to_date", - "calculate", - ] - - for tool_name in expected_tools: - assert tool_name in tools, f"Tool {tool_name} should be registered" - - print(f"βœ“ All {len(expected_tools)} local tools registered") - print(f" Tools: {list(tools.keys())}") - - -@pytest.mark.asyncio -async def test_local_tool_execution(): - """Test that local tools can be executed directly""" - from src.tools.local import get_current_time, calculate - - # Test time tool - time_result = await get_current_time() - assert time_result is not None - assert len(time_result) > 0 - assert "T" in time_result # ISO format has T separator - print(f"βœ“ get_current_time: {time_result}") - - # Test calculator tool - calc_result = await calculate("2 + 2") - assert calc_result == "4" - print(f"βœ“ calculate('2 + 2'): {calc_result}") - - # Test complex calculation - calc_result2 = await calculate("10 * (5 + 3)") - assert calc_result2 == "80" - print(f"βœ“ calculate('10 * (5 + 3)'): {calc_result2}") - - -@pytest.mark.asyncio -async def test_calculator_security(): - """Test that calculator rejects dangerous expressions""" - from src.tools.local import calculate - - # Test that dangerous operations are blocked - dangerous_expressions = [ - "__import__('os').system('ls')", - "exec('print(1)')", - "eval('1+1')", - "open('/etc/passwd')", - ] - - for expr in dangerous_expressions: - result = await calculate(expr) - assert "Error" in result, f"Should reject dangerous expression: {expr}" - print(f"βœ“ Blocked dangerous expression: {expr}") - - -@pytest.mark.asyncio -async def test_pydantic_agent_with_tools(): - """Test PydanticAI agent initialization with tools""" - agent = PydanticAgent(discover_tools=True, enable_memory=False) - - assert len(agent.tools) > 0, "Agent should have tools" - print(f"βœ“ PydanticAI agent initialized with {len(agent.tools)} tools") - - -@pytest.mark.asyncio -async def test_tool_calling_integration(): - """Test that PydanticAI agent can use tools to answer questions""" - agent = PydanticAgent(discover_tools=True, enable_memory=False) - - # Ask a question that requires the calculator tool - messages = [{"role": "user", "content": "What is 15 + 27? Use the calculate tool."}] - - print(f"\nβ†’ Testing tool calling with: {messages[0]['content']}") - response = await agent.chat_completion(messages=messages) - - assert response is not None, "Should get a response" - assert len(response) > 0, "Response should not be empty" - - # The response should contain the answer - # Note: The agent might or might not use the tool, depending on the model - print(f"βœ“ Response received: {response[:200]}...") - - -@pytest.mark.asyncio -async def test_date_tools(): - """Test date manipulation tools""" - from src.tools.local import get_current_date, add_days_to_date, calculate_date_difference - - # Get current date - current_date = await get_current_date() - assert current_date is not None - assert "-" in current_date # YYYY-MM-DD format - print(f"βœ“ Current date: {current_date}") - - # Add days - future_date = await add_days_to_date(current_date, 7) - assert future_date is not None - print(f"βœ“ Date + 7 days: {future_date}") - - # Calculate difference - diff = await calculate_date_difference(current_date, future_date) - assert "7 days" in diff - print(f"βœ“ Date difference: {diff}") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/core-ai/tests/test_10_pydantic_api.py b/services/core-ai/tests/test_10_pydantic_api.py deleted file mode 100644 index cdf3bee..0000000 --- a/services/core-ai/tests/test_10_pydantic_api.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 -""" -Layer 10: PydanticAI API Tests -Tests HTTP endpoints for both simple and PydanticAI agents. -""" -import pytest -import sys -import httpx -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.agents import PYDANTIC_AI_AVAILABLE - -# Base URL for the service -BASE_URL = "http://localhost:8086" - - -@pytest.mark.asyncio -async def test_health_check(): - """Test the health check endpoint""" - async with httpx.AsyncClient() as client: - response = await client.get(f"{BASE_URL}/health") - assert response.status_code == 200 - - data = response.json() - assert data["status"] == "ok" - assert data["service"] == "core-ai" - assert "agents" in data - assert "tools_count" in data - - print(f"βœ“ Health check OK") - print(f" Agents: {data['agents']}") - print(f" Tools: {data['tools_count']}") - - -@pytest.mark.asyncio -async def test_list_tools(): - """Test the tools listing endpoint""" - async with httpx.AsyncClient() as client: - response = await client.get(f"{BASE_URL}/v1/tools") - assert response.status_code == 200 - - data = response.json() - assert "tools" in data - assert "tools_count" in data - assert data["tools_count"] > 0 - - print(f"βœ“ Tools endpoint OK") - print(f" Total tools: {data['tools_count']}") - for tool in data["tools"]: - print(f" - {tool['name']}: {tool['description'][:50]}...") - - -@pytest.mark.asyncio -async def test_chat_completions_default(): - """Test /v1/chat/completions endpoint (default - should use PydanticAI)""" - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{BASE_URL}/v1/chat/completions", - json={ - "messages": [ - {"role": "user", "content": "What is 2+2? Answer with just the number."} - ], - "stream": False - } - ) - - assert response.status_code == 200 - data = response.json() - - assert "choices" in data - assert len(data["choices"]) > 0 - assert "message" in data["choices"][0] - - content = data["choices"][0]["message"]["content"] - print(f"βœ“ /v1/chat/completions response: {content[:100]}...") - - -@pytest.mark.asyncio -async def test_chat_simple_endpoint(): - """Test /v1/chat/simple endpoint""" - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{BASE_URL}/v1/chat/simple", - json={ - "messages": [ - {"role": "user", "content": "Say 'hello' in one word."} - ], - "stream": False - } - ) - - assert response.status_code == 200 - data = response.json() - - assert data["model"] == "simple" - assert "choices" in data - assert len(data["choices"]) > 0 - - content = data["choices"][0]["message"]["content"] - print(f"βœ“ /v1/chat/simple response: {content}") - - -@pytest.mark.asyncio -async def test_chat_pydantic_endpoint(): - """Test /v1/chat/completions endpoint with tools (Tatlock)""" - if not PYDANTIC_AI_AVAILABLE: - pytest.skip("PydanticAI not available") - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{BASE_URL}/v1/chat/completions", - json={ - "messages": [ - {"role": "user", "content": "What is the current date?"} - ], - "stream": False, - "enable_tools": True - } - ) - - assert response.status_code == 200 - data = response.json() - - assert data["model"] == "Tatlock" - assert "choices" in data - assert "tools_enabled" in data or "two_stage_analysis" in data - - content = data["choices"][0]["message"]["content"] - print(f"βœ“ /v1/chat/completions (Tatlock) response: {content[:200]}...") - print(f" Tools enabled: {data.get('tools_enabled', True)}") - print(f" Two-stage: {data.get('two_stage_analysis', True)}") - - -@pytest.mark.asyncio -async def test_chat_pydantic_with_calculator(): - """Test Tatlock endpoint using calculator tool""" - if not PYDANTIC_AI_AVAILABLE: - pytest.skip("PydanticAI not available") - - async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.post( - f"{BASE_URL}/v1/chat/completions", - json={ - "messages": [ - {"role": "user", "content": "What is 123 + 456? Use the calculate tool."} - ], - "stream": False, - "enable_tools": True - } - ) - - assert response.status_code == 200 - data = response.json() - - content = data["choices"][0]["message"]["content"] - print(f"βœ“ Tatlock with calculator: {content}") - - -@pytest.mark.asyncio -async def test_streaming_simple(): - """Test streaming response from simple endpoint""" - async with httpx.AsyncClient(timeout=30.0) as client: - async with client.stream( - "POST", - f"{BASE_URL}/v1/chat/simple", - json={ - "messages": [ - {"role": "user", "content": "Count from 1 to 3"} - ], - "stream": True - } - ) as response: - assert response.status_code == 200 - - chunks = [] - async for line in response.aiter_lines(): - if line.startswith("data: "): - data_str = line[6:] - if data_str == "[DONE]": - break - try: - import json - chunk_data = json.loads(data_str) - if "choices" in chunk_data: - delta_content = chunk_data["choices"][0]["delta"].get("content", "") - if delta_content: - chunks.append(delta_content) - except json.JSONDecodeError: - pass - - full_response = "".join(chunks) - print(f"βœ“ Streaming response received: {full_response[:100]}...") - assert len(full_response) > 0 - - -@pytest.mark.asyncio -async def test_pydantic_without_tools(): - """Test Tatlock endpoint with tools disabled""" - if not PYDANTIC_AI_AVAILABLE: - pytest.skip("PydanticAI not available") - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{BASE_URL}/v1/chat/completions", - json={ - "messages": [ - {"role": "user", "content": "Hello!"} - ], - "stream": False, - "enable_tools": False - } - ) - - assert response.status_code == 200 - data = response.json() - - # With tools disabled, tools_enabled should be False or not present - assert data.get("tools_enabled", False) is False - - print(f"βœ“ Tatlock without tools works") - - -@pytest.mark.asyncio -async def test_list_models(): - """Test the /v1/models endpoint""" - async with httpx.AsyncClient() as client: - response = await client.get(f"{BASE_URL}/v1/models") - assert response.status_code == 200 - - data = response.json() - assert "object" in data - assert data["object"] == "list" - assert "data" in data - assert len(data["data"]) > 0 - - print(f"βœ“ Models endpoint OK") - print(f" Available models:") - for model in data["data"]: - print(f" - {model['id']}") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/core-ai/tests/test_agent_timezone.py b/services/core-ai/tests/test_agent_timezone.py deleted file mode 100644 index df15218..0000000 --- a/services/core-ai/tests/test_agent_timezone.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -""" -Test that PydanticAI agent correctly uses timezone tool -""" -import pytest -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.agents import PYDANTIC_AI_AVAILABLE - -if not PYDANTIC_AI_AVAILABLE: - pytest.skip("PydanticAI not available", allow_module_level=True) - -from src.agents import PydanticAgent - - -@pytest.mark.asyncio -async def test_agent_uses_tool_for_amsterdam_time(): - """Test that agent uses get_current_time tool for Amsterdam time query""" - agent = PydanticAgent(discover_tools=True, enable_memory=False) - - # Verify tools are loaded - assert len(agent.tools) > 0, "Agent should have tools" - print(f"βœ“ Agent has {len(agent.tools)} tools") - - # Ask about Amsterdam time - messages = [{"role": "user", "content": "What time is it in Amsterdam right now?"}] - - print(f"\nβ†’ Testing Amsterdam time query...") - response = await agent.chat_completion(messages=messages) - - print(f"βœ“ Response: {response}") - - # The response should mention Amsterdam and include a time - assert response is not None, "Should get a response" - assert len(response) > 0, "Response should not be empty" - - # Response should contain time-related information - # Note: We can't assert exact format since model might phrase it differently, - # but it should at least mention time or a specific hour - print(f"βœ“ Agent responded with time information") - - -@pytest.mark.asyncio -async def test_agent_without_tools_limitation(): - """Test that agent without tools acknowledges limitation""" - agent = PydanticAgent(discover_tools=False, enable_memory=False) - - # Verify no tools - assert len(agent.tools) == 0, "Agent should have no tools" - print(f"βœ“ Agent has no tools (as expected)") - - messages = [{"role": "user", "content": "What time is it in Tokyo?"}] - - print(f"\nβ†’ Testing time query without tools...") - response = await agent.chat_completion(messages=messages) - - print(f"βœ“ Response: {response}") - # Agent should respond, but without tools it might not have accurate time - - -@pytest.mark.asyncio -async def test_agent_timezone_calculation(): - """Test agent can handle timezone-related questions""" - agent = PydanticAgent(discover_tools=True, enable_memory=False) - - messages = [{"role": "user", "content": "What's the current time in UTC and Europe/Paris?"}] - - print(f"\nβ†’ Testing multiple timezone query...") - response = await agent.chat_completion(messages=messages) - - print(f"βœ“ Response: {response}") - assert response is not None - assert len(response) > 0 - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/core-ai/tests/test_timezone.py b/services/core-ai/tests/test_timezone.py deleted file mode 100644 index a4e186c..0000000 --- a/services/core-ai/tests/test_timezone.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -""" -Test timezone-aware time tool -""" -import pytest -import sys -from pathlib import Path -from datetime import datetime -import pytz - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from src.tools.local import get_current_time - - -@pytest.mark.asyncio -async def test_get_current_time_utc(): - """Test getting time in UTC""" - result = await get_current_time("UTC") - - assert result is not None - assert "UTC" in result - assert "2025" in result # Current year - print(f"βœ“ UTC time: {result}") - - -@pytest.mark.asyncio -async def test_get_current_time_amsterdam(): - """Test getting time in Amsterdam""" - result = await get_current_time("Europe/Amsterdam") - - assert result is not None - assert "CET" in result or "CEST" in result # Central European Time (Standard or Summer) - assert "2025" in result - print(f"βœ“ Amsterdam time: {result}") - - -@pytest.mark.asyncio -async def test_get_current_time_new_york(): - """Test getting time in New York""" - result = await get_current_time("America/New_York") - - assert result is not None - assert "EST" in result or "EDT" in result # Eastern Standard/Daylight Time - assert "2025" in result - print(f"βœ“ New York time: {result}") - - -@pytest.mark.asyncio -async def test_get_current_time_tokyo(): - """Test getting time in Tokyo""" - result = await get_current_time("Asia/Tokyo") - - assert result is not None - assert "JST" in result # Japan Standard Time - assert "2025" in result - print(f"βœ“ Tokyo time: {result}") - - -@pytest.mark.asyncio -async def test_get_current_time_invalid_timezone(): - """Test that invalid timezone returns error""" - result = await get_current_time("Invalid/Timezone") - - assert "Error" in result - assert "Unknown timezone" in result - print(f"βœ“ Invalid timezone error: {result}") - - -@pytest.mark.asyncio -async def test_timezone_offset_correctness(): - """Test that timezone offset is correct""" - # Get times in different timezones - utc_str = await get_current_time("UTC") - amsterdam_str = await get_current_time("Europe/Amsterdam") - - # Parse the times - utc_time = datetime.strptime(utc_str, "%Y-%m-%d %H:%M:%S %Z") - - # Amsterdam should be 1 hour ahead of UTC in winter (CET) - # We can't assert exact offset without knowing if it's DST, but we can check it's valid - assert utc_str != amsterdam_str, "UTC and Amsterdam times should be different" - print(f"βœ“ UTC: {utc_str}") - print(f"βœ“ Amsterdam: {amsterdam_str}") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/services/core-api/REQUESTED_SERVICES.md b/services/core-api/REQUESTED_SERVICES.md new file mode 100644 index 0000000..278a2a1 --- /dev/null +++ b/services/core-api/REQUESTED_SERVICES.md @@ -0,0 +1,446 @@ +# Requested Core-API Services for Core-AI Infrastructure Tools + +This document specifies the API endpoints needed by core-ai infrastructure tools. All requests from core-ai should go through core-api for centralized logging and access control. + +## Context + +The core-ai service is implementing 9 infrastructure tools in 3 logical clusters: +1. **Container Lifecycle** (4 tools) - containers.py +2. **Service Management** (3 tools) - services.py +3. **Monitoring & Resources** (2 tools) - monitoring.py + +These tools need corresponding core-api REST endpoints to perform operations via Portainer. + +--- + +## Cluster 1: Container Lifecycle Management + +### 1.1 List Containers + +**Endpoint:** `GET /v1/infrastructure/containers` + +**Query Parameters:** +- `status` (optional): Filter by status - "all", "running", "stopped", "paused" (default: "running") + +**Response:** +```json +[ + { + "Id": "abc123...", + "Names": ["/nginx"], + "State": "running", + "Status": "Up 3 days", + "Image": "nginx:latest", + "Ports": [ + {"PrivatePort": 80, "PublicPort": 8080, "Type": "tcp"}, + {"PrivatePort": 443, "PublicPort": 8443, "Type": "tcp"} + ], + "StartedAt": "2024-12-01T10:00:00Z" + } +] +``` + +**Implementation Notes:** +- Use `PortainerClient.list_containers(all_containers=True)` with Docker socket fallback +- Filter results based on `status` query parameter +- Return standard Docker API container list format + +--- + +### 1.2 Manage Container + +**Endpoint:** `POST /v1/infrastructure/containers/{container}/{action}` + +**Path Parameters:** +- `container`: Container name or ID (e.g., "nginx", "core-ai") +- `action`: One of: "start", "stop", "restart", "pause", "unpause", "remove" + +**Response (Success):** +```json +{ + "success": true, + "action": "restart", + "container": "nginx", + "message": "Container restarted successfully" +} +``` + +**Response (Error):** +```json +{ + "success": false, + "error": "Container not found", + "message": "Container 'nginx2' not found. Available containers: nginx, core-ai, ollama" +} +``` + +**Status Codes:** +- `200` - Success +- `304` - Not Modified (already in target state) +- `404` - Container not found +- `409` - Conflict (e.g., cannot remove running container) +- `500` - Server error + +**Implementation Notes:** +- For "restart": call stop then start +- For actions not yet in PortainerClient (pause, unpause, remove): + - Call Portainer API directly: `/api/endpoints/{endpoint_id}/docker/containers/{container_id}/{action}` +- Handle partial name matching (case-insensitive) +- Return helpful error messages suggesting `docker_list_containers()` when not found + +--- + +### 1.3 Inspect Container + +**Endpoint:** `GET /v1/infrastructure/containers/{container}` + +**Path Parameters:** +- `container`: Container name or ID + +**Query Parameters:** +- `details` (optional): Level of detail - "summary" (default), "full", "resources" + +**Response:** +```json +{ + "Id": "abc123...", + "Name": "/nginx", + "State": { + "Status": "running", + "Running": true, + "StartedAt": "2024-12-01T10:00:00Z", + "FinishedAt": "0001-01-01T00:00:00Z", + "ExitCode": 0 + }, + "Config": { + "Image": "nginx:latest", + "Env": ["PATH=/usr/local/sbin:...", "NGINX_VERSION=1.25.0"], + "Cmd": ["nginx", "-g", "daemon off;"] + }, + "NetworkSettings": { + "Ports": { + "80/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}], + "443/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8443"}] + }, + "Networks": { + "bridge": { + "IPAddress": "172.17.0.2", + "Gateway": "172.17.0.1" + } + } + }, + "HostConfig": { + "Memory": 536870912, + "NanoCpus": 1000000000, + "RestartPolicy": {"Name": "unless-stopped"} + }, + "Mounts": [ + { + "Type": "bind", + "Source": "/host/path", + "Destination": "/container/path" + } + ] +} +``` + +**Implementation Notes:** +- Use `PortainerClient.inspect_container(container)` which auto-detects endpoint and falls back to Docker socket +- Return full Docker inspect response +- The core-ai tool will handle formatting based on `details` level +- Return 404 if container not found + +--- + +### 1.4 Container Logs + +**Endpoint:** `GET /v1/infrastructure/containers/{container}/logs` + +**Path Parameters:** +- `container`: Container name or ID + +**Query Parameters:** +- `lines` (optional): Number of log lines (default: 50, max: 500) +- `since` (optional): Time filter - "1h", "30m", or ISO timestamp + +**Response:** +```json +{ + "container": "nginx", + "lines_requested": 50, + "since": null, + "logs": "2024-12-04T10:00:00.123Z Starting nginx...\n2024-12-04T10:00:01.456Z Ready to accept connections\n..." +} +``` + +**Implementation Notes:** +- Access Docker API directly: `GET /v1.41/containers/{container}/logs` + - Use Docker socket transport (httpx with uds) + - Parameters: `stdout=true`, `stderr=true`, `tail={lines}`, `timestamps=true` + - If `since` provided: add `since={unix_timestamp}` parameter +- Strip Docker stream headers (8-byte binary prefix per line) +- Return plain text logs with timestamps +- Return 404 if container not found + +--- + +## Cluster 2: Service Management + +### 2.1 List Services + +**Endpoint:** `GET /v1/infrastructure/services` (already exists, may need enhancement) + +**Query Parameters:** +- `stack` (optional): Filter by stack name + +**Response:** +```json +[ + { + "name": "portainer", + "stack_id": 1, + "status": "active", + "containers_running": 3, + "containers_total": 3, + "ports": [9000, 8000], + "domains": ["portainer.example.com"] + } +] +``` + +**Implementation Notes:** +- Enhance existing `/infrastructure/services` endpoint if needed +- Ensure it returns stack/service information from Portainer +- Include container counts (running/total) + +--- + +### 2.2 Manage Service + +**Endpoint:** `POST /v1/infrastructure/services/{service}/{action}` + +**Path Parameters:** +- `service`: Service/stack name +- `action`: One of: "start", "stop", "restart", "scale" + +**Request Body (for scale action):** +```json +{ + "replicas": 3 +} +``` + +**Response:** +```json +{ + "success": true, + "action": "restart", + "service": "web", + "message": "Service restarted successfully" +} +``` + +**Implementation Notes:** +- For "start"/"stop": Use Portainer stack start/stop API +- For "restart": Stop then start the stack +- For "scale": Update stack with new replica count + - This may require updating stack compose file + +--- + +### 2.3 Service Status + +**Endpoint:** `GET /v1/infrastructure/services/{service}/status` + +**Path Parameters:** +- `service`: Service/stack name + +**Response:** +```json +{ + "name": "web", + "status": "active", + "stack_id": 5, + "containers": [ + { + "name": "web_app_1", + "status": "running", + "health": "healthy", + "uptime": "2 days" + } + ], + "replica_status": "3/3 running", + "resources": { + "memory_total": "1.2 GB", + "cpu_usage": "15%" + }, + "recent_events": [ + {"time": "2024-12-04T09:00:00Z", "action": "container_start", "container": "web_app_3"} + ] +} +``` + +**Implementation Notes:** +- Get stack details from Portainer +- Get individual container statuses +- Calculate aggregate resource usage +- May require querying Docker events API for recent events + +--- + +## Cluster 3: Monitoring & Resources + +### 3.1 System Resources + +**Endpoint:** `GET /v1/infrastructure/resources/system` + +**Response:** +```json +{ + "cpu": { + "cores": 8, + "usage_percent": 45.2, + "load_average": [2.5, 2.3, 2.1] + }, + "memory": { + "total_bytes": 16777216000, + "used_bytes": 8388608000, + "available_bytes": 8388608000, + "usage_percent": 50.0 + }, + "disk": { + "total_bytes": 500000000000, + "used_bytes": 250000000000, + "available_bytes": 250000000000, + "usage_percent": 50.0 + }, + "network": { + "interfaces": { + "eth0": { + "rx_bytes": 1000000000, + "tx_bytes": 500000000 + } + } + } +} +``` + +**Implementation Notes:** +- Use Docker system info API: `GET /v1.41/system/df` +- May also use `GET /v1.41/info` for system-wide stats +- Calculate percentages and format nicely +- Include load averages from system stats + +--- + +### 3.2 Container Resources + +**Endpoint:** `GET /v1/infrastructure/resources/containers` + +**Query Parameters:** +- `container` (optional): Specific container name/ID (if omitted, return all) + +**Response:** +```json +[ + { + "name": "nginx", + "cpu_percent": 5.2, + "memory_usage_bytes": 45000000, + "memory_limit_bytes": 100000000, + "memory_percent": 45.0, + "network_rx_bytes": 50000000, + "network_tx_bytes": 25000000, + "block_read_bytes": 10000000, + "block_write_bytes": 5000000 + } +] +``` + +**Implementation Notes:** +- Use Docker stats API: `GET /v1.41/containers/{id}/stats?stream=false` +- If `container` param provided: return single container stats +- If omitted: return stats for all running containers +- Calculate percentages where applicable +- Stats API returns real-time metrics (one-time snapshot, not streaming) + +--- + +## Implementation Priority + +**Phase 1 (Needed immediately for core-ai):** +1. `GET /v1/infrastructure/containers` - List containers +2. `POST /v1/infrastructure/containers/{container}/{action}` - Manage containers +3. `GET /v1/infrastructure/containers/{container}` - Inspect container +4. `GET /v1/infrastructure/containers/{container}/logs` - Container logs + +**Phase 2 (Needed for full infrastructure tools):** +5. `POST /v1/infrastructure/services/{service}/{action}` - Manage services +6. `GET /v1/infrastructure/services/{service}/status` - Service status +7. `GET /v1/infrastructure/resources/system` - System resources +8. `GET /v1/infrastructure/resources/containers` - Container resources + +--- + +## Security & Access Control + +All endpoints should: +- Log all requests (especially write operations) +- Support OIDC authentication when enabled +- Require admin privileges for destructive operations (remove, scale) +- Rate limit to prevent abuse +- Validate input parameters +- Return sanitized errors (no sensitive data in error messages) + +--- + +## Error Handling + +Standard error response format: +```json +{ + "error": "ContainerNotFound", + "message": "Container 'nginx2' not found", + "details": { + "container": "nginx2", + "available_containers": ["nginx", "core-ai", "ollama"] + } +} +``` + +Common error codes: +- `400` - Bad Request (invalid parameters) +- `404` - Not Found (container/service doesn't exist) +- `409` - Conflict (invalid state transition) +- `500` - Internal Server Error (Portainer/Docker API failed) +- `503` - Service Unavailable (Portainer/Docker not accessible) + +--- + +## Testing + +Each endpoint should have: +- Unit tests (mock Portainer client) +- Integration tests (real Portainer/Docker) +- Error case tests (not found, permission denied, etc.) +- Performance tests (ensure response times < 2s) + +--- + +## Questions / Decisions Needed + +1. **Authentication**: Should container management require admin role, or allow read-only for all users? +2. **Rate Limiting**: What limits should be applied to prevent abuse? +3. **Caching**: Should container lists be cached? (TTL: 5s?) +4. **Async**: Should heavy operations (like logs) be async with job IDs? +5. **Webhooks**: Should operations emit events for monitoring? + +--- + +## Notes + +- All endpoints follow RESTful conventions +- Use existing PortainerClient methods where available +- Fall back to Docker socket when Portainer doesn't have data +- Log all operations with timestamps, user, and outcome +- Consider adding `/v1/infrastructure/containers/search` for fuzzy name matching diff --git a/services/core-api/src/controllers/infrastructure_controller.py b/services/core-api/src/controllers/infrastructure_controller.py index e047f1a..a5a180d 100644 --- a/services/core-api/src/controllers/infrastructure_controller.py +++ b/services/core-api/src/controllers/infrastructure_controller.py @@ -287,6 +287,180 @@ class InfrastructureController(BaseController): logger.error(f"Failed to get service '{name}': {e}") raise HTTPException(status_code=500, detail=str(e)) + @router.post( + "/services/{service}/manage", + response_model=Dict[str, Any], + summary="Manage service lifecycle" + ) + async def manage_service(service: str, action: str, replicas: Optional[int] = None): + """ + Manage Docker Compose service/stack lifecycle. + + Args: + service: Service/stack name + action: One of: "start", "stop", "restart", "scale" + replicas: Number of replicas (required for scale action) + + Returns: + Success status and message + """ + from pydantic import BaseModel + + class ManageServiceRequest(BaseModel): + action: str + replicas: Optional[int] = None + + portainer = get_portainer_client() + logger.info(f"Managing service '{service}': action={action}, replicas={replicas}") + + # Validate action + valid_actions = ["start", "stop", "restart", "scale"] + if action not in valid_actions: + raise HTTPException( + status_code=400, + detail=f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" + ) + + # Validate scale has replicas + if action == "scale" and replicas is None: + raise HTTPException( + status_code=400, + detail="'scale' action requires 'replicas' parameter" + ) + + try: + # Find the stack by name + stacks = await portainer.get_stacks() + stack = next( + (s for s in stacks if s.get("Name", "").lower() == service.lower()), + None + ) + + if not stack: + raise HTTPException( + status_code=404, + detail=f"Service '{service}' not found" + ) + + stack_id = stack.get("Id") + endpoint_id = stack.get("EndpointId") + + # Perform the action + if action in ["start", "stop", "restart"]: + # These actions need to be implemented + # For now, return not implemented + raise HTTPException( + status_code=501, + detail=f"Action '{action}' not yet implemented for services" + ) + + elif action == "scale": + # Scaling requires updating the stack's compose file + raise HTTPException( + status_code=501, + detail="Scaling not yet implemented" + ) + + return { + "success": True, + "action": action, + "service": service, + "message": f"Action '{action}' completed successfully" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to {action} service '{service}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @router.get( + "/services/{service}/status", + response_model=Dict[str, Any], + summary="Get detailed service status" + ) + async def get_service_status(service: str): + """ + Get comprehensive service status including containers, resources, and events. + + Args: + service: Service/stack name + + Returns: + Detailed status information + """ + portainer = get_portainer_client() + logger.info(f"Getting status for service '{service}'") + + try: + # Find the stack + stacks = await portainer.get_stacks() + stack = next( + (s for s in stacks if s.get("Name", "").lower() == service.lower()), + None + ) + + if not stack: + raise HTTPException( + status_code=404, + detail=f"Service '{service}' not found" + ) + + stack_id = stack.get("Id") + endpoint_id = stack.get("EndpointId") + status_code = stack.get("Status", 0) + + # Get containers for this stack + all_containers = await portainer.list_containers(all_containers=True) + + # Filter containers belonging to this stack + # Stack name is usually in the container labels + stack_containers = [] + for container in all_containers: + labels = container.get('Labels', {}) + # Check if container belongs to this stack + # Docker Compose adds labels like: com.docker.compose.project + project = labels.get('com.docker.compose.project', '').lower() + if project == service.lower(): + stack_containers.append(container) + + # Format container info + container_info = [] + running_count = 0 + for c in stack_containers: + state = c.get('State', 'unknown') + if state == 'running': + running_count += 1 + + container_info.append({ + 'name': c.get('Names', ['unknown'])[0].lstrip('/'), + 'status': state, + 'health': 'N/A', # Would need to inspect container for health + 'uptime': c.get('Status', 'N/A') + }) + + total_containers = len(stack_containers) + replica_status = f"{running_count}/{total_containers} running" + + return { + "name": stack.get("Name"), + "status": "active" if status_code == 1 else "inactive", + "stack_id": stack_id, + "replica_status": replica_status, + "containers": container_info, + "resources": { + "memory_total": "N/A", # Would need to aggregate container stats + "cpu_usage": "N/A" + }, + "recent_events": [] # Would need to query Docker events API + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get status for service '{service}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + @router.get( "/ports", response_model=List[PortInfo], @@ -1284,6 +1458,474 @@ class InfrastructureController(BaseController): logger.error(f"Failed to delete monitor {monitor_id}: {e}") raise HTTPException(status_code=500, detail=f"Failed to delete monitor: {str(e)}") + # ======================================================================== + # Container Management Endpoints (for core-ai infrastructure tools) + # ======================================================================== + + @router.get( + "/containers", + response_model=List[Dict[str, Any]], + summary="List Docker containers" + ) + async def list_containers(status: Optional[str] = "running"): + """ + List Docker containers with optional status filter. + + Args: + status: Filter by status - "all", "running", "stopped", "paused" (default: "running") + + Returns: + List of containers in Docker API format + """ + portainer = get_portainer_client() + logger.info(f"Listing containers (status filter: {status})") + + try: + # Get all containers using Portainer with Docker socket fallback + all_containers_flag = status in ["all", "stopped"] + containers = await portainer.list_containers(all_containers=all_containers_flag) + + # Filter by status if needed + if status == "running": + containers = [c for c in containers if c.get('State') == 'running'] + elif status == "stopped": + containers = [c for c in containers if c.get('State') != 'running'] + elif status == "paused": + containers = [c for c in containers if c.get('State') == 'paused'] + + logger.info(f"Found {len(containers)} containers matching status '{status}'") + return containers + + except Exception as e: + logger.error(f"Failed to list containers: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to list containers: {str(e)}") + + @router.post( + "/containers/{container}/{action}", + response_model=Dict[str, Any], + summary="Manage container state" + ) + async def manage_container(container: str, action: str): + """ + Perform lifecycle operation on a Docker container. + + Args: + container: Container name or ID + action: One of: "start", "stop", "restart", "pause", "unpause", "remove" + + Returns: + Success status and message + """ + portainer = get_portainer_client() + logger.info(f"Managing container '{container}': action={action}") + + # Validate action + valid_actions = ["start", "stop", "restart", "pause", "unpause", "remove"] + if action not in valid_actions: + raise HTTPException( + status_code=400, + detail=f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" + ) + + try: + # Find the container + all_containers = await portainer.list_containers(all_containers=True) + + matching_container = None + for c in all_containers: + names = c.get('Names', []) + for name in names: + clean_name = name.lstrip('/') + if clean_name.lower() == container.lower(): + matching_container = c + break + if matching_container: + break + + if not matching_container: + raise HTTPException( + status_code=404, + detail=f"Container '{container}' not found" + ) + + container_id = matching_container['Id'] + + # Get endpoints + endpoints = await portainer.get_endpoints() + if not endpoints: + raise HTTPException(status_code=500, detail="No Portainer endpoints available") + + endpoint_id = endpoints[0]['Id'] + + # Perform the action + if action == "start": + await portainer.start_container(endpoint_id, container_id) + message = f"Started container '{container}' successfully" + + elif action == "stop": + await portainer.stop_container(endpoint_id, container_id) + message = f"Stopped container '{container}' successfully" + + elif action == "restart": + await portainer.stop_container(endpoint_id, container_id) + await portainer.start_container(endpoint_id, container_id) + message = f"Restarted container '{container}' successfully" + + elif action in ["pause", "unpause", "remove"]: + # These actions need to be added to Portainer client + # For now, return error + raise HTTPException( + status_code=501, + detail=f"Action '{action}' not yet implemented" + ) + + logger.info(f"Successfully {action}ed container '{container}'") + + return { + "success": True, + "action": action, + "container": container, + "message": message + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to {action} container '{container}': {e}", exc_info=True) + + # Provide helpful error messages + error_str = str(e).lower() + if "304" in error_str or "not modified" in error_str: + raise HTTPException( + status_code=304, + detail=f"Container '{container}' is already in the target state" + ) + elif "conflict" in error_str: + raise HTTPException( + status_code=409, + detail=f"Cannot {action} container '{container}': state conflict" + ) + else: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get( + "/containers/{container}", + response_model=Dict[str, Any], + summary="Inspect container" + ) + async def inspect_container( + container: str, + details: Optional[str] = "summary" + ): + """ + Get detailed information about a Docker container. + + Args: + container: Container name or ID + details: Level of detail - "summary", "full", or "resources" (not used server-side, for client formatting) + + Returns: + Container details in Docker inspect format + """ + portainer = get_portainer_client() + logger.info(f"Inspecting container '{container}' (details={details})") + + try: + # Use Portainer's inspect_container which auto-detects endpoint and falls back to Docker socket + info = await portainer.inspect_container(container) + + if not info: + raise HTTPException( + status_code=404, + detail=f"Container '{container}' not found" + ) + + logger.info(f"Successfully inspected container '{container}'") + return info + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to inspect container '{container}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @router.get( + "/containers/{container}/logs", + response_model=Dict[str, Any], + summary="Get container logs" + ) + async def get_container_logs( + container: str, + lines: Optional[int] = 50, + since: Optional[str] = None + ): + """ + Retrieve logs from a Docker container. + + Args: + container: Container name or ID + lines: Number of log lines to retrieve (default: 50, max: 500) + since: Time filter - "1h", "30m", or ISO timestamp (not yet implemented) + + Returns: + Container logs with metadata + """ + import httpx + + logger.info(f"Retrieving logs for container '{container}' (lines={lines}, since={since})") + + # Clamp lines to reasonable limit + lines = min(max(1, lines), 500) + + try: + # Access Docker socket directly to get logs + transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock") + async with httpx.AsyncClient(transport=transport, timeout=15.0) as client: + # Get container logs via Docker API + params = { + "stdout": "true", + "stderr": "true", + "tail": lines, + "timestamps": "true" + } + + # TODO: Add 'since' parameter support + # if since: + # params["since"] = parse_since_to_timestamp(since) + + response = await client.get( + f"http://localhost/v1.41/containers/{container}/logs", + params=params + ) + + if response.status_code == 404: + raise HTTPException( + status_code=404, + detail=f"Container '{container}' not found" + ) + + response.raise_for_status() + logs_raw = response.text + + # Docker logs come with binary prefixes (8 bytes per line) + # Strip these headers for cleaner output + lines_list = logs_raw.split('\n') + cleaned_lines = [] + + for line in lines_list: + if len(line) > 8: + # Skip the 8-byte Docker stream header if present + cleaned_line = line[8:] if line[0:1] in [b'\x01', b'\x02', '\x01', '\x02'] else line + cleaned_lines.append(cleaned_line) + elif line: + cleaned_lines.append(line) + + logs = '\n'.join(cleaned_lines).strip() + + logger.info(f"Successfully retrieved logs for container '{container}' ({len(cleaned_lines)} lines)") + + return { + "container": container, + "lines_requested": lines, + "since": since, + "logs": logs + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to retrieve logs for container '{container}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + # ======================================================================== + # Monitoring & Resource Endpoints (for core-ai infrastructure tools) + # ======================================================================== + + @router.get( + "/resources/system", + response_model=Dict[str, Any], + summary="Get system resource usage" + ) + async def get_system_resources(): + """ + Get overall system resource usage. + + Returns: + System-level metrics including CPU, memory, disk, and network + """ + logger.info("Getting system resources") + + try: + # Access Docker system info via socket + import httpx + + transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock") + async with httpx.AsyncClient(transport=transport, timeout=10.0) as client: + # Get system info + info_response = await client.get("http://localhost/v1.41/info") + info_response.raise_for_status() + info = info_response.json() + + # Get system df (disk usage) + df_response = await client.get("http://localhost/v1.41/system/df") + df_response.raise_for_status() + df = df_response.json() + + # Extract system metrics + ncpu = info.get('NCPU', 0) + mem_total = info.get('MemTotal', 0) + + # Calculate memory usage (rough estimate) + # Docker doesn't directly provide used memory, so we estimate + mem_used = mem_total * 0.5 # Placeholder - would need psutil or /proc + mem_available = mem_total - mem_used + mem_usage_pct = (mem_used / mem_total * 100) if mem_total > 0 else 0 + + # Disk usage from system/df + layers_size = sum(img.get('Size', 0) for img in df.get('Images', [])) + containers_size = sum(c.get('SizeRw', 0) for c in df.get('Containers', [])) + volumes_size = sum(v.get('UsageData', {}).get('Size', 0) for v in df.get('Volumes', [])) + + total_disk_used = layers_size + containers_size + volumes_size + + return { + "cpu": { + "cores": ncpu, + "usage_percent": None, # Would need to calculate from stats over time + "load_average": [] # Not available from Docker API + }, + "memory": { + "total_bytes": mem_total, + "used_bytes": mem_used, + "available_bytes": mem_available, + "usage_percent": mem_usage_pct + }, + "disk": { + "total_bytes": None, # Not available from Docker API + "used_bytes": total_disk_used, + "available_bytes": None, + "usage_percent": None + }, + "network": { + "interfaces": {} # Would need to parse network stats + } + } + + except Exception as e: + logger.error(f"Failed to get system resources: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @router.get( + "/resources/containers", + response_model=List[Dict[str, Any]], + summary="Get container resource usage" + ) + async def get_container_resources(container: Optional[str] = None): + """ + Get container-specific resource usage. + + Args: + container: Optional specific container name/ID + + Returns: + List of container resource stats + """ + logger.info(f"Getting container resources (container={container})") + + try: + import httpx + portainer = get_portainer_client() + + # Get list of containers + if container: + # Get specific container + all_containers = await portainer.list_containers(all_containers=False) + containers = [c for c in all_containers + if container.lower() in c.get('Names', [''])[0].lower()] + + if not containers: + raise HTTPException(status_code=404, detail=f"Container '{container}' not found") + else: + # Get all running containers + containers = await portainer.list_containers(all_containers=False) + + # Get stats for each container + stats_list = [] + transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock") + + async with httpx.AsyncClient(transport=transport, timeout=15.0) as client: + for c in containers: + container_id = c.get('Id') + name = c.get('Names', ['unknown'])[0].lstrip('/') + + try: + # Get container stats (one-time, not streaming) + stats_response = await client.get( + f"http://localhost/v1.41/containers/{container_id}/stats", + params={"stream": "false"} + ) + stats_response.raise_for_status() + stats = stats_response.json() + + # Parse stats + cpu_stats = stats.get('cpu_stats', {}) + precpu_stats = stats.get('precpu_stats', {}) + memory_stats = stats.get('memory_stats', {}) + networks = stats.get('networks', {}) + blkio_stats = stats.get('blkio_stats', {}) + + # Calculate CPU percentage + cpu_delta = cpu_stats.get('cpu_usage', {}).get('total_usage', 0) - \ + precpu_stats.get('cpu_usage', {}).get('total_usage', 0) + system_delta = cpu_stats.get('system_cpu_usage', 0) - \ + precpu_stats.get('system_cpu_usage', 0) + online_cpus = cpu_stats.get('online_cpus', 1) + + cpu_percent = 0.0 + if system_delta > 0 and cpu_delta > 0: + cpu_percent = (cpu_delta / system_delta) * online_cpus * 100.0 + + # Memory stats + mem_usage = memory_stats.get('usage', 0) + mem_limit = memory_stats.get('limit', 0) + mem_percent = (mem_usage / mem_limit * 100) if mem_limit > 0 else 0 + + # Network stats + net_rx = sum(net.get('rx_bytes', 0) for net in networks.values()) + net_tx = sum(net.get('tx_bytes', 0) for net in networks.values()) + + # Block I/O stats + io_service_bytes = blkio_stats.get('io_service_bytes_recursive', []) + block_read = sum(entry.get('value', 0) for entry in io_service_bytes + if entry.get('op') == 'Read') + block_write = sum(entry.get('value', 0) for entry in io_service_bytes + if entry.get('op') == 'Write') + + stats_list.append({ + "name": name, + "cpu_percent": cpu_percent, + "memory_usage_bytes": mem_usage, + "memory_limit_bytes": mem_limit, + "memory_percent": mem_percent, + "network_rx_bytes": net_rx, + "network_tx_bytes": net_tx, + "block_read_bytes": block_read, + "block_write_bytes": block_write + }) + + except Exception as e: + logger.warning(f"Failed to get stats for container {name}: {e}") + # Skip containers that fail to get stats + continue + + return stats_list + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get container resources: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + return router