Unify chat.py and conversation.py into single implementation

CRITICAL FIX: chat.py had TWO execution paths causing inconsistent behavior:
1. Tool calling (correct) - used centralized command_patterns
2. Legacy JSON command parsing (broken) - bypassed SysadminTools

This caused macha-chat to fail SSH connections while macha-ask worked.

Changes:
- Rewrote chat.py to use ONLY tool-calling architecture
- All commands now go through SysadminTools.execute_command()
- SSH commands use centralized command_patterns.py
- conversation.py is now a lightweight wrapper for compatibility
- Both macha-chat and macha-ask use the same code path
- Updated module.nix to call chat.py directly

Benefits:
- Consistent behavior between macha-chat and macha-ask
- Single execution path = easier to maintain
- All SSH commands use explicit key paths
- No more password prompts

Fixes:
- SSH from macha-chat now works correctly
- Both interfaces use centralized command patterns
This commit is contained in:
Lily Miller
2025-10-06 16:19:57 -06:00
parent 2f367f7cdc
commit b9a498a3fd
3 changed files with 116 additions and 569 deletions

View File

@@ -1,328 +1,12 @@
#!/usr/bin/env python3
"""
Conversational Interface - Allows questioning Macha about decisions and system state
Macha conversation interface - legacy compatibility wrapper.
This module now uses the unified chat.py implementation.
"""
import json
import requests
from typing import Dict, List, Any, Optional
from pathlib import Path
from datetime import datetime
from agent import MachaAgent
class MachaConversation:
"""Conversational interface for Macha"""
def __init__(
self,
ollama_host: str = "http://localhost:11434",
model: str = "gpt-oss:latest",
state_dir: Path = Path("/var/lib/macha")
):
self.ollama_host = ollama_host
self.model = model
self.state_dir = state_dir
self.decision_log = self.state_dir / "decisions.jsonl"
self.approval_queue = self.state_dir / "approval_queue.json"
self.orchestrator_log = self.state_dir / "orchestrator.log"
# Initialize agent with tool support and queue
self.agent = MachaAgent(
ollama_host=ollama_host,
model=model,
state_dir=state_dir,
enable_tools=True,
use_queue=True,
priority="INTERACTIVE"
)
def ask(self, question: str, include_context: bool = True) -> str:
"""Ask Macha a question with optional system context"""
context = ""
if include_context:
context = self._gather_context()
# Build messages for tool-aware chat
content = self._create_conversational_prompt(question, context)
messages = [{"role": "user", "content": content}]
response = self.agent._query_ollama_with_tools(messages)
return response
def discuss_action(self, action_index: int) -> str:
"""Discuss a specific queued action by its queue position (0-based index)"""
action = self._get_action_from_queue(action_index)
if not action:
return f"No action found at queue position {action_index}. Use 'macha-approve list' to see available actions."
context = self._gather_context()
action_context = json.dumps(action, indent=2)
content = f"""TASK: DISCUSS PROPOSED ACTION
================================================================================
A user is asking about a proposed action in your approval queue.
QUEUED ACTION (Queue Position #{action_index}):
{action_context}
RECENT SYSTEM CONTEXT:
{context}
The user wants to discuss this action. Explain:
1. Why you proposed this action
2. What problem it solves
3. The risks involved
4. What could go wrong
5. Alternative approaches if any
Be conversational, helpful, and honest about uncertainties.
"""
messages = [{"role": "user", "content": content}]
return self.agent._query_ollama_with_tools(messages)
def _gather_context(self) -> str:
"""Gather relevant system context for the conversation"""
context_parts = []
# System infrastructure from ChromaDB
try:
from context_db import ContextDatabase
db = ContextDatabase()
systems = db.get_all_systems()
if systems:
context_parts.append("INFRASTRUCTURE:")
for system in systems:
context_parts.append(f" - {system['hostname']} ({system.get('type', 'unknown')})")
if system.get('config_repo'):
context_parts.append(f" Config Repo: {system['config_repo']}")
context_parts.append(f" Branch: {system.get('config_branch', 'unknown')}")
if system.get('capabilities'):
context_parts.append(f" Capabilities: {', '.join(system['capabilities'])}")
except Exception as e:
# ChromaDB not available, skip
pass
# Recent decisions
recent_decisions = self._get_recent_decisions(5)
if recent_decisions:
context_parts.append("\nRECENT DECISIONS:")
for i, dec in enumerate(recent_decisions, 1):
timestamp = dec.get("timestamp", "unknown")
analysis = dec.get("analysis", {})
status = analysis.get("status", "unknown")
context_parts.append(f"{i}. [{timestamp}] Status: {status}")
if "issues" in analysis:
for issue in analysis.get("issues", [])[:3]:
context_parts.append(f" - {issue.get('description', 'N/A')}")
# Pending approvals
pending = self._get_pending_approvals()
if pending:
context_parts.append(f"\nPENDING APPROVALS: {len(pending)} action(s) awaiting approval")
# Recent log excerpts (last 10 lines)
recent_logs = self._get_recent_logs(10)
if recent_logs:
context_parts.append("\nRECENT LOG ENTRIES:")
context_parts.extend(recent_logs)
return "\n".join(context_parts)
def _create_conversational_prompt(self, question: str, context: str) -> str:
"""Create a conversational prompt"""
return f"""{MachaAgent.SYSTEM_PROMPT}
TASK: ANSWER QUESTION
================================================================================
You monitor system health, analyze issues using AI, and propose fixes. Be helpful,
honest about what you know and don't know, and reference the context provided below.
SYSTEM CONTEXT:
{context if context else "No recent activity"}
USER QUESTION:
{question}
Respond conversationally and helpfully. If the question is about your recent decisions
or actions, reference the context above. If you don't have enough information, say so.
Keep responses concise but informative.
"""
def _query_ollama(self, prompt: str, temperature: float = 0.7) -> str:
"""Query Ollama API"""
try:
response = requests.post(
f"{self.ollama_host}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"stream": False,
"temperature": temperature,
},
timeout=60
)
response.raise_for_status()
return response.json().get("response", "")
except requests.exceptions.HTTPError as e:
error_detail = ""
try:
error_detail = f" - {response.text}"
except:
pass
return f"Error: Ollama returned HTTP {response.status_code}{error_detail}"
except Exception as e:
return f"Error querying Ollama: {str(e)}"
def _get_recent_decisions(self, count: int = 5) -> List[Dict[str, Any]]:
"""Get recent decisions from log"""
if not self.decision_log.exists():
return []
decisions = []
try:
with open(self.decision_log, 'r') as f:
for line in f:
if line.strip():
try:
decisions.append(json.loads(line))
except:
pass
except:
pass
return decisions[-count:]
def _get_pending_approvals(self) -> List[Dict[str, Any]]:
"""Get pending approvals from queue"""
if not self.approval_queue.exists():
return []
try:
with open(self.approval_queue, 'r') as f:
data = json.load(f)
# Queue is a JSON array, not an object with "pending" key
if isinstance(data, list):
return data
return data.get("pending", [])
except:
return []
def _get_action_from_queue(self, action_index: int) -> Optional[Dict[str, Any]]:
"""Get a specific action from the queue by index"""
pending = self._get_pending_approvals()
if 0 <= action_index < len(pending):
return pending[action_index]
return None
def _get_recent_logs(self, count: int = 10) -> List[str]:
"""Get recent orchestrator log lines"""
if not self.orchestrator_log.exists():
return []
try:
with open(self.orchestrator_log, 'r') as f:
lines = f.readlines()
return [line.strip() for line in lines[-count:] if line.strip()]
except:
return []
# Import the unified implementation
from chat import ask_main
# Entry point
if __name__ == "__main__":
import sys
import argparse
parser = argparse.ArgumentParser(description="Ask Macha a question or discuss an action")
parser.add_argument("--discuss", type=int, metavar="ACTION_ID", help="Discuss a specific queued action")
parser.add_argument("--follow-up", type=str, metavar="QUESTION", help="Follow-up question about the action")
parser.add_argument("question", nargs="*", help="Your question for Macha")
parser.add_argument("--no-context", action="store_true", help="Don't include system context")
args = parser.parse_args()
# Load config if available
config_file = Path("/etc/macha-autonomous/config.json")
ollama_host = "http://localhost:11434"
model = "gpt-oss:latest"
if config_file.exists():
try:
with open(config_file, 'r') as f:
config = json.load(f)
ollama_host = config.get("ollama_host", ollama_host)
model = config.get("model", model)
except:
pass
conversation = MachaConversation(
ollama_host=ollama_host,
model=model
)
if args.discuss is not None:
if args.follow_up:
# Follow-up question about a specific action
action = conversation._get_action_from_queue(args.discuss)
if not action:
print(f"No action found at queue position {args.discuss}. Use 'macha-approve list' to see available actions.")
sys.exit(1)
# Build context with the action details
action_context = f"""
QUEUED ACTION #{args.discuss}:
Diagnosis: {action.get('proposal', {}).get('diagnosis', 'N/A')}
Proposed Action: {action.get('proposal', {}).get('proposed_action', 'N/A')}
Action Type: {action.get('proposal', {}).get('action_type', 'N/A')}
Risk Level: {action.get('proposal', {}).get('risk_level', 'N/A')}
Commands: {json.dumps(action.get('proposal', {}).get('commands', []), indent=2)}
Reasoning: {action.get('proposal', {}).get('reasoning', 'N/A')}
FOLLOW-UP QUESTION:
{args.follow_up}
"""
# Query the AI with the action context
response = conversation._query_ollama(f"""{MachaAgent.SYSTEM_PROMPT}
TASK: ANSWER FOLLOW-UP QUESTION ABOUT QUEUED ACTION
================================================================================
You are answering a follow-up question about a proposed fix that is awaiting approval.
Be helpful and answer directly. If the user is concerned about risks, explain them clearly.
If they ask about alternatives, suggest them.
{action_context}
RESPOND CONCISELY AND DIRECTLY.
""")
else:
# Initial discussion about the action
response = conversation.discuss_action(args.discuss)
elif args.question:
# Ask a general question
question = " ".join(args.question)
response = conversation.ask(question, include_context=not args.no_context)
else:
parser.print_help()
sys.exit(1)
# Only print formatted output for initial discussion, not for follow-ups
if args.follow_up:
print(response)
else:
print("\n" + "="*60)
print("MACHA:")
print("="*60)
print(response)
print("="*60 + "\n")
ask_main()