feat: Add Gitea MCP integration and project cleanup
## New Features - **Gitea MCP Tools** (zero API cost): - gitea_read_file: Read files from homelab repo - gitea_list_files: Browse directories - gitea_search_code: Search by filename - gitea_get_tree: Get directory tree - **Gitea Client** (gitea_tools/client.py): REST API wrapper with OAuth - **Proxmox SSH Scripts** (scripts/): Homelab data collection utilities - **Obsidian MCP Support** (obsidian_mcp.py): Advanced vault operations - **Voice Integration Plan** (JARVIS_VOICE_INTEGRATION_PLAN.md) ## Improvements - **Increased timeout**: 5min → 10min for complex tasks (llm_interface.py) - **Removed Direct API fallback**: Gitea tools are MCP-only (zero cost) - **Updated .env.example**: Added Obsidian MCP configuration - **Enhanced .gitignore**: Protect personal memory files (SOUL.md, MEMORY.md) ## Cleanup - Deleted 24 obsolete files (temp/test/experimental scripts, outdated docs) - Untracked personal memory files (SOUL.md, MEMORY.md now in .gitignore) - Removed: AGENT_SDK_IMPLEMENTATION.md, HYBRID_SEARCH_SUMMARY.md, IMPLEMENTATION_SUMMARY.md, MIGRATION.md, test_agent_sdk.py, etc. ## Configuration - Added config/gitea_config.example.yaml (Gitea setup template) - Added config/obsidian_mcp.example.yaml (Obsidian MCP template) - Updated scheduled_tasks.yaml with new task examples Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
213
agent.py
213
agent.py
@@ -1,7 +1,8 @@
|
||||
"""AI Agent with Memory and LLM Integration."""
|
||||
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
import time
|
||||
from typing import List, Optional, Callable
|
||||
|
||||
from hooks import HooksSystem
|
||||
from llm_interface import LLMInterface
|
||||
@@ -35,6 +36,8 @@ class Agent:
|
||||
self.conversation_history: List[dict] = []
|
||||
self._chat_lock = threading.Lock()
|
||||
self.healing_system = SelfHealingSystem(self.memory, self)
|
||||
self._progress_callback: Optional[Callable[[str], None]] = None
|
||||
self._progress_timer: Optional[threading.Timer] = None
|
||||
|
||||
# Sub-agent orchestration
|
||||
self.is_sub_agent = is_sub_agent
|
||||
@@ -194,13 +197,26 @@ class Agent:
|
||||
|
||||
self.conversation_history = self.conversation_history[start_idx:]
|
||||
|
||||
def chat(self, user_message: str, username: str = "default") -> str:
|
||||
def chat(
|
||||
self,
|
||||
user_message: str,
|
||||
username: str = "default",
|
||||
progress_callback: Optional[Callable[[str], None]] = None
|
||||
) -> str:
|
||||
"""Chat with context from memory and tool use.
|
||||
|
||||
Thread-safe: uses a lock to prevent concurrent modification of
|
||||
conversation history from multiple threads (e.g., scheduled tasks
|
||||
and live messages).
|
||||
|
||||
Args:
|
||||
user_message: The user's message
|
||||
username: The user's name (default: "default")
|
||||
progress_callback: Optional callback for sending progress updates during long operations
|
||||
"""
|
||||
# Store the callback for use during the chat
|
||||
self._progress_callback = progress_callback
|
||||
|
||||
# Handle model switching commands (no lock needed, read-only on history)
|
||||
if user_message.lower().startswith("/model "):
|
||||
model_name = user_message[7:].strip()
|
||||
@@ -225,48 +241,160 @@ class Agent:
|
||||
)
|
||||
|
||||
with self._chat_lock:
|
||||
return self._chat_inner(user_message, username)
|
||||
try:
|
||||
return self._chat_inner(user_message, username)
|
||||
finally:
|
||||
# Clear the callback when done
|
||||
self._progress_callback = None
|
||||
|
||||
def _build_system_prompt(self, user_message: str, username: str) -> str:
|
||||
"""Build the system prompt with SOUL, user profile, and memory context."""
|
||||
if self.specialist_prompt:
|
||||
return (
|
||||
f"{self.specialist_prompt}\n\n"
|
||||
f"You have access to tools for file operations, command execution, "
|
||||
f"web fetching, note-taking, and Google services. "
|
||||
f"Use them to accomplish your specialized task efficiently."
|
||||
)
|
||||
|
||||
soul = self.memory.get_soul()
|
||||
user_profile = self.memory.get_user(username)
|
||||
relevant_memory = self.memory.search_hybrid(user_message, max_results=5)
|
||||
|
||||
memory_lines = [f"- {mem['snippet']}" for mem in relevant_memory]
|
||||
return (
|
||||
f"{soul}\n\nUser Profile:\n{user_profile}\n\n"
|
||||
f"Relevant Memory:\n" + "\n".join(memory_lines) +
|
||||
f"\n\nYou have access to tools for file operations, command execution, "
|
||||
f"web fetching, note-taking, and Google services (Gmail, Calendar, Contacts). "
|
||||
f"Use them freely to help the user."
|
||||
)
|
||||
|
||||
def _chat_inner(self, user_message: str, username: str) -> str:
|
||||
"""Inner chat logic, called while holding _chat_lock."""
|
||||
# Use specialist prompt if this is a sub-agent, otherwise use full context
|
||||
if self.specialist_prompt:
|
||||
# Sub-agent: Use focused specialist prompt
|
||||
system = (
|
||||
f"{self.specialist_prompt}\n\n"
|
||||
f"You have access to {len(TOOL_DEFINITIONS)} tools. Use them to accomplish your specialized task. "
|
||||
f"Stay focused on your specialty and complete the task efficiently."
|
||||
)
|
||||
else:
|
||||
# Main agent: Use full SOUL, user profile, and memory context
|
||||
soul = self.memory.get_soul()
|
||||
user_profile = self.memory.get_user(username)
|
||||
relevant_memory = self.memory.search_hybrid(user_message, max_results=5)
|
||||
|
||||
memory_lines = [f"- {mem['snippet']}" for mem in relevant_memory]
|
||||
system = (
|
||||
f"{soul}\n\nUser Profile:\n{user_profile}\n\n"
|
||||
f"Relevant Memory:\n" + "\n".join(memory_lines) +
|
||||
f"\n\nYou have access to {len(TOOL_DEFINITIONS)} tools for file operations, "
|
||||
f"command execution, and Google services. Use them freely to help the user. "
|
||||
f"Note: You're running on a flat-rate Agent SDK subscription, so don't worry "
|
||||
f"about API costs when making multiple tool calls or processing large contexts."
|
||||
)
|
||||
system = self._build_system_prompt(user_message, username)
|
||||
|
||||
self.conversation_history.append(
|
||||
{"role": "user", "content": user_message}
|
||||
)
|
||||
|
||||
# Prune history to prevent unbounded growth
|
||||
self._prune_conversation_history()
|
||||
|
||||
# Tool execution loop
|
||||
# In Agent SDK mode, query() handles tool calls automatically via MCP.
|
||||
# The tool loop is only needed for Direct API mode.
|
||||
if self.llm.mode == "agent_sdk":
|
||||
return self._chat_agent_sdk(user_message, system)
|
||||
else:
|
||||
return self._chat_direct_api(user_message, system)
|
||||
|
||||
def _send_progress_update(self, elapsed_seconds: int):
|
||||
"""Send a progress update if callback is set."""
|
||||
if self._progress_callback:
|
||||
messages = [
|
||||
f"⏳ Still working... ({elapsed_seconds}s elapsed)",
|
||||
f"🔄 Processing your request... ({elapsed_seconds}s)",
|
||||
f"⚙️ Working on it, this might take a moment... ({elapsed_seconds}s)",
|
||||
]
|
||||
# Rotate through messages
|
||||
message = messages[(elapsed_seconds // 90) % len(messages)]
|
||||
try:
|
||||
self._progress_callback(message)
|
||||
except Exception as e:
|
||||
print(f"[Agent] Failed to send progress update: {e}")
|
||||
|
||||
def _start_progress_updates(self):
|
||||
"""Start periodic progress updates (every 90 seconds)."""
|
||||
def send_update(elapsed: int):
|
||||
self._send_progress_update(elapsed)
|
||||
# Schedule next update
|
||||
self._progress_timer = threading.Timer(90.0, send_update, args=[elapsed + 90])
|
||||
self._progress_timer.daemon = True
|
||||
self._progress_timer.start()
|
||||
|
||||
# Send first update after 90 seconds
|
||||
self._progress_timer = threading.Timer(90.0, send_update, args=[90])
|
||||
self._progress_timer.daemon = True
|
||||
self._progress_timer.start()
|
||||
|
||||
def _stop_progress_updates(self):
|
||||
"""Stop progress updates."""
|
||||
if self._progress_timer:
|
||||
self._progress_timer.cancel()
|
||||
self._progress_timer = None
|
||||
|
||||
def _chat_agent_sdk(self, user_message: str, system: str) -> str:
|
||||
"""Chat using Agent SDK. Tools are handled automatically by MCP."""
|
||||
context_messages = self._get_context_messages(MAX_CONTEXT_MESSAGES)
|
||||
|
||||
# Start progress updates
|
||||
self._start_progress_updates()
|
||||
|
||||
try:
|
||||
# chat_with_tools() in Agent SDK mode returns a string directly.
|
||||
# The SDK handles all tool calls via MCP servers internally.
|
||||
response = self.llm.chat_with_tools(
|
||||
context_messages,
|
||||
tools=[], # Ignored in Agent SDK mode; tools come from MCP
|
||||
system=system,
|
||||
)
|
||||
except TimeoutError as e:
|
||||
error_msg = "⏱️ Task timed out after 5 minutes. The task might be too complex - try breaking it into smaller steps."
|
||||
print(f"[Agent] TIMEOUT: {error_msg}")
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_agent_sdk",
|
||||
intent="Calling Agent SDK for chat response (TIMEOUT)",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
"error_type": "timeout",
|
||||
},
|
||||
)
|
||||
return error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Agent SDK error: {e}"
|
||||
print(f"[Agent] {error_msg}")
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_agent_sdk",
|
||||
intent="Calling Agent SDK for chat response",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
},
|
||||
)
|
||||
return "Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
finally:
|
||||
# Always stop progress updates when done
|
||||
self._stop_progress_updates()
|
||||
|
||||
# In Agent SDK mode, response is always a string
|
||||
final_response = response if isinstance(response, str) else str(response)
|
||||
|
||||
if not final_response.strip():
|
||||
final_response = "(No response generated)"
|
||||
|
||||
self.conversation_history.append(
|
||||
{"role": "assistant", "content": final_response}
|
||||
)
|
||||
|
||||
# Write compact summary to memory
|
||||
compact_summary = self.memory.compact_conversation(
|
||||
user_message=user_message,
|
||||
assistant_response=final_response,
|
||||
tools_used=None # SDK handles tools internally; we don't track them here
|
||||
)
|
||||
self.memory.write_memory(compact_summary, daily=True)
|
||||
|
||||
return final_response
|
||||
|
||||
def _chat_direct_api(self, user_message: str, system: str) -> str:
|
||||
"""Chat using Direct API with manual tool execution loop."""
|
||||
max_iterations = MAX_TOOL_ITERATIONS
|
||||
# Enable caching for Sonnet to save 90% on repeated system prompts
|
||||
use_caching = "sonnet" in self.llm.model.lower()
|
||||
tools_used = []
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
# Get recent messages, ensuring we don't break tool_use/tool_result pairs
|
||||
context_messages = self._get_context_messages(MAX_CONTEXT_MESSAGES)
|
||||
|
||||
try:
|
||||
@@ -281,19 +409,17 @@ class Agent:
|
||||
print(f"[Agent] {error_msg}")
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_inner",
|
||||
intent="Calling LLM API for chat response",
|
||||
component="agent.py:_chat_direct_api",
|
||||
intent="Calling Direct API for chat response",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
"iteration": iteration,
|
||||
},
|
||||
)
|
||||
return f"Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
return "Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
|
||||
# Check stop reason
|
||||
if response.stop_reason == "end_turn":
|
||||
# Extract text response
|
||||
text_content = []
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
@@ -301,7 +427,6 @@ class Agent:
|
||||
|
||||
final_response = "\n".join(text_content)
|
||||
|
||||
# Handle empty response
|
||||
if not final_response.strip():
|
||||
final_response = "(No response generated)"
|
||||
|
||||
@@ -309,17 +434,16 @@ class Agent:
|
||||
{"role": "assistant", "content": final_response}
|
||||
)
|
||||
|
||||
preview = final_response[:MEMORY_RESPONSE_PREVIEW_LENGTH]
|
||||
self.memory.write_memory(
|
||||
f"**{username}**: {user_message}\n"
|
||||
f"**Garvis**: {preview}...",
|
||||
daily=True,
|
||||
compact_summary = self.memory.compact_conversation(
|
||||
user_message=user_message,
|
||||
assistant_response=final_response,
|
||||
tools_used=tools_used if tools_used else None
|
||||
)
|
||||
self.memory.write_memory(compact_summary, daily=True)
|
||||
|
||||
return final_response
|
||||
|
||||
elif response.stop_reason == "tool_use":
|
||||
# Build assistant message with tool uses
|
||||
assistant_content = []
|
||||
tool_uses = []
|
||||
|
||||
@@ -343,11 +467,11 @@ class Agent:
|
||||
"content": assistant_content
|
||||
})
|
||||
|
||||
# Execute tools and build tool result message
|
||||
tool_results = []
|
||||
for tool_use in tool_uses:
|
||||
if tool_use.name not in tools_used:
|
||||
tools_used.append(tool_use.name)
|
||||
result = execute_tool(tool_use.name, tool_use.input, healing_system=self.healing_system)
|
||||
# Truncate large tool outputs to prevent token explosion
|
||||
if len(result) > 5000:
|
||||
result = result[:5000] + "\n... (output truncated)"
|
||||
print(f"[Tool] {tool_use.name}: {result[:100]}...")
|
||||
@@ -363,7 +487,6 @@ class Agent:
|
||||
})
|
||||
|
||||
else:
|
||||
# Unexpected stop reason
|
||||
return f"Unexpected stop reason: {response.stop_reason}"
|
||||
|
||||
return "Error: Maximum tool use iterations exceeded"
|
||||
|
||||
Reference in New Issue
Block a user