Files
macha-autonomous/chat.py
Lily Miller 782dce4b2d Add action explanation and follow-up functionality to MachaChatSession
- Introduced `explain_action` method to provide detailed explanations for pending actions in the approval queue.
- Added `answer_action_followup` method to handle user follow-up questions regarding proposed actions.
- Updated `main` function to support discussion mode with action explanations and follow-ups.
- Refactored `conversation.py` to utilize the unified chat implementation from `chat.py`, enhancing compatibility and functionality.
- Enhanced error handling for file operations and user input validation in both new methods.
2025-10-09 16:42:28 -06:00

517 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Interactive chat interface with Macha AI agent.
Unified chat/conversation interface using tool-calling architecture.
"""
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from agent import MachaAgent
class MachaChatSession:
"""Interactive chat session with Macha using tool-calling architecture"""
def __init__(
self,
ollama_host: str = "http://localhost:11434",
model: str = "gpt-oss:latest",
state_dir: Path = Path("/var/lib/macha"),
enable_tools: bool = True
):
"""Initialize chat session with Macha
Args:
ollama_host: Ollama API endpoint
model: Model name to use
state_dir: State directory for agent
enable_tools: Whether to enable tool calling (should always be True)
"""
self.agent = MachaAgent(
ollama_host=ollama_host,
model=model,
state_dir=state_dir,
enable_tools=enable_tools,
use_queue=True,
priority="INTERACTIVE"
)
self.conversation_history: List[Dict[str, str]] = []
self.session_start = datetime.now().isoformat()
def _auto_diagnose_ollama(self) -> str:
"""Automatically diagnose Ollama issues"""
diagnostics = []
diagnostics.append("🔍 AUTO-DIAGNOSIS: Investigating Ollama failure...\n")
# Check if Ollama service is running
try:
result = subprocess.run(
['systemctl', 'is-active', 'ollama.service'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
diagnostics.append("✅ Ollama service is active")
else:
diagnostics.append(f"❌ Ollama service is NOT active: {result.stdout.strip()}")
# Get service status
status_result = subprocess.run(
['systemctl', 'status', 'ollama.service', '--no-pager', '-l'],
capture_output=True,
text=True,
timeout=5
)
diagnostics.append(f"\nService status:\n```\n{status_result.stdout[-500:]}\n```")
except Exception as e:
diagnostics.append(f"⚠️ Could not check service status: {e}")
# Check memory usage
try:
result = subprocess.run(['free', '-h'], capture_output=True, text=True, timeout=5)
lines = result.stdout.split('\n')
for line in lines[:3]: # First 3 lines
diagnostics.append(f" {line}")
except Exception as e:
diagnostics.append(f"⚠️ Could not check memory: {e}")
# Check which models are loaded
try:
import requests
response = requests.get(f"{self.agent.ollama_host}/api/tags", timeout=5)
if response.status_code == 200:
models = response.json().get('models', [])
diagnostics.append(f"\n📦 Loaded models ({len(models)}):")
for model in models:
name = model.get('name', 'unknown')
size = model.get('size', 0) / (1024**3)
is_current = "← TARGET" if name == self.agent.model else ""
diagnostics.append(f"{name} ({size:.1f} GB) {is_current}")
# Check if target model is loaded
model_names = [m.get('name') for m in models]
if self.agent.model not in model_names:
diagnostics.append(f"\n❌ TARGET MODEL NOT LOADED: {self.agent.model}")
diagnostics.append(f" Available models: {', '.join(model_names)}")
else:
diagnostics.append(f"❌ Ollama API returned {response.status_code}")
except Exception as e:
diagnostics.append(f"⚠️ Could not query Ollama API: {e}")
# Check recent Ollama logs
try:
result = subprocess.run(
['journalctl', '-u', 'ollama.service', '-n', '10', '--no-pager'],
capture_output=True,
text=True,
timeout=5
)
if result.stdout:
diagnostics.append(f"\n📋 Recent Ollama logs (last 10 lines):\n```\n{result.stdout}\n```")
except Exception as e:
diagnostics.append(f"⚠️ Could not check logs: {e}")
return "\n".join(diagnostics)
def process_message(self, user_message: str, verbose: bool = False) -> str:
"""Process a user message and return Macha's response
Args:
user_message: The user's message
verbose: Whether to show detailed token counts
Returns:
Macha's response
"""
# Add user message to history
self.conversation_history.append({
'role': 'user',
'message': user_message,
'timestamp': datetime.now().isoformat()
})
# Build chat messages for tool-calling API
messages = []
# Query relevant knowledge based on user message
knowledge_context = self.agent._query_relevant_knowledge(user_message, limit=3)
# Add recent conversation history (last 15 messages to stay within context limits)
recent_history = self.conversation_history[-15:]
for entry in recent_history:
content = entry['message']
# Truncate very long messages (e.g., command outputs)
if len(content) > 3000:
content = content[:1500] + "\n... [message truncated] ...\n" + content[-1500:]
# Add knowledge context to last user message if available
if entry == recent_history[-1] and knowledge_context:
content += knowledge_context
messages.append({
"role": entry['role'],
"content": content
})
if verbose:
# Estimate tokens for debugging
total_chars = sum(len(json.dumps(m)) for m in messages)
estimated_tokens = total_chars // 4
print(f"[Context: {estimated_tokens:,} tokens, {len(messages)} messages]")
try:
# Use tool-aware chat API - this handles all tool calling automatically
# Note: tool definitions are retrieved internally by _query_ollama_with_tools
ai_response = self.agent._query_ollama_with_tools(messages)
except Exception as e:
error_msg = (
f"❌ CRITICAL: Failed to communicate with Ollama inference engine\n\n"
f"Error Type: {type(e).__name__}\n"
f"Error Message: {str(e)}\n\n"
)
# Auto-diagnose the issue
diagnostics = self._auto_diagnose_ollama()
return error_msg + "\n" + diagnostics
if not ai_response:
error_msg = (
f"❌ Empty response from Ollama inference engine\n\n"
f"The request succeeded but returned no data. This usually means:\n"
f" • The model ({self.agent.model}) is still loading\n"
f" • Ollama ran out of memory during generation\n"
f" • The prompt was too large for the context window\n\n"
)
# Auto-diagnose the issue
diagnostics = self._auto_diagnose_ollama()
return error_msg + "\n" + diagnostics
# Add response to history
self.conversation_history.append({
'role': 'assistant',
'message': ai_response,
'timestamp': datetime.now().isoformat()
})
return ai_response
def run_interactive(self):
"""Run the interactive chat session"""
print("=" * 70)
print("🌐 MACHA INTERACTIVE CHAT")
print("=" * 70)
print("Type your message and press Enter. Commands:")
print(" /exit or /quit - End the chat session")
print(" /clear - Clear conversation history")
print(" /history - Show conversation history")
print(" /debug - Show Ollama connection status")
print("=" * 70)
print()
while True:
try:
# Get user input
user_input = input("\n💬 YOU: ").strip()
if not user_input:
continue
# Handle special commands
if user_input.lower() in ['/exit', '/quit']:
print("\n👋 Ending chat session. Goodbye!")
break
elif user_input.lower() == '/clear':
self.conversation_history.clear()
print("🧹 Conversation history cleared.")
continue
elif user_input.lower() == '/history':
print("\n" + "=" * 70)
print("CONVERSATION HISTORY")
print("=" * 70)
for entry in self.conversation_history:
role = entry['role'].upper()
msg = entry['message'][:100] + "..." if len(entry['message']) > 100 else entry['message']
print(f"{role}: {msg}")
print("=" * 70)
continue
elif user_input.lower() == '/debug':
print("\n" + "=" * 70)
print("MACHA ARCHITECTURE & STATUS")
print("=" * 70)
print("\n🏗️ SYSTEM ARCHITECTURE:")
print(f" Hostname: macha.coven.systems")
print(f" Service: macha-autonomous.service (systemd)")
print(f" Working Directory: /var/lib/macha")
print("\n👤 EXECUTION CONTEXT:")
current_user = os.getenv('USER') or os.getenv('USERNAME') or 'unknown'
print(f" Current User: {current_user}")
print(f" UID: {os.getuid()}")
# Check if user has sudo access
try:
result = subprocess.run(['sudo', '-n', 'true'],
capture_output=True, timeout=1)
if result.returncode == 0:
print(f" Sudo Access: ✓ Yes (passwordless)")
else:
print(f" Sudo Access: ⚠ Requires password")
except:
print(f" Sudo Access: ❌ No")
print(f" Note: Chat runs as invoking user (you), using macha's tools")
print("\n🧠 INFERENCE ENGINE:")
print(f" Backend: Ollama")
print(f" Host: {self.agent.ollama_host}")
print(f" Model: {self.agent.model}")
print(f" Service: ollama.service (systemd)")
print(f" Queue Worker: ollama-queue-worker.service")
print("\n💾 DATABASE:")
print(f" Backend: ChromaDB")
print(f" State: {self.agent.state_dir}")
print("\n🔍 OLLAMA STATUS:")
# Try to query Ollama status
try:
import requests
# Check if Ollama is running
response = requests.get(f"{self.agent.ollama_host}/api/tags", timeout=5)
if response.status_code == 200:
models = response.json().get('models', [])
print(f" Status: ✓ Running")
print(f" Loaded models: {len(models)}")
for model in models:
name = model.get('name', 'unknown')
size = model.get('size', 0) / (1024**3) # GB
is_current = "← ACTIVE" if name == self.agent.model else ""
print(f"{name} ({size:.1f} GB) {is_current}")
else:
print(f" Status: ❌ Error (HTTP {response.status_code})")
except Exception as e:
print(f" Status: ❌ Cannot connect: {e}")
print(f" Hint: Check 'systemctl status ollama.service'")
print("\n🛠️ TOOLS:")
print(f" Enabled: {self.agent.enable_tools}")
if self.agent.enable_tools:
print(f" Available tools: {len(self.agent.tools.get_tool_definitions())}")
print(f" Architecture: Centralized command_patterns.py")
print("\n💡 CONVERSATION:")
print(f" History: {len(self.conversation_history)} messages")
print(f" Session started: {self.session_start}")
print("=" * 70)
continue
# Process the message
print("\n🤖 MACHA: ", end='', flush=True)
response = self.process_message(user_input, verbose=False)
print(response)
except KeyboardInterrupt:
print("\n\n👋 Chat interrupted. Use /exit to quit properly.")
continue
except EOFError:
print("\n\n👋 Ending chat session. Goodbye!")
break
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
continue
def ask_once(self, question: str, verbose: bool = True) -> str:
"""Ask a single question and return the response (for macha-ask command)
Args:
question: The question to ask
verbose: Whether to show detailed context information
Returns:
Macha's response
"""
response = self.process_message(question, verbose=verbose)
return response
def explain_action(self, action_index: int) -> str:
"""Explain a pending action from the approval queue"""
# Get action from approval queue
approval_queue_file = self.agent.state_dir / "approval_queue.json"
if not approval_queue_file.exists():
return "Error: No approval queue found."
try:
with open(approval_queue_file, 'r') as f:
queue = json.load(f)
if not (0 <= action_index < len(queue)):
return f"Error: Action #{action_index} not found in approval queue (queue has {len(queue)} items)."
action_item = queue[action_index]
except Exception as e:
return f"Error reading approval queue: {e}"
action = action_item.get("action", {})
context = action_item.get("context", {})
timestamp = action_item.get("timestamp", "unknown")
# Build explanation prompt
prompt = f"""You are Macha, explaining a proposed system action to the user.
ACTION DETAILS:
- Proposed Action: {action.get('proposed_action', 'N/A')}
- Action Type: {action.get('action_type', 'N/A')}
- Risk Level: {action.get('risk_level', 'N/A')}
- Diagnosis: {action.get('diagnosis', 'N/A')}
- Commands to execute: {', '.join(action.get('commands', []))}
- Timestamp: {timestamp}
SYSTEM CONTEXT:
{json.dumps(context, indent=2)}
Please provide a clear, concise explanation of:
1. What problem was detected
2. What this action will do to fix it
3. Why this approach was chosen
4. Any potential risks or side effects
5. Expected outcome
Be conversational and helpful. Use plain language, not technical jargon unless necessary."""
try:
response = self.agent._query_ollama(prompt, temperature=0.7)
return response
except Exception as e:
return f"Error generating explanation: {e}"
def answer_action_followup(self, action_index: int, user_question: str) -> str:
"""Answer a follow-up question about a pending action"""
# Get action from approval queue
approval_queue_file = self.agent.state_dir / "approval_queue.json"
if not approval_queue_file.exists():
return "Error: No approval queue found."
try:
with open(approval_queue_file, 'r') as f:
queue = json.load(f)
if not (0 <= action_index < len(queue)):
return f"Error: Action #{action_index} not found."
action_item = queue[action_index]
except Exception as e:
return f"Error reading approval queue: {e}"
action = action_item.get("action", {})
context = action_item.get("context", {})
# Build follow-up prompt
prompt = f"""You are Macha, answering a follow-up question about a proposed action.
ACTION SUMMARY:
- Proposed: {action.get('proposed_action', 'N/A')}
- Type: {action.get('action_type', 'N/A')}
- Risk: {action.get('risk_level', 'N/A')}
- Diagnosis: {action.get('diagnosis', 'N/A')}
- Commands: {', '.join(action.get('commands', []))}
SYSTEM CONTEXT:
{json.dumps(context, indent=2)[:2000]}
USER'S QUESTION:
{user_question}
Please answer the user's question clearly and honestly. If you're uncertain about something, say so. Focus on helping them make an informed decision about whether to approve this action."""
try:
response = self.agent._query_ollama(prompt, temperature=0.7)
return response
except Exception as e:
return f"Error: {e}"
def main():
"""Main entry point for macha-chat and conversation.py"""
# Check for --discuss flag (used by macha-approve discuss)
if "--discuss" in sys.argv:
try:
discuss_index = sys.argv.index("--discuss")
if discuss_index + 1 >= len(sys.argv):
print("Error: --discuss requires an action number", file=sys.stderr)
sys.exit(1)
action_number = int(sys.argv[discuss_index + 1])
session = MachaChatSession()
# Check if this is a follow-up question or initial explanation
if "--follow-up" in sys.argv:
followup_index = sys.argv.index("--follow-up")
if followup_index + 1 >= len(sys.argv):
print("Error: --follow-up requires a question", file=sys.stderr)
sys.exit(1)
# Get the rest of the arguments as the question
question = " ".join(sys.argv[followup_index + 1:])
response = session.answer_action_followup(action_number, question)
print(response)
else:
# Initial explanation
explanation = session.explain_action(action_number)
print(explanation)
return
except (ValueError, IndexError) as e:
print(f"Error: Invalid action number: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Normal interactive chat mode
session = MachaChatSession()
session.run_interactive()
def ask_main():
"""Entry point for macha-ask"""
if len(sys.argv) < 2:
print("Usage: macha-ask <question>", file=sys.stderr)
sys.exit(1)
question = " ".join(sys.argv[1:])
session = MachaChatSession()
response = session.ask_once(question, verbose=True)
print("\n" + "=" * 60)
print("MACHA:")
print("=" * 60)
print(response)
print("=" * 60)
print()
if __name__ == "__main__":
main()