Compare commits
4 Commits
ce2c384387
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fe7c146dc6 | |||
| 0271dea551 | |||
| 50cf7165cb | |||
| 911d362ba2 |
23
.env.example
23
.env.example
@@ -35,6 +35,29 @@ AJARBOT_SLACK_APP_TOKEN=xapp-your-app-token
|
|||||||
# Get token from: https://t.me/BotFather
|
# Get token from: https://t.me/BotFather
|
||||||
AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
|
AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Obsidian MCP Integration (Optional)
|
||||||
|
# ========================================
|
||||||
|
# Obsidian MCP server provides advanced vault operations via Obsidian REST API
|
||||||
|
# See: OBSIDIAN_MCP_INTEGRATION.md for setup instructions
|
||||||
|
|
||||||
|
# Enable/disable Obsidian MCP integration
|
||||||
|
OBSIDIAN_MCP_ENABLED=false
|
||||||
|
|
||||||
|
# Obsidian Local REST API Key
|
||||||
|
# Install "Local REST API" plugin in Obsidian first, then generate key in settings
|
||||||
|
OBSIDIAN_API_KEY=your-obsidian-api-key-here
|
||||||
|
|
||||||
|
# Obsidian REST API endpoint (default: http://127.0.0.1:27123)
|
||||||
|
OBSIDIAN_BASE_URL=http://127.0.0.1:27123
|
||||||
|
|
||||||
|
# Path to your main Obsidian vault (overrides config/obsidian_mcp.yaml)
|
||||||
|
# OBSIDIAN_VAULT_PATH=C:/Users/YourName/Documents/MyVault
|
||||||
|
|
||||||
|
# Tool routing strategy (optional, overrides config/obsidian_mcp.yaml)
|
||||||
|
# Options: obsidian_preferred, custom_preferred, obsidian_only
|
||||||
|
# OBSIDIAN_ROUTING_STRATEGY=obsidian_preferred
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Alternative LLM Providers (Optional)
|
# Alternative LLM Providers (Optional)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -42,6 +42,8 @@ Thumbs.db
|
|||||||
*.local.json
|
*.local.json
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
scripts/proxmox_ssh.sh # Contains Proxmox root password (legacy)
|
||||||
|
scripts/proxmox_ssh.py # Contains Proxmox root password (paramiko)
|
||||||
config/scheduled_tasks.yaml # Use scheduled_tasks.example.yaml instead
|
config/scheduled_tasks.yaml # Use scheduled_tasks.example.yaml instead
|
||||||
|
|
||||||
# Memory workspace (optional - remove if you want to version control)
|
# Memory workspace (optional - remove if you want to version control)
|
||||||
@@ -49,6 +51,9 @@ memory_workspace/memory/*.md
|
|||||||
memory_workspace/memory_index.db
|
memory_workspace/memory_index.db
|
||||||
memory_workspace/users/*.md # User profiles (jordan.md, etc.)
|
memory_workspace/users/*.md # User profiles (jordan.md, etc.)
|
||||||
memory_workspace/vectors.usearch
|
memory_workspace/vectors.usearch
|
||||||
|
memory_workspace/obsidian/ # Zettelkasten vault (personal notes)
|
||||||
|
memory_workspace/SOUL.md # Personal config (use SOUL.example.md)
|
||||||
|
memory_workspace/MEMORY.md # Personal memory (use MEMORY.example.md)
|
||||||
|
|
||||||
# User profiles (personal info)
|
# User profiles (personal info)
|
||||||
users/
|
users/
|
||||||
@@ -60,5 +65,12 @@ usage_data.json
|
|||||||
config/google_credentials.yaml
|
config/google_credentials.yaml
|
||||||
config/google_oauth_token.json
|
config/google_oauth_token.json
|
||||||
|
|
||||||
|
# Obsidian MCP config (contains vault path - use obsidian_mcp.example.yaml)
|
||||||
|
config/obsidian_mcp.yaml
|
||||||
|
|
||||||
|
# Gitea config (contains access token - use gitea_config.example.yaml)
|
||||||
|
config/gitea_config.yaml
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
# Claude Agent SDK Implementation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This implementation integrates the Claude Agent SDK as the **default backend** for the Ajarbot LLM interface, replacing the previous pay-per-token API model with Claude Pro subscription-based access.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Strategy: Thin Wrapper (Strategy A)
|
|
||||||
|
|
||||||
The Agent SDK is implemented as a **pure LLM backend replacement**:
|
|
||||||
- SDK handles only the LLM communication layer
|
|
||||||
- Existing `agent.py` tool execution loop remains unchanged
|
|
||||||
- All 17 tools (file operations, Gmail, Calendar, etc.) work identically
|
|
||||||
- Zero changes required to `agent.py`, `tools.py`, or adapters
|
|
||||||
|
|
||||||
### Three Modes of Operation
|
|
||||||
|
|
||||||
The system now supports three modes (in priority order):
|
|
||||||
|
|
||||||
1. **Agent SDK Mode** (DEFAULT)
|
|
||||||
- Uses Claude Pro subscription
|
|
||||||
- No API token costs
|
|
||||||
- Enabled by default when `claude-agent-sdk` is installed
|
|
||||||
- Set `USE_AGENT_SDK=true` (default)
|
|
||||||
|
|
||||||
2. **Direct API Mode**
|
|
||||||
- Pay-per-token using Anthropic API
|
|
||||||
- Requires `ANTHROPIC_API_KEY`
|
|
||||||
- Enable with `USE_DIRECT_API=true`
|
|
||||||
|
|
||||||
3. **Legacy Server Mode** (Deprecated)
|
|
||||||
- Uses local FastAPI server wrapper
|
|
||||||
- Enable with `USE_CLAUDE_CODE_SERVER=true`
|
|
||||||
|
|
||||||
## Key Implementation Details
|
|
||||||
|
|
||||||
### 1. Async/Sync Bridge
|
|
||||||
|
|
||||||
The Agent SDK is async-native, but the bot uses synchronous interfaces. We bridge them using `anyio.from_thread.run()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Synchronous chat_with_tools() calls async _agent_sdk_chat_with_tools()
|
|
||||||
response = anyio.from_thread.run(
|
|
||||||
self._agent_sdk_chat_with_tools,
|
|
||||||
messages,
|
|
||||||
tools,
|
|
||||||
system,
|
|
||||||
max_tokens
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Response Format Conversion
|
|
||||||
|
|
||||||
Agent SDK responses are converted to `anthropic.types.Message` format for compatibility:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _convert_sdk_response_to_message(self, sdk_response: Dict[str, Any]) -> Message:
|
|
||||||
"""Convert Agent SDK response to anthropic.types.Message format."""
|
|
||||||
# Extracts:
|
|
||||||
# - TextBlock for text content
|
|
||||||
# - ToolUseBlock for tool_use blocks
|
|
||||||
# - Usage information
|
|
||||||
# Returns MessageLike object compatible with agent.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Backward Compatibility
|
|
||||||
|
|
||||||
All existing environment variables work:
|
|
||||||
- `ANTHROPIC_API_KEY` - Still used for Direct API mode
|
|
||||||
- `USE_CLAUDE_CODE_SERVER` - Legacy mode still supported
|
|
||||||
- `CLAUDE_CODE_SERVER_URL` - Legacy server URL
|
|
||||||
|
|
||||||
New variables:
|
|
||||||
- `USE_AGENT_SDK=true` - Enable Agent SDK (default)
|
|
||||||
- `USE_DIRECT_API=true` - Force Direct API mode
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Step 1: Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd c:\Users\fam1n\projects\ajarbot
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
This installs:
|
|
||||||
- `claude-agent-sdk>=0.1.0` - Agent SDK
|
|
||||||
- `anyio>=4.0.0` - Async/sync bridging
|
|
||||||
|
|
||||||
### Step 2: Configure Mode (Optional)
|
|
||||||
|
|
||||||
Agent SDK is the default. To use a different mode:
|
|
||||||
|
|
||||||
**For Direct API (pay-per-token):**
|
|
||||||
```bash
|
|
||||||
# Add to .env
|
|
||||||
USE_DIRECT_API=true
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
**For Legacy Server:**
|
|
||||||
```bash
|
|
||||||
# Add to .env
|
|
||||||
USE_CLAUDE_CODE_SERVER=true
|
|
||||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Run the Bot
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see:
|
|
||||||
```
|
|
||||||
[LLM] Using Claude Agent SDK (Pro subscription)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### 1. `requirements.txt`
|
|
||||||
- Replaced `claude-code-sdk` with `claude-agent-sdk`
|
|
||||||
- Added `anyio>=4.0.0` for async bridging
|
|
||||||
- Removed FastAPI/Uvicorn (no longer needed for default mode)
|
|
||||||
|
|
||||||
### 2. `llm_interface.py`
|
|
||||||
Major refactoring:
|
|
||||||
- Added Agent SDK import and availability check
|
|
||||||
- New mode selection logic (agent_sdk > legacy_server > direct_api)
|
|
||||||
- `_agent_sdk_chat()` - Async method for simple chat
|
|
||||||
- `_agent_sdk_chat_with_tools()` - Async method for tool chat
|
|
||||||
- `_convert_sdk_response_to_message()` - Response format converter
|
|
||||||
- Updated `chat()` and `chat_with_tools()` with Agent SDK support
|
|
||||||
|
|
||||||
**Lines of code:**
|
|
||||||
- Before: ~250 lines
|
|
||||||
- After: ~410 lines
|
|
||||||
- Added: ~160 lines for Agent SDK support
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
### Basic Functionality
|
|
||||||
- [ ] Bot starts successfully with Agent SDK
|
|
||||||
- [ ] Simple chat works (`agent.chat("Hello", "user")`)
|
|
||||||
- [ ] Tool execution works (file operations, Gmail, Calendar)
|
|
||||||
- [ ] Multiple tool calls in sequence work
|
|
||||||
- [ ] Error handling works (invalid requests, SDK failures)
|
|
||||||
|
|
||||||
### Mode Switching
|
|
||||||
- [ ] Agent SDK mode works (default)
|
|
||||||
- [ ] Direct API mode works (`USE_DIRECT_API=true`)
|
|
||||||
- [ ] Legacy server mode works (`USE_CLAUDE_CODE_SERVER=true`)
|
|
||||||
- [ ] Fallback to Direct API when SDK unavailable
|
|
||||||
|
|
||||||
### Compatibility
|
|
||||||
- [ ] All 17 tools work identically
|
|
||||||
- [ ] Scheduled tasks work
|
|
||||||
- [ ] Telegram adapter works
|
|
||||||
- [ ] Slack adapter works
|
|
||||||
- [ ] Memory system works
|
|
||||||
- [ ] Self-healing system works
|
|
||||||
|
|
||||||
### Response Format
|
|
||||||
- [ ] `.content` attribute accessible
|
|
||||||
- [ ] `.stop_reason` attribute correct
|
|
||||||
- [ ] `.usage` attribute present
|
|
||||||
- [ ] TextBlock extraction works
|
|
||||||
- [ ] ToolUseBlock extraction works
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "Agent SDK not available, falling back to Direct API"
|
|
||||||
|
|
||||||
**Solution:** Install the SDK:
|
|
||||||
```bash
|
|
||||||
pip install claude-agent-sdk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: SDK import fails
|
|
||||||
|
|
||||||
**Check:**
|
|
||||||
1. Is `claude-agent-sdk` installed? (`pip list | grep claude-agent-sdk`)
|
|
||||||
2. Is virtual environment activated?
|
|
||||||
3. Are there any import errors in the SDK itself?
|
|
||||||
|
|
||||||
### Issue: Response format incompatible with agent.py
|
|
||||||
|
|
||||||
**Check:**
|
|
||||||
- `MessageLike` class has all required attributes (`.content`, `.stop_reason`, `.usage`)
|
|
||||||
- `TextBlock` and `ToolUseBlock` are properly constructed
|
|
||||||
- `sdk_response` structure matches expected format
|
|
||||||
|
|
||||||
### Issue: Async/sync bridge errors
|
|
||||||
|
|
||||||
**Check:**
|
|
||||||
- `anyio` is installed (`pip list | grep anyio`)
|
|
||||||
- Thread context is available (not running in async context already)
|
|
||||||
- No event loop conflicts
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Token Costs
|
|
||||||
- **Agent SDK**: $0 (uses Pro subscription)
|
|
||||||
- **Direct API**: ~$0.25-$1.25 per 1M tokens (Haiku), ~$3-$15 per 1M tokens (Sonnet)
|
|
||||||
|
|
||||||
### Speed
|
|
||||||
- **Agent SDK**: Similar to Direct API
|
|
||||||
- **Direct API**: Baseline
|
|
||||||
- **Legacy Server**: Additional HTTP overhead
|
|
||||||
|
|
||||||
### Memory
|
|
||||||
- **Agent SDK**: ~50MB overhead for SDK client
|
|
||||||
- **Direct API**: Minimal overhead
|
|
||||||
- **Legacy Server**: Requires separate server process
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Potential Improvements
|
|
||||||
1. **Streaming Support**: Implement streaming responses via SDK
|
|
||||||
2. **Better Error Messages**: More detailed SDK error propagation
|
|
||||||
3. **Usage Tracking**: Track SDK usage separately (if SDK provides metrics)
|
|
||||||
4. **Caching**: Implement prompt caching for Agent SDK (if supported)
|
|
||||||
5. **Batch Requests**: Support batch processing via SDK
|
|
||||||
|
|
||||||
### Migration Path
|
|
||||||
1. Phase 1: Agent SDK as default (DONE)
|
|
||||||
2. Phase 2: Remove legacy server code (after testing period)
|
|
||||||
3. Phase 3: Deprecate Direct API mode (after SDK proven stable)
|
|
||||||
4. Phase 4: SDK-only implementation
|
|
||||||
|
|
||||||
## Version History
|
|
||||||
|
|
||||||
### v1.0.0 (2026-02-15)
|
|
||||||
- Initial Agent SDK implementation
|
|
||||||
- Three-mode architecture (agent_sdk, direct_api, legacy_server)
|
|
||||||
- Async/sync bridge using anyio
|
|
||||||
- Response format converter
|
|
||||||
- Backward compatibility with existing env vars
|
|
||||||
- All 17 tools preserved
|
|
||||||
- Zero changes to agent.py, tools.py, adapters
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- **Agent SDK Docs**: (TBD - add when available)
|
|
||||||
- **Anthropic API Docs**: https://docs.anthropic.com/
|
|
||||||
- **anyio Docs**: https://anyio.readthedocs.io/
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
- **Implementation**: Strategy A (Thin Wrapper)
|
|
||||||
- **Planning**: Based on planning agent recommendations
|
|
||||||
- **Architecture**: Minimal disruption, maximum compatibility
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# Hybrid Search Implementation Summary
|
|
||||||
|
|
||||||
## What Was Implemented
|
|
||||||
|
|
||||||
Successfully upgraded Ajarbot's memory system from keyword-only search to **hybrid semantic + keyword search**.
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Stack
|
|
||||||
- **FastEmbed** (sentence-transformers/all-MiniLM-L6-v2) - 384-dimensional embeddings
|
|
||||||
- **usearch** - Fast vector similarity search
|
|
||||||
- **SQLite FTS5** - Keyword/BM25 search (retained)
|
|
||||||
|
|
||||||
### Scoring Algorithm
|
|
||||||
- **0.7 weight** - Vector similarity (semantic understanding)
|
|
||||||
- **0.3 weight** - BM25 score (keyword matching)
|
|
||||||
- Combined and normalized for optimal results
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- **Query time**: ~15ms average (was 5ms keyword-only)
|
|
||||||
- **Storage overhead**: +1.5KB per memory chunk
|
|
||||||
- **Cost**: $0 (runs locally, no API calls)
|
|
||||||
- **Embeddings generated**: 59 for existing memories
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
1. **memory_system.py**
|
|
||||||
- Added FastEmbed and usearch imports
|
|
||||||
- Initialize embedding model in `__init__` (line ~88)
|
|
||||||
- Added `_generate_embedding()` method
|
|
||||||
- Modified `index_file()` to generate and store embeddings
|
|
||||||
- Implemented `search_hybrid()` method
|
|
||||||
- Added database migration for `vector_id` column
|
|
||||||
- Save vector index on `close()`
|
|
||||||
|
|
||||||
2. **agent.py**
|
|
||||||
- Line 71: Changed `search()` to `search_hybrid()`
|
|
||||||
|
|
||||||
3. **memory_workspace/MEMORY.md**
|
|
||||||
- Updated Core Stack section
|
|
||||||
- Changed "Planned (Phase 2)" to "IMPLEMENTED"
|
|
||||||
- Added Recent Changes entry
|
|
||||||
- Updated Architecture Decisions
|
|
||||||
|
|
||||||
## Results - Before vs After
|
|
||||||
|
|
||||||
### Example Query: "How do I reduce costs?"
|
|
||||||
|
|
||||||
**Keyword Search (old)**:
|
|
||||||
```
|
|
||||||
No results found!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hybrid Search (new)**:
|
|
||||||
```
|
|
||||||
1. MEMORY.md:28 (score: 0.228)
|
|
||||||
## Cost Optimizations (2026-02-13)
|
|
||||||
Target: Minimize API costs...
|
|
||||||
|
|
||||||
2. SOUL.md:45 (score: 0.213)
|
|
||||||
Be proactive and use tools...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Query: "when was I born"
|
|
||||||
|
|
||||||
**Keyword Search (old)**:
|
|
||||||
```
|
|
||||||
No results found!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hybrid Search (new)**:
|
|
||||||
```
|
|
||||||
1. SOUL.md:1 (score: 0.071)
|
|
||||||
# SOUL - Agent Identity...
|
|
||||||
|
|
||||||
2. MEMORY.md:49 (score: 0.060)
|
|
||||||
## Search Evolution...
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works Automatically
|
|
||||||
|
|
||||||
The bot now automatically uses hybrid search on **every chat message**:
|
|
||||||
|
|
||||||
1. User sends message to bot
|
|
||||||
2. `agent.py` calls `memory.search_hybrid(user_message, max_results=2)`
|
|
||||||
3. System generates embedding for query (~10ms)
|
|
||||||
4. Searches vector index for semantic matches
|
|
||||||
5. Searches FTS5 for keyword matches
|
|
||||||
6. Combines scores (70% semantic, 30% keyword)
|
|
||||||
7. Returns top 2 results
|
|
||||||
8. Results injected into LLM context automatically
|
|
||||||
|
|
||||||
**No user action needed** - it's completely transparent!
|
|
||||||
|
|
||||||
## Dependencies Added
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install fastembed usearch
|
|
||||||
```
|
|
||||||
|
|
||||||
Installs:
|
|
||||||
- fastembed (0.7.4)
|
|
||||||
- usearch (2.23.0)
|
|
||||||
- numpy (2.4.2)
|
|
||||||
- onnxruntime (1.24.1)
|
|
||||||
- Plus supporting libraries
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
- `memory_workspace/vectors.usearch` - Vector index (~90KB for 59 vectors)
|
|
||||||
- `test_hybrid_search.py` - Test script
|
|
||||||
- `test_agent_hybrid.py` - Agent integration test
|
|
||||||
- `demo_hybrid_comparison.py` - Comparison demo
|
|
||||||
|
|
||||||
## Memory Impact
|
|
||||||
|
|
||||||
- **FastEmbed model**: ~50MB RAM (loaded once, persists)
|
|
||||||
- **Vector index**: ~1.5KB per memory chunk
|
|
||||||
- **59 memories**: ~90KB total vector storage
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **10x better semantic recall** - Finds memories by meaning, not just keywords
|
|
||||||
2. **Natural language queries** - "How do I save money?" finds cost optimization
|
|
||||||
3. **Zero cost** - No API calls, runs entirely locally
|
|
||||||
4. **Fast** - Sub-20ms queries
|
|
||||||
5. **Automatic** - Works transparently in all bot interactions
|
|
||||||
6. **Maintains keyword power** - Still finds exact technical terms
|
|
||||||
|
|
||||||
## Next Steps (Optional Future Enhancements)
|
|
||||||
|
|
||||||
- Add `search_user_hybrid()` for per-user semantic search
|
|
||||||
- Tune weights (currently 0.7/0.3) based on query patterns
|
|
||||||
- Add query expansion for better recall
|
|
||||||
- Pre-compute common query embeddings for speed
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Run comparison test:
|
|
||||||
```bash
|
|
||||||
python demo_hybrid_comparison.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Output shows keyword search finding 0 results, hybrid finding relevant matches for all queries.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Implementation Status**: ✅ COMPLETE
|
|
||||||
**Date**: 2026-02-13
|
|
||||||
**Lines of Code**: ~150 added to memory_system.py
|
|
||||||
**Breaking Changes**: None (backward compatible)
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
# Claude Agent SDK Implementation - Summary
|
|
||||||
|
|
||||||
## Status: ✅ COMPLETE
|
|
||||||
|
|
||||||
The Claude Agent SDK backend has been successfully implemented in `llm_interface.py` following the "Strategy A - Thin Wrapper" approach from the planning phase.
|
|
||||||
|
|
||||||
## Implementation Overview
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
|
|
||||||
1. **`llm_interface.py`** (408 lines, +160 lines)
|
|
||||||
- Added Agent SDK import and availability check
|
|
||||||
- Implemented three-mode architecture (agent_sdk, direct_api, legacy_server)
|
|
||||||
- Added async/sync bridge using `anyio.from_thread.run()`
|
|
||||||
- Implemented SDK response to Message format converter
|
|
||||||
- Preserved all existing functionality
|
|
||||||
|
|
||||||
2. **`requirements.txt`** (31 lines)
|
|
||||||
- Replaced `claude-code-sdk` with `claude-agent-sdk>=0.1.0`
|
|
||||||
- Added `anyio>=4.0.0` for async/sync bridging
|
|
||||||
- Removed FastAPI/Uvicorn (no longer needed for default mode)
|
|
||||||
|
|
||||||
### Files Created
|
|
||||||
|
|
||||||
3. **`AGENT_SDK_IMPLEMENTATION.md`** (Documentation)
|
|
||||||
- Architecture overview
|
|
||||||
- Installation instructions
|
|
||||||
- Testing checklist
|
|
||||||
- Troubleshooting guide
|
|
||||||
- Performance considerations
|
|
||||||
|
|
||||||
4. **`MIGRATION_GUIDE_AGENT_SDK.md`** (User guide)
|
|
||||||
- Step-by-step migration instructions
|
|
||||||
- Environment variable reference
|
|
||||||
- Troubleshooting common issues
|
|
||||||
- Rollback plan
|
|
||||||
- FAQ section
|
|
||||||
|
|
||||||
5. **`test_agent_sdk.py`** (Test suite)
|
|
||||||
- 5 comprehensive tests
|
|
||||||
- Mode selection verification
|
|
||||||
- Response format compatibility
|
|
||||||
- Simple chat and tool chat tests
|
|
||||||
- Initialization tests
|
|
||||||
|
|
||||||
6. **`IMPLEMENTATION_SUMMARY.md`** (This file)
|
|
||||||
- Quick reference summary
|
|
||||||
- Key features
|
|
||||||
- Verification steps
|
|
||||||
|
|
||||||
## Key Features Implemented
|
|
||||||
|
|
||||||
### ✅ Three-Mode Architecture
|
|
||||||
|
|
||||||
**Agent SDK Mode (DEFAULT)**
|
|
||||||
- Uses Claude Pro subscription (zero API costs)
|
|
||||||
- Automatic async/sync bridging
|
|
||||||
- Full tool support (all 17 tools)
|
|
||||||
- Enabled by default when SDK installed
|
|
||||||
|
|
||||||
**Direct API Mode**
|
|
||||||
- Pay-per-token using Anthropic API
|
|
||||||
- Usage tracking enabled
|
|
||||||
- Prompt caching support
|
|
||||||
- Fallback when SDK unavailable
|
|
||||||
|
|
||||||
**Legacy Server Mode (Deprecated)**
|
|
||||||
- Backward compatible with old setup
|
|
||||||
- Still functional but not recommended
|
|
||||||
|
|
||||||
### ✅ Async/Sync Bridge
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Synchronous interface calls async SDK methods
|
|
||||||
response = anyio.from_thread.run(
|
|
||||||
self._agent_sdk_chat_with_tools,
|
|
||||||
messages, tools, system, max_tokens
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Response Format Conversion
|
|
||||||
|
|
||||||
Converts Agent SDK responses to `anthropic.types.Message` format:
|
|
||||||
- TextBlock for text content
|
|
||||||
- ToolUseBlock for tool calls
|
|
||||||
- Usage information
|
|
||||||
- Full compatibility with agent.py
|
|
||||||
|
|
||||||
### ✅ Backward Compatibility
|
|
||||||
|
|
||||||
All existing features preserved:
|
|
||||||
- Environment variables (ANTHROPIC_API_KEY, etc.)
|
|
||||||
- Usage tracking (for Direct API mode)
|
|
||||||
- Model switching (/sonnet, /haiku commands)
|
|
||||||
- Prompt caching (for Direct API mode)
|
|
||||||
- All 17 tools (file ops, Gmail, Calendar, etc.)
|
|
||||||
- Scheduled tasks
|
|
||||||
- Memory system
|
|
||||||
- Self-healing system
|
|
||||||
- All adapters (Telegram, Slack, etc.)
|
|
||||||
|
|
||||||
### ✅ Zero Changes Required
|
|
||||||
|
|
||||||
No modifications needed to:
|
|
||||||
- `agent.py` - Tool execution loop unchanged
|
|
||||||
- `tools.py` - All 17 tools work identically
|
|
||||||
- `adapters/` - Telegram, Slack adapters unchanged
|
|
||||||
- `memory_system.py` - Memory system unchanged
|
|
||||||
- `self_healing.py` - Self-healing unchanged
|
|
||||||
- `scheduled_tasks.py` - Scheduler unchanged
|
|
||||||
|
|
||||||
## Mode Selection Logic
|
|
||||||
|
|
||||||
```
|
|
||||||
Priority Order:
|
|
||||||
1. USE_DIRECT_API=true → Direct API mode
|
|
||||||
2. USE_CLAUDE_CODE_SERVER=true → Legacy server mode
|
|
||||||
3. USE_AGENT_SDK=true (default) → Agent SDK mode
|
|
||||||
4. Agent SDK unavailable → Fallback to Direct API
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### New Variables
|
|
||||||
- `USE_AGENT_SDK=true` (default) - Enable Agent SDK
|
|
||||||
- `USE_DIRECT_API=true` - Force Direct API mode
|
|
||||||
|
|
||||||
### Preserved Variables
|
|
||||||
- `ANTHROPIC_API_KEY` - For Direct API mode
|
|
||||||
- `USE_CLAUDE_CODE_SERVER` - For legacy server mode
|
|
||||||
- `CLAUDE_CODE_SERVER_URL` - Legacy server URL
|
|
||||||
|
|
||||||
## Code Statistics
|
|
||||||
|
|
||||||
### Lines of Code Added
|
|
||||||
- `llm_interface.py`: ~160 lines
|
|
||||||
- `test_agent_sdk.py`: ~450 lines
|
|
||||||
- Documentation: ~800 lines
|
|
||||||
- **Total: ~1,410 lines**
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
- 5 automated tests
|
|
||||||
- All test scenarios pass
|
|
||||||
- Response format validated
|
|
||||||
- Mode selection verified
|
|
||||||
|
|
||||||
## Installation & Usage
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
```bash
|
|
||||||
# 1. Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# 2. Run the bot (Agent SDK is default)
|
|
||||||
python bot_runner.py
|
|
||||||
|
|
||||||
# Expected output:
|
|
||||||
# [LLM] Using Claude Agent SDK (Pro subscription)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Tests
|
|
||||||
```bash
|
|
||||||
python test_agent_sdk.py
|
|
||||||
|
|
||||||
# Expected: 5/5 tests pass
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification Checklist
|
|
||||||
|
|
||||||
### ✅ Implementation
|
|
||||||
- [x] Agent SDK backend class implemented
|
|
||||||
- [x] Async/sync bridge using anyio
|
|
||||||
- [x] Response format converter
|
|
||||||
- [x] Three-mode architecture
|
|
||||||
- [x] Backward compatibility maintained
|
|
||||||
- [x] Usage tracking preserved (for Direct API)
|
|
||||||
- [x] Error handling implemented
|
|
||||||
|
|
||||||
### ✅ Testing
|
|
||||||
- [x] Initialization test
|
|
||||||
- [x] Simple chat test
|
|
||||||
- [x] Chat with tools test
|
|
||||||
- [x] Response format test
|
|
||||||
- [x] Mode selection test
|
|
||||||
|
|
||||||
### ✅ Documentation
|
|
||||||
- [x] Implementation guide created
|
|
||||||
- [x] Migration guide created
|
|
||||||
- [x] Test suite created
|
|
||||||
- [x] Inline code comments
|
|
||||||
- [x] Summary document created
|
|
||||||
|
|
||||||
### ✅ Compatibility
|
|
||||||
- [x] agent.py unchanged
|
|
||||||
- [x] tools.py unchanged
|
|
||||||
- [x] adapters unchanged
|
|
||||||
- [x] All 17 tools work
|
|
||||||
- [x] Scheduled tasks work
|
|
||||||
- [x] Memory system works
|
|
||||||
- [x] Self-healing works
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
### Current Limitations
|
|
||||||
1. **No streaming support** - SDK responses are not streamed (future enhancement)
|
|
||||||
2. **No usage tracking for Agent SDK** - Only Direct API mode tracks usage
|
|
||||||
3. **No prompt caching for Agent SDK** - Only Direct API mode supports caching
|
|
||||||
4. **Mode changes require restart** - Cannot switch modes dynamically
|
|
||||||
|
|
||||||
### Future Enhancements
|
|
||||||
1. Implement streaming responses via SDK
|
|
||||||
2. Add SDK-specific usage metrics (if SDK provides them)
|
|
||||||
3. Implement dynamic mode switching
|
|
||||||
4. Add prompt caching support for Agent SDK
|
|
||||||
5. Optimize response format conversion
|
|
||||||
6. Add batch request support
|
|
||||||
|
|
||||||
## Performance Comparison
|
|
||||||
|
|
||||||
### Cost (per 1M tokens)
|
|
||||||
| Mode | Input | Output | Notes |
|
|
||||||
|------|-------|--------|-------|
|
|
||||||
| Agent SDK | $0 | $0 | Uses Pro subscription |
|
|
||||||
| Direct API (Haiku) | $0.25 | $1.25 | Pay-per-token |
|
|
||||||
| Direct API (Sonnet) | $3.00 | $15.00 | Pay-per-token |
|
|
||||||
|
|
||||||
### Speed
|
|
||||||
- **Agent SDK**: Similar to Direct API
|
|
||||||
- **Direct API**: Baseline
|
|
||||||
- **Legacy Server**: Slower (HTTP overhead)
|
|
||||||
|
|
||||||
### Memory
|
|
||||||
- **Agent SDK**: ~50MB overhead for SDK client
|
|
||||||
- **Direct API**: Minimal overhead
|
|
||||||
- **Legacy Server**: Requires separate process
|
|
||||||
|
|
||||||
## Migration Impact
|
|
||||||
|
|
||||||
### Zero Disruption
|
|
||||||
- Existing users can keep using Direct API mode
|
|
||||||
- Legacy server mode still works
|
|
||||||
- No breaking changes
|
|
||||||
- Smooth migration path
|
|
||||||
|
|
||||||
### Recommended Migration
|
|
||||||
1. Install new dependencies
|
|
||||||
2. Let bot default to Agent SDK mode
|
|
||||||
3. Verify all features work
|
|
||||||
4. Remove old server code (optional)
|
|
||||||
|
|
||||||
### Rollback Plan
|
|
||||||
If issues occur:
|
|
||||||
1. Set `USE_DIRECT_API=true` in `.env`
|
|
||||||
2. Restart bot
|
|
||||||
3. Report issues for investigation
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### ✅ All Met
|
|
||||||
- [x] Agent SDK is the default backend
|
|
||||||
- [x] API mode still works (not Agent SDK default)
|
|
||||||
- [x] Async/sync bridge functional
|
|
||||||
- [x] Response format compatible with agent.py
|
|
||||||
- [x] Backward compatibility with old env vars
|
|
||||||
- [x] All existing functionality preserved
|
|
||||||
- [x] Zero changes to agent.py, tools.py, adapters
|
|
||||||
- [x] Test suite passes
|
|
||||||
- [x] Documentation complete
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The Claude Agent SDK implementation is **complete and production-ready**. The implementation follows the "Strategy A - Thin Wrapper" approach, making the SDK a pure LLM backend replacement while preserving all existing functionality.
|
|
||||||
|
|
||||||
### Key Achievements
|
|
||||||
1. ✅ Agent SDK is the default mode
|
|
||||||
2. ✅ Zero breaking changes
|
|
||||||
3. ✅ All 17 tools work identically
|
|
||||||
4. ✅ Comprehensive testing and documentation
|
|
||||||
5. ✅ Smooth migration path with rollback option
|
|
||||||
|
|
||||||
### Next Steps
|
|
||||||
1. Test in production environment
|
|
||||||
2. Monitor for issues
|
|
||||||
3. Gather user feedback
|
|
||||||
4. Plan future enhancements (streaming, caching, etc.)
|
|
||||||
5. Consider deprecating legacy server mode
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Implementation Date**: 2026-02-15
|
|
||||||
**Strategy Used**: Strategy A - Thin Wrapper
|
|
||||||
**Files Modified**: 2 (llm_interface.py, requirements.txt)
|
|
||||||
**Files Created**: 4 (docs + tests)
|
|
||||||
**Total Lines Added**: ~1,410 lines
|
|
||||||
**Breaking Changes**: 0
|
|
||||||
**Tests Passing**: 5/5
|
|
||||||
**Status**: ✅ PRODUCTION READY
|
|
||||||
1304
JARVIS_VOICE_INTEGRATION_PLAN.md
Normal file
1304
JARVIS_VOICE_INTEGRATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
207
LOGGING.md
Normal file
207
LOGGING.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Structured Logging System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Ajarbot now includes a comprehensive structured logging system to track errors, tool executions, and system behavior.
|
||||||
|
|
||||||
|
## Log Files
|
||||||
|
|
||||||
|
All logs are stored in the `logs/` directory (gitignored):
|
||||||
|
|
||||||
|
### 1. `ajarbot.log` - Main Application Log
|
||||||
|
- **Format**: JSON (one record per line)
|
||||||
|
- **Level**: DEBUG and above
|
||||||
|
- **Size**: Rotates at 10MB, keeps 5 backups
|
||||||
|
- **Contents**: All application events, tool executions, LLM calls
|
||||||
|
|
||||||
|
### 2. `errors.log` - Error-Only Log
|
||||||
|
- **Format**: JSON
|
||||||
|
- **Level**: ERROR and CRITICAL only
|
||||||
|
- **Size**: Rotates at 5MB, keeps 3 backups
|
||||||
|
- **Contents**: Only errors and critical issues for quick diagnosis
|
||||||
|
|
||||||
|
### 3. `tools.log` - Tool Execution Log
|
||||||
|
- **Format**: JSON
|
||||||
|
- **Level**: INFO and above
|
||||||
|
- **Size**: Rotates at 10MB, keeps 3 backups
|
||||||
|
- **Contents**: Every tool call with inputs, outputs, duration, and success/failure
|
||||||
|
|
||||||
|
## Log Format
|
||||||
|
|
||||||
|
### JSON Structure
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-02-16T12:34:56.789Z",
|
||||||
|
"level": "ERROR",
|
||||||
|
"logger": "tools",
|
||||||
|
"message": "Tool failed: permanent_note",
|
||||||
|
"module": "tools",
|
||||||
|
"function": "execute_tool",
|
||||||
|
"line": 500,
|
||||||
|
"extra": {
|
||||||
|
"tool_name": "permanent_note",
|
||||||
|
"inputs": {"title": "Test", "content": "..."},
|
||||||
|
"success": false,
|
||||||
|
"error": "Unknown tool error",
|
||||||
|
"duration_ms": 123.45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Log Example
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-02-16T06:00:15.234Z",
|
||||||
|
"level": "INFO",
|
||||||
|
"logger": "tools",
|
||||||
|
"message": "Tool executed: get_weather",
|
||||||
|
"extra": {
|
||||||
|
"tool_name": "get_weather",
|
||||||
|
"inputs": {"location": "Centennial, CO"},
|
||||||
|
"success": true,
|
||||||
|
"result_length": 456,
|
||||||
|
"duration_ms": 1234.56
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Code
|
||||||
|
|
||||||
|
### Get a Logger
|
||||||
|
```python
|
||||||
|
from logging_config import get_logger, get_tool_logger
|
||||||
|
|
||||||
|
# General logger
|
||||||
|
logger = get_logger("my_module")
|
||||||
|
|
||||||
|
# Specialized tool logger
|
||||||
|
tool_logger = get_tool_logger()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging Methods
|
||||||
|
|
||||||
|
**Basic logging:**
|
||||||
|
```python
|
||||||
|
logger.debug("Detailed debug info", key="value")
|
||||||
|
logger.info("Informational message", user_id=123)
|
||||||
|
logger.warning("Warning message", issue="something")
|
||||||
|
logger.error("Error occurred", exc_info=True, error_code="E001")
|
||||||
|
logger.critical("Critical system failure", exc_info=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tool execution logging:**
|
||||||
|
```python
|
||||||
|
tool_logger.log_tool_call(
|
||||||
|
tool_name="permanent_note",
|
||||||
|
inputs={"title": "Test", "content": "..."},
|
||||||
|
success=True,
|
||||||
|
result="Created note successfully",
|
||||||
|
duration_ms=123.45
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analyzing Logs
|
||||||
|
|
||||||
|
### View Recent Errors
|
||||||
|
```bash
|
||||||
|
# Last 20 errors
|
||||||
|
tail -20 logs/errors.log | jq .
|
||||||
|
|
||||||
|
# Errors from specific module
|
||||||
|
grep '"module":"tools"' logs/errors.log | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Performance Analysis
|
||||||
|
```bash
|
||||||
|
# Average tool execution time
|
||||||
|
cat logs/tools.log | jq -r '.extra.duration_ms' | awk '{sum+=$1; count++} END {print sum/count}'
|
||||||
|
|
||||||
|
# Failed tools
|
||||||
|
grep '"success":false' logs/tools.log | jq -r '.extra.tool_name' | sort | uniq -c
|
||||||
|
|
||||||
|
# Slowest tool calls
|
||||||
|
cat logs/tools.log | jq -r '[.extra.tool_name, .extra.duration_ms] | @csv' | sort -t, -k2 -rn | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find Specific Errors
|
||||||
|
```bash
|
||||||
|
# Max token errors
|
||||||
|
grep -i "max.*token" logs/errors.log | jq .
|
||||||
|
|
||||||
|
# Tool iteration limits
|
||||||
|
grep -i "iteration.*exceeded" logs/ajarbot.log | jq .
|
||||||
|
|
||||||
|
# MCP tool failures
|
||||||
|
grep '"tool_name":"permanent_note"' logs/tools.log | grep '"success":false' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Patterns to Watch
|
||||||
|
|
||||||
|
1. **Max Tool Iterations** - Search: `"iteration.*exceeded"`
|
||||||
|
2. **Max Tokens** - Search: `"max.*token"`
|
||||||
|
3. **MCP Tool Failures** - Search: `"Unknown tool"` or failed MCP tool names
|
||||||
|
4. **Slow Tools** - Tools taking > 5000ms
|
||||||
|
5. **Repeated Failures** - Same tool failing multiple times
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Log Rotation
|
||||||
|
Logs automatically rotate when they reach size limits:
|
||||||
|
- `ajarbot.log`: 10MB → keeps 5 old files (50MB total)
|
||||||
|
- `errors.log`: 5MB → keeps 3 old files (15MB total)
|
||||||
|
- `tools.log`: 10MB → keeps 3 old files (30MB total)
|
||||||
|
|
||||||
|
Total max disk usage: ~95MB
|
||||||
|
|
||||||
|
### Manual Cleanup
|
||||||
|
```bash
|
||||||
|
# Remove old logs
|
||||||
|
rm logs/*.log.*
|
||||||
|
|
||||||
|
# Clear all logs (careful!)
|
||||||
|
rm logs/*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
### Automatic Integration
|
||||||
|
The logging system is automatically integrated into:
|
||||||
|
- ✅ `tools.py` - All tool executions logged
|
||||||
|
- ✅ Console output - Human-readable format
|
||||||
|
- ✅ File logs - JSON format for parsing
|
||||||
|
|
||||||
|
### Adding Logging to New Modules
|
||||||
|
```python
|
||||||
|
from logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
def my_function():
|
||||||
|
logger.info("Starting operation", operation_id=123)
|
||||||
|
try:
|
||||||
|
# Do work
|
||||||
|
logger.debug("Step completed", step=1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Operation failed", exc_info=True, operation_id=123)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Quick Error Diagnosis**: Separate `errors.log` for immediate issue identification
|
||||||
|
2. **Performance Tracking**: Tool execution times and success rates
|
||||||
|
3. **Historical Analysis**: JSON format enables programmatic analysis
|
||||||
|
4. **Debugging**: Full context with inputs, outputs, and stack traces
|
||||||
|
5. **Monitoring**: Easy to parse logs for alerting systems
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Web dashboard for log visualization
|
||||||
|
- [ ] Real-time log streaming via WebSocket
|
||||||
|
- [ ] Automatic error rate alerts (email/Telegram)
|
||||||
|
- [ ] Integration with external monitoring (Datadog, CloudWatch)
|
||||||
|
- [ ] Log aggregation for multi-instance deployments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2026-02-16
|
||||||
|
**Log System Version:** 1.0
|
||||||
152
MCP_MIGRATION.md
Normal file
152
MCP_MIGRATION.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# MCP Tools Migration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Successfully migrated file/system tools to MCP (Model Context Protocol) servers for better performance and integration with Claude Agent SDK.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### MCP Tools (In-Process - No API Costs)
|
||||||
|
**File**: `mcp_tools.py`
|
||||||
|
**Server**: `file_system` (v1.0.0)
|
||||||
|
|
||||||
|
These tools run directly in the Python process using the Claude Agent SDK:
|
||||||
|
- ✅ `read_file` - Read file contents
|
||||||
|
- ✅ `write_file` - Create/overwrite files
|
||||||
|
- ✅ `edit_file` - Replace text in files
|
||||||
|
- ✅ `list_directory` - List directory contents
|
||||||
|
- ✅ `run_command` - Execute shell commands
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Zero per-token API costs when using Agent SDK
|
||||||
|
- Better performance (no IPC overhead)
|
||||||
|
- Direct access to application state
|
||||||
|
- Simpler deployment (single process)
|
||||||
|
|
||||||
|
### Traditional Tools (API-Based - Consumes Tokens)
|
||||||
|
**File**: `tools.py`
|
||||||
|
|
||||||
|
These tools require external APIs and fall back to Direct API even in Agent SDK mode:
|
||||||
|
- 🌤️ `get_weather` - OpenWeatherMap API
|
||||||
|
- 📧 `send_email`, `read_emails`, `get_email` - Gmail API
|
||||||
|
- 📅 `read_calendar`, `create_calendar_event`, `search_calendar` - Google Calendar API
|
||||||
|
- 👤 `create_contact`, `list_contacts`, `get_contact` - Google People API
|
||||||
|
|
||||||
|
**Why not MCP?**: These tools need OAuth state, external API calls, and async HTTP clients that are better suited to the traditional tool execution model.
|
||||||
|
|
||||||
|
## Model Configuration
|
||||||
|
|
||||||
|
### Agent SDK Mode (DEFAULT)
|
||||||
|
```python
|
||||||
|
USE_AGENT_SDK=true # Default
|
||||||
|
```
|
||||||
|
|
||||||
|
**Model Configuration**:
|
||||||
|
- Default: **claude-sonnet-4-5-20250929** (all operations - chat, tools, coding)
|
||||||
|
- Optional: **claude-opus-4-6** (requires `USE_OPUS_FOR_TOOLS=true`, only for extremely intensive tasks)
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
- Regular chat: Uses Sonnet (flat-rate, no API costs)
|
||||||
|
- File operations: Uses Sonnet via MCP tools (flat-rate, no API costs)
|
||||||
|
- Google/Weather: Uses Sonnet via Direct API fallback (requires ANTHROPIC_API_KEY, consumes tokens)
|
||||||
|
- Intensive tasks: Optionally enable Opus with `USE_OPUS_FOR_TOOLS=true` (flat-rate, no extra cost)
|
||||||
|
|
||||||
|
**Cost Structure**:
|
||||||
|
- Chat + MCP tools: Flat-rate subscription (Pro plan)
|
||||||
|
- Traditional tools (Google/Weather): Pay-per-token at Sonnet rates (requires API key)
|
||||||
|
|
||||||
|
### Direct API Mode
|
||||||
|
```python
|
||||||
|
USE_DIRECT_API=true
|
||||||
|
Model: claude-sonnet-4-5-20250929 # Cost-effective (never uses Opus - too expensive)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
- All operations: Pay-per-token
|
||||||
|
- Requires: ANTHROPIC_API_KEY in .env
|
||||||
|
- All tools: Traditional execution (same token cost)
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### MCP Server Integration
|
||||||
|
|
||||||
|
**In `llm_interface.py`**:
|
||||||
|
```python
|
||||||
|
from mcp_tools import file_system_server
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"file_system": file_system_server},
|
||||||
|
allowed_tools=[
|
||||||
|
"read_file", "write_file", "edit_file",
|
||||||
|
"list_directory", "run_command"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await query(
|
||||||
|
messages=sdk_messages,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Definition Format
|
||||||
|
|
||||||
|
**MCP Tool Example**:
|
||||||
|
```python
|
||||||
|
@tool(
|
||||||
|
name="read_file",
|
||||||
|
description="Read the contents of a file.",
|
||||||
|
input_schema={"file_path": str},
|
||||||
|
)
|
||||||
|
async def read_file_tool(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": "..."}],
|
||||||
|
"isError": False # Optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Traditional Tool Example**:
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"name": "send_email",
|
||||||
|
"description": "Send an email from the bot's Gmail account.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"to": {"type": "string"}, ...},
|
||||||
|
"required": ["to", "subject", "body"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Potential MCP Candidates
|
||||||
|
- [ ] Weather tool (if we cache API responses in-process)
|
||||||
|
- [ ] Memory search tools (direct DB access)
|
||||||
|
- [ ] Configuration management tools
|
||||||
|
|
||||||
|
### Google Tools Migration (Optional)
|
||||||
|
To fully migrate Google tools to MCP, we would need to:
|
||||||
|
1. Embed OAuth manager in MCP server lifecycle
|
||||||
|
2. Handle async HTTP clients within MCP context
|
||||||
|
3. Manage token refresh in-process
|
||||||
|
|
||||||
|
**Recommendation**: Keep Google tools as traditional tools for now. The complexity of OAuth state management outweighs the token cost savings for infrequent API calls.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test MCP server creation
|
||||||
|
python -c "from mcp_tools import file_system_server; print(file_system_server)"
|
||||||
|
|
||||||
|
# Test Agent SDK with Opus
|
||||||
|
python -c "import os; os.environ['USE_AGENT_SDK']='true'; from llm_interface import LLMInterface; llm = LLMInterface(provider='claude'); print(f'Model: {llm.model}')"
|
||||||
|
|
||||||
|
# Expected: Model: claude-opus-4-6
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Claude Agent SDK Docs: https://github.com/anthropics/claude-agent-sdk
|
||||||
|
- MCP Protocol: https://modelcontextprotocol.io
|
||||||
|
- Tool Decorators: `claude_agent_sdk.tool`, `create_sdk_mcp_server`
|
||||||
325
MIGRATION.md
325
MIGRATION.md
@@ -1,325 +0,0 @@
|
|||||||
# Migration Guide: FastAPI Server to Agent SDK
|
|
||||||
|
|
||||||
This guide helps you upgrade from the old FastAPI server setup to the new Claude Agent SDK integration.
|
|
||||||
|
|
||||||
## What Changed?
|
|
||||||
|
|
||||||
### Old Architecture (Deprecated)
|
|
||||||
```
|
|
||||||
Bot → FastAPI Server (localhost:8000) → Claude Code SDK → Claude
|
|
||||||
```
|
|
||||||
- Required running `claude_code_server.py` in separate terminal
|
|
||||||
- Only worked with Pro subscription
|
|
||||||
- More complex setup with multiple processes
|
|
||||||
|
|
||||||
### New Architecture (Current)
|
|
||||||
```
|
|
||||||
Bot → Claude Agent SDK → Claude (Pro OR API)
|
|
||||||
```
|
|
||||||
- Single process (no separate server)
|
|
||||||
- Works with Pro subscription OR API key
|
|
||||||
- Simpler setup and operation
|
|
||||||
- Same functionality, less complexity
|
|
||||||
|
|
||||||
## Migration Steps
|
|
||||||
|
|
||||||
### Step 1: Update Dependencies
|
|
||||||
|
|
||||||
Pull latest code and reinstall dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
This installs `claude-agent-sdk` and removes deprecated dependencies.
|
|
||||||
|
|
||||||
### Step 2: Update Environment Configuration
|
|
||||||
|
|
||||||
Edit your `.env` file:
|
|
||||||
|
|
||||||
**Remove these deprecated variables:**
|
|
||||||
```bash
|
|
||||||
# DELETE THESE
|
|
||||||
USE_CLAUDE_CODE_SERVER=true
|
|
||||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Add new mode selection:**
|
|
||||||
```bash
|
|
||||||
# ADD THIS
|
|
||||||
AJARBOT_LLM_MODE=agent-sdk # Use Pro subscription (default)
|
|
||||||
# OR
|
|
||||||
AJARBOT_LLM_MODE=api # Use pay-per-token API
|
|
||||||
```
|
|
||||||
|
|
||||||
**If using API mode**, ensure you have:
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
**If using agent-sdk mode**, authenticate once:
|
|
||||||
```bash
|
|
||||||
claude auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Stop Old Server
|
|
||||||
|
|
||||||
The FastAPI server is no longer needed:
|
|
||||||
|
|
||||||
1. Stop any running `claude_code_server.py` processes
|
|
||||||
2. Remove it from startup scripts/systemd services
|
|
||||||
3. Optionally archive or delete `claude_code_server.py` (kept for reference)
|
|
||||||
|
|
||||||
### Step 4: Use New Launcher
|
|
||||||
|
|
||||||
**Old way:**
|
|
||||||
```bash
|
|
||||||
# Terminal 1
|
|
||||||
python claude_code_server.py
|
|
||||||
|
|
||||||
# Terminal 2
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**New way:**
|
|
||||||
```bash
|
|
||||||
# Single command
|
|
||||||
run.bat # Windows
|
|
||||||
python ajarbot.py # Linux/Mac
|
|
||||||
```
|
|
||||||
|
|
||||||
The new launcher:
|
|
||||||
- Runs pre-flight checks (Node.js, authentication, config)
|
|
||||||
- Sets sensible defaults (agent-sdk mode)
|
|
||||||
- Starts bot in single process
|
|
||||||
- No separate server needed
|
|
||||||
|
|
||||||
### Step 5: Test Your Setup
|
|
||||||
|
|
||||||
Run health check:
|
|
||||||
```bash
|
|
||||||
python ajarbot.py --health
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output (agent-sdk mode):
|
|
||||||
```
|
|
||||||
============================================================
|
|
||||||
Ajarbot Pre-Flight Checks
|
|
||||||
============================================================
|
|
||||||
|
|
||||||
✓ Python 3.10.x
|
|
||||||
✓ Node.js found: v18.x.x
|
|
||||||
✓ Claude CLI authenticated
|
|
||||||
|
|
||||||
[Configuration Checks]
|
|
||||||
✓ Config file found: config/adapters.local.yaml
|
|
||||||
|
|
||||||
Pre-flight checks complete!
|
|
||||||
============================================================
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Verify Functionality
|
|
||||||
|
|
||||||
Test that everything works:
|
|
||||||
|
|
||||||
1. **Start the bot:**
|
|
||||||
```bash
|
|
||||||
run.bat # or python ajarbot.py
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Send a test message** via Slack/Telegram
|
|
||||||
|
|
||||||
3. **Verify tools work:**
|
|
||||||
- Ask bot to read a file
|
|
||||||
- Request calendar events
|
|
||||||
- Test scheduled tasks
|
|
||||||
|
|
||||||
All features are preserved:
|
|
||||||
- 15 tools (file ops, Gmail, Calendar, Contacts)
|
|
||||||
- Memory system with hybrid search
|
|
||||||
- Multi-platform adapters
|
|
||||||
- Task scheduling
|
|
||||||
|
|
||||||
## Mode Comparison
|
|
||||||
|
|
||||||
Choose the mode that fits your use case:
|
|
||||||
|
|
||||||
| Feature | Agent SDK Mode | API Mode |
|
|
||||||
|---------|---------------|----------|
|
|
||||||
| **Cost** | $20/month (Pro) | ~$0.25-$3/M tokens |
|
|
||||||
| **Setup** | `claude auth login` | API key in `.env` |
|
|
||||||
| **Requirements** | Node.js + Claude CLI | Just Python |
|
|
||||||
| **Best For** | Personal heavy use | Light use, production |
|
|
||||||
| **Rate Limits** | Pro subscription limits | API rate limits |
|
|
||||||
|
|
||||||
### Switching Between Modes
|
|
||||||
|
|
||||||
You can switch anytime by editing `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Switch to agent-sdk
|
|
||||||
AJARBOT_LLM_MODE=agent-sdk
|
|
||||||
|
|
||||||
# Switch to API
|
|
||||||
AJARBOT_LLM_MODE=api
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
No code changes needed - just restart the bot.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Node.js not found" (Agent SDK mode)
|
|
||||||
|
|
||||||
**Option 1: Install Node.js**
|
|
||||||
```bash
|
|
||||||
# Download from https://nodejs.org
|
|
||||||
# Or via package manager:
|
|
||||||
winget install OpenJS.NodeJS # Windows
|
|
||||||
brew install node # Mac
|
|
||||||
sudo apt install nodejs # Ubuntu/Debian
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Switch to API mode**
|
|
||||||
```bash
|
|
||||||
# In .env
|
|
||||||
AJARBOT_LLM_MODE=api
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Claude CLI not authenticated"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check status
|
|
||||||
claude auth status
|
|
||||||
|
|
||||||
# Re-authenticate
|
|
||||||
claude auth logout
|
|
||||||
claude auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
If Claude CLI isn't installed, download from: https://claude.ai/download
|
|
||||||
|
|
||||||
### "Agent SDK not available"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install claude-agent-sdk
|
|
||||||
```
|
|
||||||
|
|
||||||
If installation fails, use API mode instead.
|
|
||||||
|
|
||||||
### Old Environment Variables Still Set
|
|
||||||
|
|
||||||
Check your `.env` file for deprecated variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# These should NOT be in your .env:
|
|
||||||
USE_CLAUDE_CODE_SERVER=true
|
|
||||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
|
||||||
USE_AGENT_SDK=true
|
|
||||||
USE_DIRECT_API=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Delete them and use `AJARBOT_LLM_MODE` instead.
|
|
||||||
|
|
||||||
### Bot Works But Features Missing
|
|
||||||
|
|
||||||
Ensure you have latest code:
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
pip install -r requirements.txt --upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
All features from the old setup are preserved:
|
|
||||||
- Tools system (15 tools)
|
|
||||||
- Memory with hybrid search
|
|
||||||
- Scheduled tasks
|
|
||||||
- Google integration
|
|
||||||
- Multi-platform adapters
|
|
||||||
|
|
||||||
### Performance Issues
|
|
||||||
|
|
||||||
**Agent SDK mode:**
|
|
||||||
- May hit Pro subscription rate limits
|
|
||||||
- Temporary solution: Switch to API mode
|
|
||||||
- Long-term: Wait for limit reset (usually 24 hours)
|
|
||||||
|
|
||||||
**API mode:**
|
|
||||||
- Check usage with: `python -c "from usage_tracker import UsageTracker; UsageTracker().print_summary()"`
|
|
||||||
- Costs shown in usage_data.json
|
|
||||||
- Default Haiku model is very cheap (~$0.04/day moderate use)
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
If you need to rollback to the old setup:
|
|
||||||
|
|
||||||
1. **Restore old .env settings:**
|
|
||||||
```bash
|
|
||||||
USE_CLAUDE_CODE_SERVER=true
|
|
||||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Start the old server:**
|
|
||||||
```bash
|
|
||||||
python claude_code_server.py
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Run bot with old method:**
|
|
||||||
```bash
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
However, the new setup is recommended - same functionality with less complexity.
|
|
||||||
|
|
||||||
## What's Backward Compatible?
|
|
||||||
|
|
||||||
All existing functionality is preserved:
|
|
||||||
|
|
||||||
- Configuration files (`config/adapters.local.yaml`, `config/scheduled_tasks.yaml`)
|
|
||||||
- Memory database (`memory_workspace/memory.db`)
|
|
||||||
- User profiles (`memory_workspace/users/`)
|
|
||||||
- Google OAuth tokens (`config/google_oauth_token.json`)
|
|
||||||
- Tool definitions and capabilities
|
|
||||||
- Adapter integrations
|
|
||||||
|
|
||||||
You can safely migrate without losing data or functionality.
|
|
||||||
|
|
||||||
## Benefits of New Setup
|
|
||||||
|
|
||||||
1. **Simpler operation**: Single command to start
|
|
||||||
2. **Flexible modes**: Choose Pro subscription OR API
|
|
||||||
3. **Automatic checks**: Pre-flight validation before starting
|
|
||||||
4. **Better errors**: Clear messages about missing requirements
|
|
||||||
5. **Less complexity**: No multi-process coordination
|
|
||||||
6. **Same features**: All 15 tools, adapters, scheduling preserved
|
|
||||||
|
|
||||||
## Need Help?
|
|
||||||
|
|
||||||
- Review [CLAUDE_CODE_SETUP.md](CLAUDE_CODE_SETUP.md) for detailed mode documentation
|
|
||||||
- Check [README.md](README.md) for quick start guides
|
|
||||||
- Run `python ajarbot.py --health` to diagnose issues
|
|
||||||
- Open an issue if you encounter problems
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```bash
|
|
||||||
# Terminal 1
|
|
||||||
python claude_code_server.py
|
|
||||||
|
|
||||||
# Terminal 2
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```bash
|
|
||||||
# .env
|
|
||||||
AJARBOT_LLM_MODE=agent-sdk # or "api"
|
|
||||||
|
|
||||||
# Single command
|
|
||||||
run.bat # Windows
|
|
||||||
python ajarbot.py # Linux/Mac
|
|
||||||
```
|
|
||||||
|
|
||||||
Same features, less complexity, more flexibility.
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
# Migration Guide: Agent SDK Implementation
|
|
||||||
|
|
||||||
## Quick Start (TL;DR)
|
|
||||||
|
|
||||||
### For New Users
|
|
||||||
```bash
|
|
||||||
# 1. Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# 2. Run the bot (Agent SDK is the default)
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### For Existing Users
|
|
||||||
```bash
|
|
||||||
# 1. Update dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# 2. That's it! The bot will automatically use Agent SDK
|
|
||||||
# Your existing .env settings are preserved
|
|
||||||
```
|
|
||||||
|
|
||||||
## Detailed Migration Steps
|
|
||||||
|
|
||||||
### Step 1: Understand Your Current Setup
|
|
||||||
|
|
||||||
Check your `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Scenario A: Using Direct API (Pay-per-token)**
|
|
||||||
```env
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
# No USE_CLAUDE_CODE_SERVER variable, or it's set to false
|
|
||||||
```
|
|
||||||
|
|
||||||
**Scenario B: Using Legacy Claude Code Server**
|
|
||||||
```env
|
|
||||||
USE_CLAUDE_CODE_SERVER=true
|
|
||||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Choose Your Migration Path
|
|
||||||
|
|
||||||
#### Option 1: Migrate to Agent SDK (Recommended)
|
|
||||||
|
|
||||||
**Benefits:**
|
|
||||||
- Uses Claude Pro subscription (no per-token costs)
|
|
||||||
- Same speed as Direct API
|
|
||||||
- No separate server process required
|
|
||||||
- All features work identically
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Install dependencies:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Update `.env` (optional - SDK is default):
|
|
||||||
```env
|
|
||||||
# Remove or comment out old settings
|
|
||||||
# USE_CLAUDE_CODE_SERVER=false
|
|
||||||
# CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# Agent SDK is enabled by default, but you can be explicit:
|
|
||||||
USE_AGENT_SDK=true
|
|
||||||
|
|
||||||
# Keep your API key for fallback (optional)
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run the bot:
|
|
||||||
```bash
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Verify Agent SDK is active:
|
|
||||||
```
|
|
||||||
[LLM] Using Claude Agent SDK (Pro subscription)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option 2: Keep Using Direct API
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- You don't have Claude Pro subscription
|
|
||||||
- You prefer pay-per-token billing
|
|
||||||
- You need to track exact API usage costs
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Install dependencies:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Update `.env`:
|
|
||||||
```env
|
|
||||||
USE_DIRECT_API=true
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run the bot:
|
|
||||||
```bash
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Verify Direct API is active:
|
|
||||||
```
|
|
||||||
[LLM] Using Direct API (pay-per-token)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option 3: Keep Using Legacy Server (Not Recommended)
|
|
||||||
|
|
||||||
**Only use if:**
|
|
||||||
- You have a custom modified `claude_code_server.py`
|
|
||||||
- You need the server for other tools/integrations
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Keep your current setup
|
|
||||||
2. The legacy server mode still works
|
|
||||||
3. No changes required
|
|
||||||
|
|
||||||
### Step 3: Test the Migration
|
|
||||||
|
|
||||||
Run the test suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python test_agent_sdk.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
```
|
|
||||||
=== Test 1: LLMInterface Initialization ===
|
|
||||||
✓ LLMInterface created successfully
|
|
||||||
- Mode: agent_sdk
|
|
||||||
...
|
|
||||||
Total: 5/5 tests passed
|
|
||||||
🎉 All tests passed!
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Verify Bot Functionality
|
|
||||||
|
|
||||||
Test all critical features:
|
|
||||||
|
|
||||||
1. **Simple Chat:**
|
|
||||||
```
|
|
||||||
User: Hello!
|
|
||||||
Bot: [Should respond normally]
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Tool Usage:**
|
|
||||||
```
|
|
||||||
User: What files are in the current directory?
|
|
||||||
Bot: [Should use list_directory tool]
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Gmail Integration:**
|
|
||||||
```
|
|
||||||
User: Check my recent emails
|
|
||||||
Bot: [Should use read_emails tool]
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Calendar Integration:**
|
|
||||||
```
|
|
||||||
User: What's on my calendar today?
|
|
||||||
Bot: [Should use read_calendar tool]
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Scheduled Tasks:**
|
|
||||||
- Verify scheduled tasks still run
|
|
||||||
- Check `config/scheduled_tasks.yaml`
|
|
||||||
|
|
||||||
## Environment Variables Reference
|
|
||||||
|
|
||||||
### New Variables
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `USE_AGENT_SDK` | `true` | Enable Agent SDK mode (default) |
|
|
||||||
| `USE_DIRECT_API` | `false` | Force Direct API mode |
|
|
||||||
|
|
||||||
### Existing Variables (Still Supported)
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `ANTHROPIC_API_KEY` | - | API key for Direct API mode |
|
|
||||||
| `USE_CLAUDE_CODE_SERVER` | `false` | Enable legacy server mode |
|
|
||||||
| `CLAUDE_CODE_SERVER_URL` | `http://localhost:8000` | Legacy server URL |
|
|
||||||
|
|
||||||
### Priority Order
|
|
||||||
|
|
||||||
If multiple modes are enabled, the priority is:
|
|
||||||
1. `USE_DIRECT_API=true` → Direct API mode
|
|
||||||
2. `USE_CLAUDE_CODE_SERVER=true` → Legacy server mode
|
|
||||||
3. `USE_AGENT_SDK=true` (default) → Agent SDK mode
|
|
||||||
4. Agent SDK unavailable → Fallback to Direct API mode
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "Agent SDK not available, falling back to Direct API"
|
|
||||||
|
|
||||||
**Cause:** `claude-agent-sdk` is not installed
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
pip install claude-agent-sdk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: "ModuleNotFoundError: No module named 'claude_agent_sdk'"
|
|
||||||
|
|
||||||
**Cause:** Package not in requirements.txt or not installed
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Verify requirements.txt has claude-agent-sdk
|
|
||||||
grep claude-agent-sdk requirements.txt
|
|
||||||
|
|
||||||
# If missing, update requirements.txt
|
|
||||||
pip install claude-agent-sdk anyio
|
|
||||||
|
|
||||||
# Or reinstall all dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Bot still using Direct API after migration
|
|
||||||
|
|
||||||
**Cause:** Explicit `USE_DIRECT_API=true` in `.env`
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Edit .env and remove or change to false
|
|
||||||
USE_DIRECT_API=false
|
|
||||||
|
|
||||||
# Or comment out the line
|
|
||||||
# USE_DIRECT_API=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: "anyio" import error
|
|
||||||
|
|
||||||
**Cause:** `anyio` package not installed (required for async/sync bridge)
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
pip install anyio>=4.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Response format errors in agent.py
|
|
||||||
|
|
||||||
**Cause:** SDK response not properly converted to Message format
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check `_convert_sdk_response_to_message()` implementation
|
|
||||||
2. Verify `TextBlock` and `ToolUseBlock` are imported
|
|
||||||
3. Run `python test_agent_sdk.py` to verify format compatibility
|
|
||||||
|
|
||||||
### Issue: Tool execution fails with Agent SDK
|
|
||||||
|
|
||||||
**Cause:** Agent SDK might not be returning expected tool format
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check `_agent_sdk_chat_with_tools()` method
|
|
||||||
2. Verify tool definitions are passed correctly
|
|
||||||
3. Add debug logging:
|
|
||||||
```python
|
|
||||||
print(f"SDK Response: {sdk_response}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
If you need to rollback to the old system:
|
|
||||||
|
|
||||||
### Rollback to Direct API
|
|
||||||
|
|
||||||
```env
|
|
||||||
# In .env
|
|
||||||
USE_DIRECT_API=true
|
|
||||||
USE_AGENT_SDK=false
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rollback to Legacy Server
|
|
||||||
|
|
||||||
```env
|
|
||||||
# In .env
|
|
||||||
USE_CLAUDE_CODE_SERVER=true
|
|
||||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# Start the server
|
|
||||||
python claude_code_server.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rollback Code (if needed)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Reinstall old dependencies (FastAPI/Uvicorn)
|
|
||||||
pip install fastapi>=0.109.0 uvicorn>=0.27.0
|
|
||||||
|
|
||||||
# Revert to old requirements.txt (backup needed)
|
|
||||||
git checkout HEAD~1 requirements.txt
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frequently Asked Questions
|
|
||||||
|
|
||||||
### Q: Will this increase my costs?
|
|
||||||
|
|
||||||
**A:** If you have Claude Pro, **costs will decrease to $0** for LLM calls. If you don't have Pro, you can keep using Direct API mode.
|
|
||||||
|
|
||||||
### Q: Will this break my existing bot setup?
|
|
||||||
|
|
||||||
**A:** No. All functionality is preserved:
|
|
||||||
- All 17 tools work identically
|
|
||||||
- Scheduled tasks unchanged
|
|
||||||
- Adapters (Telegram, Slack) unchanged
|
|
||||||
- Memory system unchanged
|
|
||||||
- Self-healing system unchanged
|
|
||||||
|
|
||||||
### Q: Can I switch modes dynamically?
|
|
||||||
|
|
||||||
**A:** Not currently. You need to set the mode in `.env` and restart the bot.
|
|
||||||
|
|
||||||
### Q: Will usage tracking still work?
|
|
||||||
|
|
||||||
**A:** Usage tracking is disabled for Agent SDK mode (no costs to track). It still works for Direct API mode.
|
|
||||||
|
|
||||||
### Q: What about prompt caching?
|
|
||||||
|
|
||||||
**A:** Prompt caching currently works only in Direct API mode. Agent SDK support may be added in the future.
|
|
||||||
|
|
||||||
### Q: Can I use different modes for different bot instances?
|
|
||||||
|
|
||||||
**A:** Yes! Each bot instance reads `.env` independently. You can run multiple bots with different modes.
|
|
||||||
|
|
||||||
## Migration Checklist
|
|
||||||
|
|
||||||
Use this checklist to ensure a smooth migration:
|
|
||||||
|
|
||||||
### Pre-Migration
|
|
||||||
- [ ] Backup `.env` file
|
|
||||||
- [ ] Backup `requirements.txt`
|
|
||||||
- [ ] Note current mode (Direct API or Legacy Server)
|
|
||||||
- [ ] Verify bot is working correctly
|
|
||||||
- [ ] Document any custom configurations
|
|
||||||
|
|
||||||
### Migration
|
|
||||||
- [ ] Update `requirements.txt` (or `git pull` latest)
|
|
||||||
- [ ] Install new dependencies (`pip install -r requirements.txt`)
|
|
||||||
- [ ] Update `.env` with new variables (if needed)
|
|
||||||
- [ ] Remove old variables (if migrating from legacy server)
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- [ ] Run `python test_agent_sdk.py`
|
|
||||||
- [ ] Test simple chat
|
|
||||||
- [ ] Test tool usage (file operations)
|
|
||||||
- [ ] Test Gmail integration (if using)
|
|
||||||
- [ ] Test Calendar integration (if using)
|
|
||||||
- [ ] Test scheduled tasks
|
|
||||||
- [ ] Test with Telegram adapter (if using)
|
|
||||||
- [ ] Test with Slack adapter (if using)
|
|
||||||
|
|
||||||
### Post-Migration
|
|
||||||
- [ ] Verify mode in startup logs (`[LLM] Using Claude Agent SDK...`)
|
|
||||||
- [ ] Monitor for errors in first 24 hours
|
|
||||||
- [ ] Verify scheduled tasks still run
|
|
||||||
- [ ] Check memory system working correctly
|
|
||||||
- [ ] Document any issues or edge cases
|
|
||||||
|
|
||||||
### Cleanup (Optional)
|
|
||||||
- [ ] Remove unused legacy server code (if not needed)
|
|
||||||
- [ ] Remove `USE_CLAUDE_CODE_SERVER` from `.env`
|
|
||||||
- [ ] Uninstall FastAPI/Uvicorn (if not used elsewhere)
|
|
||||||
- [ ] Update documentation with new setup
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
|
|
||||||
1. **Check logs:** Look for `[LLM]` and `[Agent]` prefixed messages
|
|
||||||
2. **Run tests:** `python test_agent_sdk.py`
|
|
||||||
3. **Check mode:** Verify startup message shows correct mode
|
|
||||||
4. **Verify dependencies:** `pip list | grep claude-agent-sdk`
|
|
||||||
5. **Check .env:** Ensure no conflicting variables
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
After successful migration:
|
|
||||||
|
|
||||||
1. **Monitor performance:** Compare speed and response quality
|
|
||||||
2. **Track savings:** Calculate cost savings vs Direct API
|
|
||||||
3. **Report issues:** Document any bugs or edge cases
|
|
||||||
4. **Optimize:** Look for opportunities to leverage SDK features
|
|
||||||
5. **Share feedback:** Help improve the implementation
|
|
||||||
|
|
||||||
## Version History
|
|
||||||
|
|
||||||
### v1.0.0 (2026-02-15)
|
|
||||||
- Initial Agent SDK implementation
|
|
||||||
- Three-mode architecture
|
|
||||||
- Backward compatibility maintained
|
|
||||||
- Zero changes to agent.py, tools.py, adapters
|
|
||||||
103
OBSIDIAN_MCP_SETUP_INSTRUCTIONS.md
Normal file
103
OBSIDIAN_MCP_SETUP_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Quick Setup: Obsidian Local REST API Plugin
|
||||||
|
|
||||||
|
## Your Current Status
|
||||||
|
- ✅ Obsidian is running
|
||||||
|
- ✅ Config file is ready (`config/obsidian_mcp.yaml`)
|
||||||
|
- ❌ Local REST API plugin not responding on port 27123
|
||||||
|
|
||||||
|
## Setup Steps
|
||||||
|
|
||||||
|
### 1. Install the Local REST API Plugin in Obsidian
|
||||||
|
|
||||||
|
1. Open **Obsidian**
|
||||||
|
2. Go to **Settings** (gear icon) → **Community Plugins**
|
||||||
|
3. If you see "Safe mode is on", click **Turn off Safe Mode**
|
||||||
|
4. Click **Browse** button
|
||||||
|
5. Search for: **"Local REST API"**
|
||||||
|
6. Click **Install** on the "Local REST API" plugin by coddingtonbear
|
||||||
|
7. After installation, click **Enable**
|
||||||
|
|
||||||
|
### 2. Configure the Plugin
|
||||||
|
|
||||||
|
1. In Obsidian Settings, scroll down to **Plugin Options**
|
||||||
|
2. Find **Local REST API** in the left sidebar
|
||||||
|
3. Copy your API key shown in the plugin settings
|
||||||
|
4. Compare it with the key in your `config/obsidian_mcp.yaml`:
|
||||||
|
```
|
||||||
|
api_key: "ee625f06a778e3267a9219f9b8c1065a039375ea270e414a34436c6a3027f2da"
|
||||||
|
```
|
||||||
|
5. If they don't match, update the config file with the correct key
|
||||||
|
|
||||||
|
### 3. Verify the Plugin is Running
|
||||||
|
|
||||||
|
1. Check that the plugin shows as **enabled** in Obsidian
|
||||||
|
2. The plugin should show: "Server running on http://127.0.0.1:27123"
|
||||||
|
3. Restart Obsidian if needed
|
||||||
|
|
||||||
|
### 4. Test the Connection
|
||||||
|
|
||||||
|
Run this command in your project directory:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -c "from obsidian_mcp import check_obsidian_health; print('Health Check:', check_obsidian_health(force=True))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected output**: `Health Check: True`
|
||||||
|
|
||||||
|
### 5. Restart the Bot
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
venv\Scripts\activate
|
||||||
|
python bot_runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for this line in the startup logs:
|
||||||
|
```
|
||||||
|
[LLM] Obsidian MCP server registered (8 tools)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see this instead, the plugin isn't working yet:
|
||||||
|
```
|
||||||
|
[LLM] Obsidian MCP enabled but health check failed - using custom tools only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative: File-Based Access (Already Working)
|
||||||
|
|
||||||
|
If you don't want to use the Local REST API plugin, your bot can **already** access your Obsidian vault via the filesystem using these tools:
|
||||||
|
|
||||||
|
- `fleeting_note` - Quick capture with auto-ID
|
||||||
|
- `daily_note` - Timestamped journal entries
|
||||||
|
- `literature_note` - Save web articles
|
||||||
|
- `permanent_note` - Create refined notes with auto-linking
|
||||||
|
- `search_vault` - Hybrid semantic search
|
||||||
|
- `search_by_tags` - Find notes by tags
|
||||||
|
- `read_file` / `write_file` / `edit_file` - Direct file access
|
||||||
|
|
||||||
|
The **Obsidian MCP tools** add these extra capabilities:
|
||||||
|
- `obsidian_update_note` - Frontmatter-aware editing
|
||||||
|
- `obsidian_global_search` - Native Obsidian search
|
||||||
|
- `obsidian_manage_frontmatter` - Advanced metadata management
|
||||||
|
- `obsidian_manage_tags` - Bulk tag operations
|
||||||
|
- `obsidian_delete_note` - Safe deletion
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Plugin shows "Server not running"
|
||||||
|
- Click the **Restart Server** button in the plugin settings
|
||||||
|
- Check Windows Firewall isn't blocking port 27123
|
||||||
|
|
||||||
|
### API key mismatch
|
||||||
|
- Copy the EXACT key from Obsidian plugin settings
|
||||||
|
- Update `config/obsidian_mcp.yaml` → `connection.api_key`
|
||||||
|
|
||||||
|
### Wrong vault path
|
||||||
|
- Your current vault path: `C:/Users/fam1n/OneDrive/Documents/Remote-Mind-Vault`
|
||||||
|
- Verify this path exists and contains a `.obsidian` folder
|
||||||
|
|
||||||
|
### Health check still fails after setup
|
||||||
|
- Restart Obsidian
|
||||||
|
- Restart the bot
|
||||||
|
- Check port 27123 isn't used by another program:
|
||||||
|
```powershell
|
||||||
|
netstat -ano | findstr :27123
|
||||||
|
```
|
||||||
205
SUB_AGENTS.md
Normal file
205
SUB_AGENTS.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Sub-Agent Orchestration System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Ajarbot now supports **dynamic sub-agent spawning** - the ability to create specialized agents on-demand for complex tasks. The main agent can delegate work to specialists with focused system prompts, reducing context window bloat and improving task efficiency.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Main Agent (Garvis)
|
||||||
|
├─> Handles general chat, memory, scheduling
|
||||||
|
├─> Can spawn sub-agents dynamically
|
||||||
|
└─> Sub-agents share tools and (optionally) memory
|
||||||
|
|
||||||
|
Sub-Agent (Specialist)
|
||||||
|
├─> Focused system prompt (no SOUL, user profile overhead)
|
||||||
|
├─> Own conversation history (isolated context)
|
||||||
|
├─> Can use all 24 tools
|
||||||
|
└─> Returns result to main agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Dynamic spawning**: Create specialists at runtime, no hardcoded definitions
|
||||||
|
- **Caching**: Reuse specialists across multiple calls (agent_id parameter)
|
||||||
|
- **Memory sharing**: Sub-agents can share memory workspace with main agent
|
||||||
|
- **Tool access**: All tools available to sub-agents (file, web, zettelkasten, Google)
|
||||||
|
- **Isolation**: Each sub-agent has separate conversation history
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Method 1: Manual Spawning
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Spawn a specialist
|
||||||
|
specialist = agent.spawn_sub_agent(
|
||||||
|
specialist_prompt="You are a zettelkasten expert. Focus ONLY on note organization.",
|
||||||
|
agent_id="zettelkasten_processor" # Optional: cache for reuse
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the specialist
|
||||||
|
result = specialist.chat("Process my fleeting notes", username="jordan")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Delegation (Recommended)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# One-off delegation (specialist not cached)
|
||||||
|
result = agent.delegate(
|
||||||
|
task="Analyze my emails and extract action items",
|
||||||
|
specialist_prompt="You are an email analyst. Extract action items and deadlines.",
|
||||||
|
username="jordan"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cached delegation (specialist reused)
|
||||||
|
result = agent.delegate(
|
||||||
|
task="Create permanent notes from my fleeting notes",
|
||||||
|
specialist_prompt="You are a zettelkasten specialist. Focus on note linking.",
|
||||||
|
username="jordan",
|
||||||
|
agent_id="zettelkasten_processor" # Cached for future use
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 3: LLM-Driven Orchestration (Future)
|
||||||
|
|
||||||
|
The main agent can analyze requests and decide when to delegate:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _should_delegate(self, user_message: str) -> Optional[str]:
|
||||||
|
"""Let LLM decide if delegation is needed."""
|
||||||
|
# Ask LLM: "Should this be delegated? If yes, generate specialist prompt"
|
||||||
|
# Return specialist_prompt if delegation needed, None otherwise
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Complex Zettelkasten Operations
|
||||||
|
```python
|
||||||
|
# Main agent detects: "This requires deep note processing"
|
||||||
|
specialist = agent.spawn_sub_agent(
|
||||||
|
specialist_prompt="""You are a zettelkasten expert. Your ONLY job is:
|
||||||
|
- Process fleeting notes into permanent notes
|
||||||
|
- Find semantic connections using hybrid search
|
||||||
|
- Create wiki-style links between related concepts
|
||||||
|
Stay focused on knowledge management.""",
|
||||||
|
agent_id="zettelkasten_processor"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Intelligence
|
||||||
|
```python
|
||||||
|
specialist = agent.spawn_sub_agent(
|
||||||
|
specialist_prompt="""You are an email analyst. Your ONLY job is:
|
||||||
|
- Summarize email threads
|
||||||
|
- Extract action items and deadlines
|
||||||
|
- Identify patterns in communication
|
||||||
|
Stay focused on email analysis.""",
|
||||||
|
agent_id="email_analyst"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calendar Optimization
|
||||||
|
```python
|
||||||
|
specialist = agent.spawn_sub_agent(
|
||||||
|
specialist_prompt="""You are a calendar optimization expert. Your ONLY job is:
|
||||||
|
- Find scheduling conflicts
|
||||||
|
- Suggest optimal meeting times
|
||||||
|
- Identify time-blocking opportunities
|
||||||
|
Stay focused on schedule management.""",
|
||||||
|
agent_id="calendar_optimizer"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Reduced Context Window**: Specialists don't load SOUL.md, user profiles, or irrelevant memory
|
||||||
|
2. **Focused Performance**: Specialists stay on-task without distractions
|
||||||
|
3. **Token Efficiency**: Smaller system prompts = lower token usage
|
||||||
|
4. **Parallel Execution**: Can spawn multiple specialists simultaneously (future)
|
||||||
|
5. **Learning Over Time**: Main agent learns when to delegate based on patterns
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No configuration needed! The infrastructure is ready to use. You can:
|
||||||
|
|
||||||
|
1. **Add specialists later**: Define common specialists in a config file
|
||||||
|
2. **LLM-driven delegation**: Let the main agent decide when to delegate
|
||||||
|
3. **Parallel execution**: Spawn multiple specialists for complex workflows
|
||||||
|
4. **Custom workspaces**: Give specialists isolated memory (set `share_memory=False`)
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Code Location
|
||||||
|
- **agent.py**: Lines 25-90 (sub-agent infrastructure)
|
||||||
|
- `spawn_sub_agent()`: Create specialist with custom prompt
|
||||||
|
- `delegate()`: Convenience method for one-off delegation
|
||||||
|
- `is_sub_agent`, `specialist_prompt`: Instance variables
|
||||||
|
- `sub_agents`: Cache dictionary
|
||||||
|
|
||||||
|
### Thread Safety
|
||||||
|
- Sub-agents have their own `_chat_lock`
|
||||||
|
- Safe to spawn from multiple threads
|
||||||
|
- Cached specialists are reused (no duplicate spawning)
|
||||||
|
|
||||||
|
### Memory Sharing
|
||||||
|
- Default: Sub-agents share main memory workspace
|
||||||
|
- Optional: Isolated workspace at `memory_workspace/sub_agents/{agent_id}/`
|
||||||
|
- Shared memory = specialists can access/update zettelkasten vault
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Specialist Registry**: Define common specialists in `config/specialists.yaml`
|
||||||
|
2. **Auto-Delegation**: Main agent auto-detects when to delegate
|
||||||
|
3. **Parallel Execution**: Run multiple specialists concurrently
|
||||||
|
4. **Result Synthesis**: Main agent combines outputs from multiple specialists
|
||||||
|
5. **Learning System**: Track which specialists work best for which tasks
|
||||||
|
|
||||||
|
## Example Workflows
|
||||||
|
|
||||||
|
### Workflow 1: Zettelkasten Processing with Delegation
|
||||||
|
```python
|
||||||
|
# User: "Process my fleeting notes about AI and machine learning"
|
||||||
|
# Main agent detects: complex zettelkasten task
|
||||||
|
|
||||||
|
result = agent.delegate(
|
||||||
|
task="Find all fleeting notes tagged 'AI' or 'machine-learning', process into permanent notes, and discover connections",
|
||||||
|
specialist_prompt="You are a zettelkasten expert. Use hybrid search to find semantic connections. Create permanent notes with smart links.",
|
||||||
|
username="jordan",
|
||||||
|
agent_id="zettelkasten_processor"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Specialist:
|
||||||
|
# 1. search_by_tags(tags=["AI", "machine-learning", "fleeting"])
|
||||||
|
# 2. For each note: permanent_note() with auto-linking
|
||||||
|
# 3. Returns: "Created 5 permanent notes with 18 discovered connections"
|
||||||
|
|
||||||
|
# Main agent synthesizes:
|
||||||
|
# "Sir, I've processed your AI and ML notes. Five concepts emerged with particularly
|
||||||
|
# interesting connections to your existing work on neural architecture..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow 2: Email + Calendar Coordination
|
||||||
|
```python
|
||||||
|
# User: "Find meetings next week and check if I have email threads about them"
|
||||||
|
|
||||||
|
# Spawn two specialists in parallel (future feature)
|
||||||
|
email_result = agent.delegate(
|
||||||
|
task="Search emails for threads about meetings",
|
||||||
|
specialist_prompt="Email analyst. Extract meeting context.",
|
||||||
|
agent_id="email_analyst"
|
||||||
|
)
|
||||||
|
|
||||||
|
calendar_result = agent.delegate(
|
||||||
|
task="List all meetings next week",
|
||||||
|
specialist_prompt="Calendar expert. Get meeting details.",
|
||||||
|
agent_id="calendar_optimizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Main agent synthesizes both results
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: Infrastructure complete, ready to use. Add specialists as patterns emerge!
|
||||||
@@ -139,11 +139,37 @@ class AdapterRuntime:
|
|||||||
if adapter:
|
if adapter:
|
||||||
await adapter.send_typing_indicator(message.channel_id)
|
await adapter.send_typing_indicator(message.channel_id)
|
||||||
|
|
||||||
|
# Capture the event loop for thread-safe progress updates
|
||||||
|
event_loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# Create progress callback to send updates to the user
|
||||||
|
def progress_callback(update_message: str):
|
||||||
|
"""Send progress updates to the user during long operations."""
|
||||||
|
if adapter:
|
||||||
|
try:
|
||||||
|
# Create outbound message for progress update
|
||||||
|
progress_msg = OutboundMessage(
|
||||||
|
platform=message.platform,
|
||||||
|
channel_id=message.channel_id,
|
||||||
|
text=update_message,
|
||||||
|
thread_id=message.thread_id,
|
||||||
|
)
|
||||||
|
# Run async send in a thread-safe way
|
||||||
|
# Use the captured event loop instead of get_running_loop()
|
||||||
|
# since this callback runs from a thread (agent.chat via to_thread)
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
adapter.send_message(progress_msg),
|
||||||
|
event_loop
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Runtime] Failed to send progress update: {e}")
|
||||||
|
|
||||||
# Get response from agent (synchronous call in thread)
|
# Get response from agent (synchronous call in thread)
|
||||||
response = await asyncio.to_thread(
|
response = await asyncio.to_thread(
|
||||||
self.agent.chat,
|
self.agent.chat,
|
||||||
user_message=processed_message.text,
|
user_message=processed_message.text,
|
||||||
username=username,
|
username=username,
|
||||||
|
progress_callback=progress_callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply postprocessors
|
# Apply postprocessors
|
||||||
@@ -217,6 +243,14 @@ class AdapterRuntime:
|
|||||||
print("[Runtime] Starting adapter runtime...")
|
print("[Runtime] Starting adapter runtime...")
|
||||||
await self.registry.start_all()
|
await self.registry.start_all()
|
||||||
|
|
||||||
|
# Pass the main event loop to the LLM interface so that Agent SDK
|
||||||
|
# async calls (from worker threads created by asyncio.to_thread)
|
||||||
|
# can be scheduled back onto this loop via run_coroutine_threadsafe.
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
if hasattr(self.agent, 'llm') and hasattr(self.agent.llm, 'set_event_loop'):
|
||||||
|
self.agent.llm.set_event_loop(loop)
|
||||||
|
print("[Runtime] Event loop reference passed to LLM interface")
|
||||||
|
|
||||||
self._is_running = True
|
self._is_running = True
|
||||||
self.message_loop_task = asyncio.create_task(
|
self.message_loop_task = asyncio.create_task(
|
||||||
self._process_message_queue()
|
self._process_message_queue()
|
||||||
|
|||||||
300
agent.py
300
agent.py
@@ -1,7 +1,8 @@
|
|||||||
"""AI Agent with Memory and LLM Integration."""
|
"""AI Agent with Memory and LLM Integration."""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from typing import List, Optional
|
import time
|
||||||
|
from typing import List, Optional, Callable
|
||||||
|
|
||||||
from hooks import HooksSystem
|
from hooks import HooksSystem
|
||||||
from llm_interface import LLMInterface
|
from llm_interface import LLMInterface
|
||||||
@@ -10,11 +11,13 @@ from self_healing import SelfHealingSystem
|
|||||||
from tools import TOOL_DEFINITIONS, execute_tool
|
from tools import TOOL_DEFINITIONS, execute_tool
|
||||||
|
|
||||||
# Maximum number of recent messages to include in LLM context
|
# Maximum number of recent messages to include in LLM context
|
||||||
MAX_CONTEXT_MESSAGES = 10 # Increased for better context retention
|
MAX_CONTEXT_MESSAGES = 20 # Optimized for Agent SDK flat-rate subscription
|
||||||
# Maximum characters of agent response to store in memory
|
# Maximum characters of agent response to store in memory
|
||||||
MEMORY_RESPONSE_PREVIEW_LENGTH = 200
|
MEMORY_RESPONSE_PREVIEW_LENGTH = 500 # Store more context for better memory retrieval
|
||||||
# Maximum conversation history entries before pruning
|
# Maximum conversation history entries before pruning
|
||||||
MAX_CONVERSATION_HISTORY = 50
|
MAX_CONVERSATION_HISTORY = 100 # Higher limit with flat-rate subscription
|
||||||
|
# Maximum tool execution iterations (generous limit for complex operations like zettelkasten)
|
||||||
|
MAX_TOOL_ITERATIONS = 30 # Allows complex multi-step workflows with auto-linking, hybrid search, etc.
|
||||||
|
|
||||||
|
|
||||||
class Agent:
|
class Agent:
|
||||||
@@ -24,6 +27,8 @@ class Agent:
|
|||||||
self,
|
self,
|
||||||
provider: str = "claude",
|
provider: str = "claude",
|
||||||
workspace_dir: str = "./memory_workspace",
|
workspace_dir: str = "./memory_workspace",
|
||||||
|
is_sub_agent: bool = False,
|
||||||
|
specialist_prompt: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.memory = MemorySystem(workspace_dir)
|
self.memory = MemorySystem(workspace_dir)
|
||||||
self.llm = LLMInterface(provider)
|
self.llm = LLMInterface(provider)
|
||||||
@@ -31,9 +36,96 @@ class Agent:
|
|||||||
self.conversation_history: List[dict] = []
|
self.conversation_history: List[dict] = []
|
||||||
self._chat_lock = threading.Lock()
|
self._chat_lock = threading.Lock()
|
||||||
self.healing_system = SelfHealingSystem(self.memory, self)
|
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
|
||||||
|
self.specialist_prompt = specialist_prompt
|
||||||
|
self.sub_agents: dict = {} # Cache for spawned sub-agents
|
||||||
|
|
||||||
self.memory.sync()
|
self.memory.sync()
|
||||||
self.hooks.trigger("agent", "startup", {"workspace_dir": workspace_dir})
|
if not is_sub_agent: # Only trigger hooks for main agent
|
||||||
|
self.hooks.trigger("agent", "startup", {"workspace_dir": workspace_dir})
|
||||||
|
|
||||||
|
def spawn_sub_agent(
|
||||||
|
self,
|
||||||
|
specialist_prompt: str,
|
||||||
|
agent_id: Optional[str] = None,
|
||||||
|
share_memory: bool = True,
|
||||||
|
) -> 'Agent':
|
||||||
|
"""Spawn a sub-agent with specialized system prompt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
specialist_prompt: Custom system prompt for the specialist
|
||||||
|
agent_id: Optional ID for caching (reuse same specialist)
|
||||||
|
share_memory: Whether to share memory workspace with main agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Agent instance configured as a specialist
|
||||||
|
|
||||||
|
Example:
|
||||||
|
sub = agent.spawn_sub_agent(
|
||||||
|
specialist_prompt="You are a zettelkasten expert. Focus ONLY on note-taking.",
|
||||||
|
agent_id="zettelkasten_processor"
|
||||||
|
)
|
||||||
|
result = sub.chat("Process my fleeting notes", username="jordan")
|
||||||
|
"""
|
||||||
|
# Check cache if agent_id provided
|
||||||
|
if agent_id and agent_id in self.sub_agents:
|
||||||
|
return self.sub_agents[agent_id]
|
||||||
|
|
||||||
|
# Create new sub-agent
|
||||||
|
workspace = self.memory.workspace_dir if share_memory else f"{self.memory.workspace_dir}/sub_agents/{agent_id}"
|
||||||
|
sub_agent = Agent(
|
||||||
|
provider=self.llm.provider,
|
||||||
|
workspace_dir=workspace,
|
||||||
|
is_sub_agent=True,
|
||||||
|
specialist_prompt=specialist_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache if ID provided
|
||||||
|
if agent_id:
|
||||||
|
self.sub_agents[agent_id] = sub_agent
|
||||||
|
|
||||||
|
return sub_agent
|
||||||
|
|
||||||
|
def delegate(
|
||||||
|
self,
|
||||||
|
task: str,
|
||||||
|
specialist_prompt: str,
|
||||||
|
username: str = "default",
|
||||||
|
agent_id: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Delegate a task to a specialist sub-agent (convenience method).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The task/message to send to the specialist
|
||||||
|
specialist_prompt: System prompt defining the specialist's role
|
||||||
|
username: Username for context
|
||||||
|
agent_id: Optional ID for caching the specialist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response from the specialist
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# One-off delegation
|
||||||
|
result = agent.delegate(
|
||||||
|
task="Process my fleeting notes and find connections",
|
||||||
|
specialist_prompt="You are a zettelkasten expert. Focus on note organization and linking.",
|
||||||
|
username="jordan"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cached specialist (reused across multiple calls)
|
||||||
|
result = agent.delegate(
|
||||||
|
task="Summarize my emails from today",
|
||||||
|
specialist_prompt="You are an email analyst. Focus on extracting key information.",
|
||||||
|
username="jordan",
|
||||||
|
agent_id="email_analyst"
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
sub_agent = self.spawn_sub_agent(specialist_prompt, agent_id=agent_id)
|
||||||
|
return sub_agent.chat(task, username=username)
|
||||||
|
|
||||||
def _get_context_messages(self, max_messages: int) -> List[dict]:
|
def _get_context_messages(self, max_messages: int) -> List[dict]:
|
||||||
"""Get recent messages without breaking tool_use/tool_result pairs.
|
"""Get recent messages without breaking tool_use/tool_result pairs.
|
||||||
@@ -105,13 +197,26 @@ class Agent:
|
|||||||
|
|
||||||
self.conversation_history = self.conversation_history[start_idx:]
|
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.
|
"""Chat with context from memory and tool use.
|
||||||
|
|
||||||
Thread-safe: uses a lock to prevent concurrent modification of
|
Thread-safe: uses a lock to prevent concurrent modification of
|
||||||
conversation history from multiple threads (e.g., scheduled tasks
|
conversation history from multiple threads (e.g., scheduled tasks
|
||||||
and live messages).
|
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)
|
# Handle model switching commands (no lock needed, read-only on history)
|
||||||
if user_message.lower().startswith("/model "):
|
if user_message.lower().startswith("/model "):
|
||||||
model_name = user_message[7:].strip()
|
model_name = user_message[7:].strip()
|
||||||
@@ -136,36 +241,160 @@ class Agent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with self._chat_lock:
|
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:
|
def _chat_inner(self, user_message: str, username: str) -> str:
|
||||||
"""Inner chat logic, called while holding _chat_lock."""
|
"""Inner chat logic, called while holding _chat_lock."""
|
||||||
soul = self.memory.get_soul()
|
system = self._build_system_prompt(user_message, username)
|
||||||
user_profile = self.memory.get_user(username)
|
|
||||||
relevant_memory = self.memory.search_hybrid(user_message, max_results=2)
|
|
||||||
|
|
||||||
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 tools for file operations and command execution. "
|
|
||||||
f"Use them freely to help the user."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.conversation_history.append(
|
self.conversation_history.append(
|
||||||
{"role": "user", "content": user_message}
|
{"role": "user", "content": user_message}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prune history to prevent unbounded growth
|
|
||||||
self._prune_conversation_history()
|
self._prune_conversation_history()
|
||||||
|
|
||||||
# Tool execution loop
|
# In Agent SDK mode, query() handles tool calls automatically via MCP.
|
||||||
max_iterations = 15 # Increased for complex multi-step operations
|
# The tool loop is only needed for Direct API mode.
|
||||||
# Enable caching for Sonnet to save 90% on repeated system prompts
|
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
|
||||||
use_caching = "sonnet" in self.llm.model.lower()
|
use_caching = "sonnet" in self.llm.model.lower()
|
||||||
|
tools_used = []
|
||||||
|
|
||||||
for iteration in range(max_iterations):
|
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)
|
context_messages = self._get_context_messages(MAX_CONTEXT_MESSAGES)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -180,19 +409,17 @@ class Agent:
|
|||||||
print(f"[Agent] {error_msg}")
|
print(f"[Agent] {error_msg}")
|
||||||
self.healing_system.capture_error(
|
self.healing_system.capture_error(
|
||||||
error=e,
|
error=e,
|
||||||
component="agent.py:_chat_inner",
|
component="agent.py:_chat_direct_api",
|
||||||
intent="Calling LLM API for chat response",
|
intent="Calling Direct API for chat response",
|
||||||
context={
|
context={
|
||||||
"model": self.llm.model,
|
"model": self.llm.model,
|
||||||
"message_preview": user_message[:100],
|
"message_preview": user_message[:100],
|
||||||
"iteration": iteration,
|
"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":
|
if response.stop_reason == "end_turn":
|
||||||
# Extract text response
|
|
||||||
text_content = []
|
text_content = []
|
||||||
for block in response.content:
|
for block in response.content:
|
||||||
if block.type == "text":
|
if block.type == "text":
|
||||||
@@ -200,7 +427,6 @@ class Agent:
|
|||||||
|
|
||||||
final_response = "\n".join(text_content)
|
final_response = "\n".join(text_content)
|
||||||
|
|
||||||
# Handle empty response
|
|
||||||
if not final_response.strip():
|
if not final_response.strip():
|
||||||
final_response = "(No response generated)"
|
final_response = "(No response generated)"
|
||||||
|
|
||||||
@@ -208,17 +434,16 @@ class Agent:
|
|||||||
{"role": "assistant", "content": final_response}
|
{"role": "assistant", "content": final_response}
|
||||||
)
|
)
|
||||||
|
|
||||||
preview = final_response[:MEMORY_RESPONSE_PREVIEW_LENGTH]
|
compact_summary = self.memory.compact_conversation(
|
||||||
self.memory.write_memory(
|
user_message=user_message,
|
||||||
f"**User ({username})**: {user_message}\n"
|
assistant_response=final_response,
|
||||||
f"**Agent**: {preview}...",
|
tools_used=tools_used if tools_used else None
|
||||||
daily=True,
|
|
||||||
)
|
)
|
||||||
|
self.memory.write_memory(compact_summary, daily=True)
|
||||||
|
|
||||||
return final_response
|
return final_response
|
||||||
|
|
||||||
elif response.stop_reason == "tool_use":
|
elif response.stop_reason == "tool_use":
|
||||||
# Build assistant message with tool uses
|
|
||||||
assistant_content = []
|
assistant_content = []
|
||||||
tool_uses = []
|
tool_uses = []
|
||||||
|
|
||||||
@@ -242,11 +467,11 @@ class Agent:
|
|||||||
"content": assistant_content
|
"content": assistant_content
|
||||||
})
|
})
|
||||||
|
|
||||||
# Execute tools and build tool result message
|
|
||||||
tool_results = []
|
tool_results = []
|
||||||
for tool_use in tool_uses:
|
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)
|
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:
|
if len(result) > 5000:
|
||||||
result = result[:5000] + "\n... (output truncated)"
|
result = result[:5000] + "\n... (output truncated)"
|
||||||
print(f"[Tool] {tool_use.name}: {result[:100]}...")
|
print(f"[Tool] {tool_use.name}: {result[:100]}...")
|
||||||
@@ -262,7 +487,6 @@ class Agent:
|
|||||||
})
|
})
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Unexpected stop reason
|
|
||||||
return f"Unexpected stop reason: {response.stop_reason}"
|
return f"Unexpected stop reason: {response.stop_reason}"
|
||||||
|
|
||||||
return "Error: Maximum tool use iterations exceeded"
|
return "Error: Maximum tool use iterations exceeded"
|
||||||
|
|||||||
22
config/gitea_config.example.yaml
Normal file
22
config/gitea_config.example.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Gitea Configuration
|
||||||
|
# Copy to gitea_config.yaml and fill in your values
|
||||||
|
#
|
||||||
|
# cp config/gitea_config.example.yaml config/gitea_config.yaml
|
||||||
|
|
||||||
|
# Gitea instance URL (no trailing slash)
|
||||||
|
base_url: "https://vulcan.apophisnetworking.net"
|
||||||
|
|
||||||
|
# Personal Access Token for API authentication
|
||||||
|
# To generate a token:
|
||||||
|
# 1. Go to https://vulcan.apophisnetworking.net/user/settings/applications
|
||||||
|
# 2. Under "Manage Access Tokens", enter a token name (e.g., "garvis-bot")
|
||||||
|
# 3. Select permissions: at minimum, check "repo" (read) scope
|
||||||
|
# 4. Click "Generate Token"
|
||||||
|
# 5. Copy the token here (it is shown only once!)
|
||||||
|
token: "your_personal_access_token_here"
|
||||||
|
|
||||||
|
# Default repository owner (used when repo is not specified in tool calls)
|
||||||
|
default_owner: "jramos"
|
||||||
|
|
||||||
|
# Default repository name (used when repo is not specified in tool calls)
|
||||||
|
default_repo: "homelab"
|
||||||
113
config/obsidian_mcp.example.yaml
Normal file
113
config/obsidian_mcp.example.yaml
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Obsidian MCP Server Configuration
|
||||||
|
# ===================================
|
||||||
|
# This file configures the external Obsidian MCP server integration.
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. Copy this file: copy config\obsidian_mcp.example.yaml config\obsidian_mcp.yaml
|
||||||
|
# 2. Set your vault_path below
|
||||||
|
# 3. Ensure Node.js 20+ is installed: node --version
|
||||||
|
# 4. Restart the bot: python bot_runner.py
|
||||||
|
#
|
||||||
|
# The config file (obsidian_mcp.yaml) is gitignored to protect your vault path.
|
||||||
|
# See OBSIDIAN_MCP_INTEGRATION.md for full documentation.
|
||||||
|
|
||||||
|
obsidian_mcp:
|
||||||
|
# ---- Core Settings ----
|
||||||
|
|
||||||
|
# Enable or disable the Obsidian MCP integration
|
||||||
|
# Set to false to disable without removing the config
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Absolute path to your Obsidian vault directory
|
||||||
|
# This MUST be the root folder of your vault (contains .obsidian/ subfolder)
|
||||||
|
#
|
||||||
|
# Windows examples (use double backslashes OR forward slashes):
|
||||||
|
# "C:\\Users\\username\\Documents\\Obsidian\\MyVault"
|
||||||
|
# "C:/Users/username/Documents/Obsidian/MyVault"
|
||||||
|
#
|
||||||
|
# Linux/Mac example:
|
||||||
|
# "/home/username/obsidian-vault"
|
||||||
|
#
|
||||||
|
# To use the bot's built-in zettelkasten vault (same files as custom tools):
|
||||||
|
# "C:\\Users\\username\\projects\\ajarbot\\memory_workspace\\obsidian"
|
||||||
|
vault_path: "C:\\Users\\YOUR_USERNAME\\Documents\\Obsidian\\YOUR_VAULT"
|
||||||
|
|
||||||
|
# ---- Server Settings ----
|
||||||
|
|
||||||
|
server:
|
||||||
|
# Command to launch the MCP server
|
||||||
|
# Default: "npx" (downloads obsidian-mcp on first run)
|
||||||
|
# Alternative: "node" (if installed globally)
|
||||||
|
command: "npx"
|
||||||
|
|
||||||
|
# Arguments passed to the command
|
||||||
|
# The vault_path is appended automatically as the last argument
|
||||||
|
# Default: ["-y", "obsidian-mcp"]
|
||||||
|
# -y = auto-confirm npm package installation
|
||||||
|
args: ["-y", "obsidian-mcp"]
|
||||||
|
|
||||||
|
# Server startup timeout in seconds
|
||||||
|
# Increase if npx is slow on first download
|
||||||
|
startup_timeout: 30
|
||||||
|
|
||||||
|
# ---- Permission Controls ----
|
||||||
|
# Control which operations the bot is allowed to perform.
|
||||||
|
# Disable categories to restrict the bot's access to your vault.
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
# Read operations (safe, no changes to vault)
|
||||||
|
# Tools: read-note, search-vault, list-available-vaults, manage-tags
|
||||||
|
allow_read: true
|
||||||
|
|
||||||
|
# Write operations (creates new files or modifies existing ones)
|
||||||
|
# Tools: create-note, edit-note
|
||||||
|
allow_write: true
|
||||||
|
|
||||||
|
# Delete operations (permanently removes files)
|
||||||
|
# Tools: delete-note
|
||||||
|
# DISABLED by default for safety - enable only if you trust the bot
|
||||||
|
allow_delete: false
|
||||||
|
|
||||||
|
# Move/rename operations (changes file paths)
|
||||||
|
# Tools: move-note
|
||||||
|
allow_move: true
|
||||||
|
|
||||||
|
# Tag operations (modifies YAML frontmatter)
|
||||||
|
# Tools: add-tags, remove-tags, rename-tag
|
||||||
|
allow_tags: true
|
||||||
|
|
||||||
|
# Directory operations (creates new folders)
|
||||||
|
# Tools: create-directory
|
||||||
|
allow_directories: true
|
||||||
|
|
||||||
|
# ---- Safety Settings ----
|
||||||
|
|
||||||
|
safety:
|
||||||
|
# Require user confirmation before any write operation
|
||||||
|
# When true, the bot will ask "Are you sure?" before creating/editing notes
|
||||||
|
confirm_writes: false
|
||||||
|
|
||||||
|
# Create a backup copy before deleting a note
|
||||||
|
# Backup is saved to .trash/ in the vault root
|
||||||
|
backup_before_delete: true
|
||||||
|
|
||||||
|
# Maximum note content size in characters
|
||||||
|
# Prevents accidental creation of very large notes
|
||||||
|
max_note_size: 50000
|
||||||
|
|
||||||
|
# Directories the bot should never access or modify
|
||||||
|
# Paths are relative to the vault root
|
||||||
|
restricted_paths:
|
||||||
|
- ".obsidian" # Obsidian app configuration
|
||||||
|
- ".trash" # Obsidian trash folder
|
||||||
|
- ".git" # Git repository data (if vault is version-controlled)
|
||||||
|
|
||||||
|
# ---- Environment Variable Overrides ----
|
||||||
|
# These environment variables override the YAML settings above.
|
||||||
|
# Add them to your .env file if you prefer not to use YAML config.
|
||||||
|
#
|
||||||
|
# OBSIDIAN_VAULT_PATH=C:\Users\username\Documents\Obsidian\MyVault
|
||||||
|
# OBSIDIAN_MCP_ENABLED=true
|
||||||
|
# OBSIDIAN_MCP_COMMAND=npx
|
||||||
|
# OBSIDIAN_MCP_ALLOW_DELETE=false
|
||||||
|
# OBSIDIAN_MCP_CONFIRM_WRITES=false
|
||||||
@@ -1,85 +1,63 @@
|
|||||||
# Scheduled Tasks Configuration (EXAMPLE)
|
# Scheduled Tasks Configuration
|
||||||
# Copy this to scheduled_tasks.yaml and customize with your values
|
# Tasks that require the Agent/LLM to execute
|
||||||
|
#
|
||||||
|
# Copy this file to scheduled_tasks.yaml and customize with your settings
|
||||||
|
# scheduled_tasks.yaml is gitignored to protect personal information
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
# Morning briefing - sent to Slack/Telegram
|
# Morning briefing - sent to Slack/Telegram
|
||||||
- name: morning-weather
|
- name: morning-weather
|
||||||
prompt: |
|
prompt: |
|
||||||
Good morning! Please provide a weather report and daily briefing:
|
Check the user profile ([username].md) for the location. Use the get_weather tool to fetch current weather.
|
||||||
|
|
||||||
1. Current weather (you can infer or say you need an API key)
|
Format the report as:
|
||||||
2. Any pending tasks from yesterday
|
|
||||||
3. Priorities for today
|
|
||||||
4. A motivational quote to start the day
|
|
||||||
|
|
||||||
Keep it brief and friendly.
|
🌤️ **Weather Report for [Your City]**
|
||||||
|
- Current: [current]°F
|
||||||
|
- High: [high]°F
|
||||||
|
- Low: [low]°F
|
||||||
|
- Conditions: [conditions]
|
||||||
|
- Recommendation: [brief clothing/activity suggestion]
|
||||||
|
|
||||||
|
Keep it brief and friendly!
|
||||||
schedule: "daily 06:00"
|
schedule: "daily 06:00"
|
||||||
enabled: true
|
enabled: true
|
||||||
send_to_platform: "telegram"
|
send_to_platform: "telegram" # or "slack"
|
||||||
send_to_channel: "YOUR_TELEGRAM_USER_ID" # Replace with your Telegram user ID
|
send_to_channel: "YOUR_TELEGRAM_USER_ID"
|
||||||
|
|
||||||
# Evening summary
|
# Daily Zettelkasten Review
|
||||||
- name: evening-report
|
- name: zettelkasten-daily-review
|
||||||
prompt: |
|
prompt: |
|
||||||
Good evening! Time for the daily wrap-up:
|
Time for your daily zettelkasten review! Help process fleeting notes:
|
||||||
|
|
||||||
1. What was accomplished today?
|
1. Use search_by_tags to find all notes tagged with "fleeting"
|
||||||
2. Any tasks still pending?
|
2. Show the list of fleeting notes
|
||||||
3. Preview of tomorrow's priorities
|
3. For each note, ask: "Would you like to:
|
||||||
4. Weather forecast for tomorrow (infer or API needed)
|
a) Process this into a permanent note
|
||||||
|
b) Keep as fleeting for now
|
||||||
|
c) Delete (not useful)"
|
||||||
|
|
||||||
Keep it concise and positive.
|
Keep it conversational and low-pressure!
|
||||||
schedule: "daily 18:00"
|
schedule: "daily 20:00"
|
||||||
enabled: false
|
enabled: true
|
||||||
send_to_platform: "telegram"
|
send_to_platform: "telegram"
|
||||||
send_to_channel: "YOUR_TELEGRAM_USER_ID"
|
send_to_channel: "YOUR_TELEGRAM_USER_ID"
|
||||||
|
|
||||||
# Hourly health check (no message sending)
|
# Daily API cost report
|
||||||
- name: system-health-check
|
- name: daily-cost-report
|
||||||
prompt: |
|
prompt: |
|
||||||
Quick health check:
|
Generate a daily API usage and cost report:
|
||||||
|
|
||||||
1. Are there any tasks that have been pending > 24 hours?
|
Read the usage_data.json file to get today's API usage statistics.
|
||||||
2. Is the memory system healthy?
|
|
||||||
3. Any alerts or issues?
|
|
||||||
|
|
||||||
Respond with "HEALTHY" if all is well, otherwise describe the issue.
|
Format the report with today's costs, token usage, and budget tracking.
|
||||||
schedule: "hourly"
|
Warn if cumulative cost exceeds 75% of budget.
|
||||||
|
|
||||||
|
Keep it clear and actionable!
|
||||||
|
schedule: "daily 23:00"
|
||||||
enabled: false
|
enabled: false
|
||||||
username: "health-checker"
|
send_to_platform: "telegram"
|
||||||
|
send_to_channel: "YOUR_TELEGRAM_USER_ID"
|
||||||
# Weekly review on Friday
|
|
||||||
- name: weekly-summary
|
|
||||||
prompt: |
|
|
||||||
It's Friday! Time for the weekly review:
|
|
||||||
|
|
||||||
1. Major accomplishments this week
|
|
||||||
2. Challenges faced and lessons learned
|
|
||||||
3. Key metrics (tasks completed, etc.)
|
|
||||||
4. Goals for next week
|
|
||||||
5. Team shoutouts (if applicable)
|
|
||||||
|
|
||||||
Make it comprehensive but engaging.
|
|
||||||
schedule: "weekly fri 17:00"
|
|
||||||
enabled: false
|
|
||||||
send_to_platform: "slack"
|
|
||||||
send_to_channel: "YOUR_SLACK_CHANNEL_ID"
|
|
||||||
|
|
||||||
# Custom: Midday standup
|
|
||||||
- name: midday-standup
|
|
||||||
prompt: |
|
|
||||||
Midday check-in! Quick standup report:
|
|
||||||
|
|
||||||
1. Morning accomplishments
|
|
||||||
2. Current focus
|
|
||||||
3. Any blockers?
|
|
||||||
4. Afternoon plan
|
|
||||||
|
|
||||||
Keep it brief - standup style.
|
|
||||||
schedule: "daily 12:00"
|
|
||||||
enabled: false
|
|
||||||
send_to_platform: "slack"
|
|
||||||
send_to_channel: "YOUR_SLACK_CHANNEL_ID"
|
|
||||||
|
|
||||||
# Configuration notes:
|
# Configuration notes:
|
||||||
# - schedule formats:
|
# - schedule formats:
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ tasks:
|
|||||||
# Morning briefing - sent to Slack/Telegram
|
# Morning briefing - sent to Slack/Telegram
|
||||||
- name: morning-weather
|
- name: morning-weather
|
||||||
prompt: |
|
prompt: |
|
||||||
Check the user profile (Jordan.md) for the location (Centennial, CO). Use the get_weather tool with OpenWeatherMap API to fetch the current weather. Format the report as:
|
Check the user profile (Jordan.md) for the location (Centennial, CO). Use the get_weather tool with OpenWeatherMap API to fetch the current weather.
|
||||||
|
|
||||||
|
Also use web_fetch to get today's high/low from a weather service:
|
||||||
|
https://wttr.in/Centennial,CO?format=j1
|
||||||
|
|
||||||
|
Parse the JSON response to extract:
|
||||||
|
- maxtempF (today's high)
|
||||||
|
- mintempF (today's low)
|
||||||
|
|
||||||
|
Format the report as:
|
||||||
|
|
||||||
🌤️ **Weather Report for Centennial, CO**
|
🌤️ **Weather Report for Centennial, CO**
|
||||||
- Current: [current]°F
|
- Current: [current]°F (feels like [feels_like]°F)
|
||||||
- High: [high]°F
|
- Today's High: [high]°F
|
||||||
- Low: [low]°F
|
- Today's Low: [low]°F
|
||||||
- Conditions: [conditions]
|
- Conditions: [conditions]
|
||||||
|
- Wind: [wind speed] mph
|
||||||
- Recommendation: [brief clothing/activity suggestion]
|
- Recommendation: [brief clothing/activity suggestion]
|
||||||
|
|
||||||
Keep it brief and friendly!
|
Keep it brief and friendly!
|
||||||
@@ -20,6 +30,35 @@ tasks:
|
|||||||
send_to_platform: "telegram"
|
send_to_platform: "telegram"
|
||||||
send_to_channel: "8088983654" # Your Telegram user ID
|
send_to_channel: "8088983654" # Your Telegram user ID
|
||||||
|
|
||||||
|
# Daily Zettelkasten Review
|
||||||
|
- name: zettelkasten-daily-review
|
||||||
|
prompt: |
|
||||||
|
Time for your daily zettelkasten review! Help Jordan process fleeting notes:
|
||||||
|
|
||||||
|
1. Use search_by_tags to find all notes tagged with "fleeting"
|
||||||
|
2. Show Jordan the list of fleeting notes captured today/recently
|
||||||
|
3. For each note, ask: "Would you like to:
|
||||||
|
a) Process this into a permanent note
|
||||||
|
b) Keep as fleeting for now
|
||||||
|
c) Delete (not useful)"
|
||||||
|
|
||||||
|
Format:
|
||||||
|
📝 **Daily Zettelkasten Review**
|
||||||
|
|
||||||
|
You have [X] fleeting notes to review:
|
||||||
|
|
||||||
|
1. [Title] - [first line of content]
|
||||||
|
2. [Title] - [first line of content]
|
||||||
|
...
|
||||||
|
|
||||||
|
Reply with the number to process, or 'skip' to review later.
|
||||||
|
|
||||||
|
Keep it conversational and low-pressure!
|
||||||
|
schedule: "daily 20:00"
|
||||||
|
enabled: true
|
||||||
|
send_to_platform: "telegram"
|
||||||
|
send_to_channel: "8088983654"
|
||||||
|
|
||||||
# Daily API cost report
|
# Daily API cost report
|
||||||
- name: daily-cost-report
|
- name: daily-cost-report
|
||||||
prompt: |
|
prompt: |
|
||||||
|
|||||||
173
examples/sub_agent_example.py
Normal file
173
examples/sub_agent_example.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
Example: Using Sub-Agent Orchestration
|
||||||
|
|
||||||
|
This example demonstrates how to use the sub-agent system to delegate
|
||||||
|
specialized tasks to focused agents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from agent import Agent
|
||||||
|
|
||||||
|
|
||||||
|
def example_1_manual_spawning():
|
||||||
|
"""Example 1: Manually spawn and use a specialist."""
|
||||||
|
print("=== Example 1: Manual Spawning ===\n")
|
||||||
|
|
||||||
|
# Create main agent
|
||||||
|
agent = Agent(provider="claude")
|
||||||
|
|
||||||
|
# Spawn a zettelkasten specialist
|
||||||
|
zettel_specialist = agent.spawn_sub_agent(
|
||||||
|
specialist_prompt="""You are a zettelkasten expert. Your ONLY job is:
|
||||||
|
- Process fleeting notes into permanent notes
|
||||||
|
- Find semantic connections using hybrid search
|
||||||
|
- Create wiki-style links between related concepts
|
||||||
|
|
||||||
|
Stay focused on knowledge management. Be concise.""",
|
||||||
|
agent_id="zettelkasten_processor" # Cached for reuse
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the specialist
|
||||||
|
result = zettel_specialist.chat(
|
||||||
|
"Search for all fleeting notes tagged 'AI' and show me what you find.",
|
||||||
|
username="jordan"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Specialist Response:\n{result}\n")
|
||||||
|
|
||||||
|
# Reuse the cached specialist
|
||||||
|
result2 = zettel_specialist.chat(
|
||||||
|
"Now create a permanent note summarizing key AI concepts.",
|
||||||
|
username="jordan"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Second Response:\n{result2}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def example_2_delegation():
|
||||||
|
"""Example 2: One-off delegation (convenience method)."""
|
||||||
|
print("=== Example 2: Delegation ===\n")
|
||||||
|
|
||||||
|
agent = Agent(provider="claude")
|
||||||
|
|
||||||
|
# One-off delegation (specialist not cached)
|
||||||
|
result = agent.delegate(
|
||||||
|
task="List all files in the memory_workspace/obsidian directory",
|
||||||
|
specialist_prompt="""You are a file system expert. Your job is to:
|
||||||
|
- Navigate directories efficiently
|
||||||
|
- Provide clear, organized file listings
|
||||||
|
|
||||||
|
Be concise and focused.""",
|
||||||
|
username="jordan"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Delegation Result:\n{result}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def example_3_cached_delegation():
|
||||||
|
"""Example 3: Cached delegation (reuse specialist)."""
|
||||||
|
print("=== Example 3: Cached Delegation ===\n")
|
||||||
|
|
||||||
|
agent = Agent(provider="claude")
|
||||||
|
|
||||||
|
# First call: Creates and caches the specialist
|
||||||
|
result1 = agent.delegate(
|
||||||
|
task="Search the zettelkasten vault for notes about 'architecture'",
|
||||||
|
specialist_prompt="""You are a zettelkasten search expert. Your job is:
|
||||||
|
- Use hybrid search to find relevant notes
|
||||||
|
- Summarize key findings concisely
|
||||||
|
|
||||||
|
Stay focused on search and retrieval.""",
|
||||||
|
username="jordan",
|
||||||
|
agent_id="zettel_search" # This specialist will be cached
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"First Search:\n{result1}\n")
|
||||||
|
|
||||||
|
# Second call: Reuses the cached specialist
|
||||||
|
result2 = agent.delegate(
|
||||||
|
task="Now search for notes about 'design patterns'",
|
||||||
|
specialist_prompt="(ignored - using cached specialist)",
|
||||||
|
username="jordan",
|
||||||
|
agent_id="zettel_search" # Same ID = reuse cached specialist
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Second Search:\n{result2}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def example_4_multiple_specialists():
|
||||||
|
"""Example 4: Use multiple specialists for different tasks."""
|
||||||
|
print("=== Example 4: Multiple Specialists ===\n")
|
||||||
|
|
||||||
|
agent = Agent(provider="claude")
|
||||||
|
|
||||||
|
# Email specialist
|
||||||
|
email_result = agent.delegate(
|
||||||
|
task="Check if there are any unread emails in the last 24 hours",
|
||||||
|
specialist_prompt="""You are an email analyst. Your job is:
|
||||||
|
- Search and filter emails efficiently
|
||||||
|
- Summarize key information concisely
|
||||||
|
|
||||||
|
Focus on email intelligence.""",
|
||||||
|
username="jordan",
|
||||||
|
agent_id="email_analyst"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Email Analysis:\n{email_result}\n")
|
||||||
|
|
||||||
|
# Calendar specialist
|
||||||
|
calendar_result = agent.delegate(
|
||||||
|
task="Show me my calendar events for the next 3 days",
|
||||||
|
specialist_prompt="""You are a calendar expert. Your job is:
|
||||||
|
- Retrieve calendar events efficiently
|
||||||
|
- Present schedules clearly
|
||||||
|
|
||||||
|
Focus on time management.""",
|
||||||
|
username="jordan",
|
||||||
|
agent_id="calendar_manager"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Calendar Review:\n{calendar_result}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def example_5_isolated_memory():
|
||||||
|
"""Example 5: Create specialist with isolated memory."""
|
||||||
|
print("=== Example 5: Isolated Memory ===\n")
|
||||||
|
|
||||||
|
agent = Agent(provider="claude")
|
||||||
|
|
||||||
|
# Specialist with its own memory workspace
|
||||||
|
specialist = agent.spawn_sub_agent(
|
||||||
|
specialist_prompt="You are a research assistant. Focus on gathering information.",
|
||||||
|
agent_id="researcher",
|
||||||
|
share_memory=False # Isolated workspace
|
||||||
|
)
|
||||||
|
|
||||||
|
# This specialist's memory is stored in:
|
||||||
|
# memory_workspace/sub_agents/researcher/
|
||||||
|
|
||||||
|
result = specialist.chat(
|
||||||
|
"Research the concept of 'emergence' and save findings.",
|
||||||
|
username="jordan"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Research Result:\n{result}\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run examples
|
||||||
|
# Uncomment the examples you want to try:
|
||||||
|
|
||||||
|
# example_1_manual_spawning()
|
||||||
|
# example_2_delegation()
|
||||||
|
# example_3_cached_delegation()
|
||||||
|
# example_4_multiple_specialists()
|
||||||
|
# example_5_isolated_memory()
|
||||||
|
|
||||||
|
print("\nℹ️ Uncomment the examples you want to run in the __main__ block")
|
||||||
|
print("ℹ️ Note: Some examples require Google OAuth setup and active API keys")
|
||||||
5
gitea_tools/__init__.py
Normal file
5
gitea_tools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Gitea Tools - Private Gitea repository access for ajarbot."""
|
||||||
|
|
||||||
|
from .client import GiteaClient
|
||||||
|
|
||||||
|
__all__ = ["GiteaClient"]
|
||||||
597
gitea_tools/client.py
Normal file
597
gitea_tools/client.py
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
"""Gitea API Client - Access private Gitea repositories.
|
||||||
|
|
||||||
|
Uses Gitea's REST API (compatible with GitHub API v3) to read files,
|
||||||
|
list directories, search code, and get directory trees from private repos.
|
||||||
|
|
||||||
|
Authentication via Personal Access Token configured in config/gitea_config.yaml.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Config file path
|
||||||
|
_CONFIG_PATH = Path("config/gitea_config.yaml")
|
||||||
|
|
||||||
|
# Request timeout (seconds)
|
||||||
|
_REQUEST_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
# Maximum file size to return (1MB)
|
||||||
|
_MAX_FILE_SIZE = 1_000_000
|
||||||
|
|
||||||
|
# Maximum output characters (prevents token explosion)
|
||||||
|
_MAX_OUTPUT_CHARS = 5000
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient:
|
||||||
|
"""Client for Gitea REST API with Personal Access Token authentication."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
token: Optional[str] = None,
|
||||||
|
default_owner: Optional[str] = None,
|
||||||
|
default_repo: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize Gitea client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Gitea instance URL (e.g., "https://vulcan.apophisnetworking.net").
|
||||||
|
token: Personal Access Token for authentication.
|
||||||
|
default_owner: Default repository owner (e.g., "jramos").
|
||||||
|
default_repo: Default repository name (e.g., "homelab").
|
||||||
|
|
||||||
|
If arguments are not provided, reads from config/gitea_config.yaml.
|
||||||
|
"""
|
||||||
|
config = self._load_config()
|
||||||
|
|
||||||
|
self.base_url = (base_url or config.get("base_url", "")).rstrip("/")
|
||||||
|
self.token = token or config.get("token", "")
|
||||||
|
self.default_owner = default_owner or config.get("default_owner", "")
|
||||||
|
self.default_repo = default_repo or config.get("default_repo", "")
|
||||||
|
|
||||||
|
if not self.base_url:
|
||||||
|
raise ValueError(
|
||||||
|
"Gitea base_url not configured. "
|
||||||
|
"Set it in config/gitea_config.yaml or pass base_url argument."
|
||||||
|
)
|
||||||
|
if not self.token:
|
||||||
|
raise ValueError(
|
||||||
|
"Gitea token not configured. "
|
||||||
|
"Create a Personal Access Token at "
|
||||||
|
f"{self.base_url}/user/settings/applications "
|
||||||
|
"and add it to config/gitea_config.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.api_url = f"{self.base_url}/api/v1"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[Gitea] Client initialized: %s (default: %s/%s)",
|
||||||
|
self.base_url,
|
||||||
|
self.default_owner,
|
||||||
|
self.default_repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_config() -> Dict[str, Any]:
|
||||||
|
"""Load configuration from YAML file."""
|
||||||
|
if not _CONFIG_PATH.exists():
|
||||||
|
logger.warning(
|
||||||
|
"[Gitea] Config file not found: %s. "
|
||||||
|
"Copy config/gitea_config.example.yaml to config/gitea_config.yaml",
|
||||||
|
_CONFIG_PATH,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = _CONFIG_PATH.read_text(encoding="utf-8")
|
||||||
|
config = yaml.safe_load(content) or {}
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[Gitea] Failed to load config: %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _parse_repo(
|
||||||
|
self,
|
||||||
|
repo: Optional[str] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
) -> tuple:
|
||||||
|
"""Parse owner/repo from various input formats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Repository in "owner/repo" format, or just "repo" name.
|
||||||
|
owner: Explicit owner (overrides repo string parsing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (owner, repo) strings.
|
||||||
|
"""
|
||||||
|
if repo and "/" in repo:
|
||||||
|
parts = repo.split("/", 1)
|
||||||
|
parsed_owner = parts[0]
|
||||||
|
parsed_repo = parts[1]
|
||||||
|
else:
|
||||||
|
parsed_owner = owner or self.default_owner
|
||||||
|
parsed_repo = repo or self.default_repo
|
||||||
|
|
||||||
|
if owner:
|
||||||
|
parsed_owner = owner
|
||||||
|
|
||||||
|
if not parsed_owner or not parsed_repo:
|
||||||
|
raise ValueError(
|
||||||
|
f"Repository not specified. Provide repo as 'owner/repo' "
|
||||||
|
f"or configure default_owner/default_repo in gitea_config.yaml. "
|
||||||
|
f"Got owner='{parsed_owner}', repo='{parsed_repo}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return parsed_owner, parsed_repo
|
||||||
|
|
||||||
|
def _headers(self) -> Dict[str, str]:
|
||||||
|
"""Build request headers with authentication."""
|
||||||
|
return {
|
||||||
|
"Authorization": f"token {self.token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "Garvis/1.0 (Ajarbot Gitea Integration)",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: Optional[Dict] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make an authenticated API request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, etc.).
|
||||||
|
endpoint: API endpoint path (e.g., "/repos/jramos/homelab/contents/README.md").
|
||||||
|
params: Optional query parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with "success" key and either "data" or "error".
|
||||||
|
"""
|
||||||
|
url = f"{self.api_url}{endpoint}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=_REQUEST_TIMEOUT,
|
||||||
|
follow_redirects=True,
|
||||||
|
verify=True,
|
||||||
|
headers=self._headers(),
|
||||||
|
) as client:
|
||||||
|
response = await client.request(method, url, params=params)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
"Authentication failed (HTTP 401). "
|
||||||
|
"Check your Personal Access Token in config/gitea_config.yaml. "
|
||||||
|
f"Generate a new token at: {self.base_url}/user/settings/applications"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
elif response.status_code == 404:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Not found (HTTP 404): {endpoint}",
|
||||||
|
}
|
||||||
|
elif response.status_code >= 400:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
}
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return {"success": True, "data": data}
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Request to {self.base_url} timed out after {_REQUEST_TIMEOUT}s",
|
||||||
|
}
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Connection failed to {self.base_url}: {e}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Request failed: {str(e)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_file_content(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
repo: Optional[str] = None,
|
||||||
|
branch: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get raw file content from a repository.
|
||||||
|
|
||||||
|
Uses Gitea Contents API: GET /repos/{owner}/{repo}/contents/{filepath}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file in repo (e.g., "scripts/proxmox_collector.py").
|
||||||
|
owner: Repository owner (default: from config).
|
||||||
|
repo: Repository name or "owner/repo" (default: from config).
|
||||||
|
branch: Branch name (default: repo default branch).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with "success", and either "content"/"metadata" or "error".
|
||||||
|
"""
|
||||||
|
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||||
|
|
||||||
|
# Normalize file path (remove leading slash)
|
||||||
|
file_path = file_path.lstrip("/")
|
||||||
|
|
||||||
|
endpoint = f"/repos/{parsed_owner}/{parsed_repo}/contents/{file_path}"
|
||||||
|
params = {}
|
||||||
|
if branch:
|
||||||
|
params["ref"] = branch
|
||||||
|
|
||||||
|
result = await self._request("GET", endpoint, params=params)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
return result
|
||||||
|
|
||||||
|
data = result["data"]
|
||||||
|
|
||||||
|
# Handle case where path is a directory (returns a list)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"'{file_path}' is a directory, not a file. "
|
||||||
|
f"Use gitea_list_files to browse directories."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
file_size = data.get("size", 0)
|
||||||
|
if file_size > _MAX_FILE_SIZE:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"content": (
|
||||||
|
f"[File too large: {file_size:,} bytes ({file_size / 1024 / 1024:.1f} MB). "
|
||||||
|
f"Maximum is {_MAX_FILE_SIZE:,} bytes. "
|
||||||
|
f"Use the download URL to fetch it directly.]"
|
||||||
|
),
|
||||||
|
"metadata": {
|
||||||
|
"name": data.get("name", ""),
|
||||||
|
"path": data.get("path", ""),
|
||||||
|
"size": file_size,
|
||||||
|
"download_url": data.get("download_url", ""),
|
||||||
|
"sha": data.get("sha", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decode base64 content
|
||||||
|
encoded_content = data.get("content", "")
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(encoded_content).decode("utf-8")
|
||||||
|
except (UnicodeDecodeError, Exception):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"content": "[Binary file - cannot display as text]",
|
||||||
|
"metadata": {
|
||||||
|
"name": data.get("name", ""),
|
||||||
|
"path": data.get("path", ""),
|
||||||
|
"size": file_size,
|
||||||
|
"encoding": data.get("encoding", ""),
|
||||||
|
"download_url": data.get("download_url", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Truncate if too long
|
||||||
|
truncated = False
|
||||||
|
if len(content) > _MAX_OUTPUT_CHARS:
|
||||||
|
content = content[:_MAX_OUTPUT_CHARS] + "\n\n... (file truncated)"
|
||||||
|
truncated = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"content": content,
|
||||||
|
"metadata": {
|
||||||
|
"name": data.get("name", ""),
|
||||||
|
"path": data.get("path", ""),
|
||||||
|
"size": file_size,
|
||||||
|
"sha": data.get("sha", ""),
|
||||||
|
"last_commit_sha": data.get("last_commit_sha", ""),
|
||||||
|
"download_url": data.get("download_url", ""),
|
||||||
|
"truncated": truncated,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_files(
|
||||||
|
self,
|
||||||
|
path: str = "",
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
repo: Optional[str] = None,
|
||||||
|
branch: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""List files and directories at a path in the repository.
|
||||||
|
|
||||||
|
Uses Gitea Contents API: GET /repos/{owner}/{repo}/contents/{path}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Directory path in repo (e.g., "scripts/"). Empty for root.
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name or "owner/repo".
|
||||||
|
branch: Branch name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with "success" and either "files" list or "error".
|
||||||
|
"""
|
||||||
|
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||||
|
|
||||||
|
# Normalize path
|
||||||
|
path = path.strip("/")
|
||||||
|
|
||||||
|
endpoint = f"/repos/{parsed_owner}/{parsed_repo}/contents/{path}" if path else f"/repos/{parsed_owner}/{parsed_repo}/contents"
|
||||||
|
params = {}
|
||||||
|
if branch:
|
||||||
|
params["ref"] = branch
|
||||||
|
|
||||||
|
result = await self._request("GET", endpoint, params=params)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
return result
|
||||||
|
|
||||||
|
data = result["data"]
|
||||||
|
|
||||||
|
# If it's a single file (not a directory), inform the user
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"'{path}' is a file, not a directory. "
|
||||||
|
f"Use gitea_read_file to read file contents."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build file listing
|
||||||
|
files = []
|
||||||
|
for item in data:
|
||||||
|
entry = {
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"type": item.get("type", ""), # "file" or "dir"
|
||||||
|
"path": item.get("path", ""),
|
||||||
|
"size": item.get("size", 0),
|
||||||
|
}
|
||||||
|
files.append(entry)
|
||||||
|
|
||||||
|
# Sort: directories first, then files, alphabetically
|
||||||
|
files.sort(key=lambda f: (0 if f["type"] == "dir" else 1, f["name"].lower()))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"files": files,
|
||||||
|
"path": path or "/",
|
||||||
|
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||||
|
"count": len(files),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def search_code(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
repo: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Search for code in a repository.
|
||||||
|
|
||||||
|
Uses Gitea Code Search API: GET /repos/{owner}/{repo}/topics (fallback)
|
||||||
|
or the general search: GET /repos/search
|
||||||
|
|
||||||
|
Note: Gitea's code search depends on indexer configuration.
|
||||||
|
Falls back to repo-level search if code search is unavailable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string.
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name or "owner/repo".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with "success" and either "results" or "error".
|
||||||
|
"""
|
||||||
|
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||||
|
|
||||||
|
# Try Gitea's code search endpoint first
|
||||||
|
# GET /repos/{owner}/{repo}/contents - search by traversing
|
||||||
|
# Gitea doesn't have a direct per-repo code search API like GitHub
|
||||||
|
# Use the global code search with repo filter
|
||||||
|
endpoint = "/repos/search"
|
||||||
|
params = {
|
||||||
|
"q": query,
|
||||||
|
"owner": parsed_owner,
|
||||||
|
"limit": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
# First try: global code search (if Gitea has it enabled)
|
||||||
|
code_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/git/grep"
|
||||||
|
code_params = {"query": query}
|
||||||
|
|
||||||
|
# Gitea doesn't have a git grep API, use the topic/label search
|
||||||
|
# or fall back to listing + content search
|
||||||
|
# Best approach: use the Gitea search API
|
||||||
|
search_endpoint = "/repos/search"
|
||||||
|
search_params = {
|
||||||
|
"q": query,
|
||||||
|
"limit": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
# For code search, Gitea's best option is the global search endpoint
|
||||||
|
# with topic filter. But for actual file content search, we need to
|
||||||
|
# traverse the tree and search file contents.
|
||||||
|
# Use a pragmatic approach: get the repo tree and search filenames
|
||||||
|
# and provide useful results.
|
||||||
|
|
||||||
|
# Strategy: Get flat tree, filter by query in filename and path
|
||||||
|
tree_result = await self.get_tree(
|
||||||
|
owner=parsed_owner,
|
||||||
|
repo=parsed_repo,
|
||||||
|
recursive=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tree_result["success"]:
|
||||||
|
return tree_result
|
||||||
|
|
||||||
|
entries = tree_result.get("entries", [])
|
||||||
|
query_lower = query.lower()
|
||||||
|
|
||||||
|
# Search filenames and paths
|
||||||
|
matches = []
|
||||||
|
for entry in entries:
|
||||||
|
path = entry.get("path", "")
|
||||||
|
if query_lower in path.lower():
|
||||||
|
matches.append({
|
||||||
|
"path": path,
|
||||||
|
"type": entry.get("type", ""),
|
||||||
|
"size": entry.get("size", 0),
|
||||||
|
"match_type": "filename",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Limit results
|
||||||
|
matches = matches[:20]
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"results": [],
|
||||||
|
"query": query,
|
||||||
|
"message": (
|
||||||
|
f"No files matching '{query}' found in "
|
||||||
|
f"{parsed_owner}/{parsed_repo}. "
|
||||||
|
f"Note: This searches file/directory names only. "
|
||||||
|
f"For content search, read specific files with gitea_read_file."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"results": matches,
|
||||||
|
"query": query,
|
||||||
|
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||||
|
"count": len(matches),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_tree(
|
||||||
|
self,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
repo: Optional[str] = None,
|
||||||
|
branch: Optional[str] = None,
|
||||||
|
recursive: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get the directory tree of a repository.
|
||||||
|
|
||||||
|
Uses Gitea Git Trees API: GET /repos/{owner}/{repo}/git/trees/{sha}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name or "owner/repo".
|
||||||
|
branch: Branch name (default: repo default branch).
|
||||||
|
recursive: If True, get full recursive tree.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with "success" and either "entries" list or "error".
|
||||||
|
"""
|
||||||
|
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||||
|
|
||||||
|
# First, get the branch SHA (or default branch)
|
||||||
|
ref = branch or "main"
|
||||||
|
branch_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/branches/{ref}"
|
||||||
|
branch_result = await self._request("GET", branch_endpoint)
|
||||||
|
|
||||||
|
if not branch_result["success"]:
|
||||||
|
# Try "master" as fallback
|
||||||
|
if not branch and "404" in branch_result.get("error", ""):
|
||||||
|
ref = "master"
|
||||||
|
branch_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/branches/{ref}"
|
||||||
|
branch_result = await self._request("GET", branch_endpoint)
|
||||||
|
|
||||||
|
if not branch_result["success"]:
|
||||||
|
return branch_result
|
||||||
|
|
||||||
|
branch_data = branch_result["data"]
|
||||||
|
tree_sha = branch_data.get("commit", {}).get("id", "")
|
||||||
|
|
||||||
|
if not tree_sha:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Could not get tree SHA for branch '{ref}'",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the tree
|
||||||
|
tree_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/git/trees/{tree_sha}"
|
||||||
|
params = {}
|
||||||
|
if recursive:
|
||||||
|
params["recursive"] = "true"
|
||||||
|
|
||||||
|
tree_result = await self._request("GET", tree_endpoint, params=params)
|
||||||
|
|
||||||
|
if not tree_result["success"]:
|
||||||
|
return tree_result
|
||||||
|
|
||||||
|
tree_data = tree_result["data"]
|
||||||
|
raw_entries = tree_data.get("tree", [])
|
||||||
|
|
||||||
|
# Format entries
|
||||||
|
entries = []
|
||||||
|
for entry in raw_entries:
|
||||||
|
entry_type = entry.get("type", "")
|
||||||
|
# Map git object types to readable types
|
||||||
|
if entry_type == "blob":
|
||||||
|
readable_type = "file"
|
||||||
|
elif entry_type == "tree":
|
||||||
|
readable_type = "dir"
|
||||||
|
else:
|
||||||
|
readable_type = entry_type
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
"path": entry.get("path", ""),
|
||||||
|
"type": readable_type,
|
||||||
|
"size": entry.get("size", 0),
|
||||||
|
"sha": entry.get("sha", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort: directories first, then files
|
||||||
|
entries.sort(key=lambda e: (0 if e["type"] == "dir" else 1, e["path"].lower()))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"entries": entries,
|
||||||
|
"branch": ref,
|
||||||
|
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||||
|
"total": len(entries),
|
||||||
|
"truncated": tree_data.get("truncated", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton client instance (lazy-loaded)
|
||||||
|
_gitea_client: Optional[GiteaClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_gitea_client() -> Optional[GiteaClient]:
|
||||||
|
"""Get or create the singleton Gitea client.
|
||||||
|
|
||||||
|
Returns None if configuration is missing or invalid.
|
||||||
|
"""
|
||||||
|
global _gitea_client
|
||||||
|
|
||||||
|
if _gitea_client is not None:
|
||||||
|
return _gitea_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
_gitea_client = GiteaClient()
|
||||||
|
return _gitea_client
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning("[Gitea] Client not available: %s", e)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[Gitea] Failed to initialize client: %s", e)
|
||||||
|
return None
|
||||||
665
llm_interface.py
665
llm_interface.py
@@ -1,24 +1,49 @@
|
|||||||
"""LLM Interface - Claude API, GLM, and other models.
|
"""LLM Interface - Claude API, GLM, and other models.
|
||||||
|
|
||||||
Supports three modes for Claude:
|
Supports two modes for Claude:
|
||||||
1. Agent SDK (uses Pro subscription) - DEFAULT - Set USE_AGENT_SDK=true (default)
|
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Max subscription
|
||||||
|
- Set USE_AGENT_SDK=true (default)
|
||||||
|
- Model: claude-sonnet-4-5-20250929 (default for all operations)
|
||||||
|
- All tools are MCP-based (no API key needed)
|
||||||
|
- Tools registered via mcp_tools.py MCP server
|
||||||
|
- Flat-rate subscription cost
|
||||||
|
|
||||||
2. Direct API (pay-per-token) - Set USE_DIRECT_API=true
|
2. Direct API (pay-per-token) - Set USE_DIRECT_API=true
|
||||||
3. Legacy: Local Claude Code server - Set USE_CLAUDE_CODE_SERVER=true (deprecated)
|
- Model: claude-sonnet-4-5-20250929
|
||||||
|
- Requires ANTHROPIC_API_KEY in .env
|
||||||
|
- Uses traditional tool definitions from tools.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import atexit
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from anthropic import Anthropic
|
from anthropic import Anthropic
|
||||||
from anthropic.types import Message, ContentBlock, TextBlock, ToolUseBlock, Usage
|
|
||||||
|
|
||||||
from usage_tracker import UsageTracker
|
from usage_tracker import UsageTracker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
# Ensure our debug messages are visible even if root logger is at WARNING.
|
||||||
|
# Only add a handler if none exist (prevent duplicate output).
|
||||||
|
if not logger.handlers:
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(logging.Formatter(
|
||||||
|
"%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
))
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Try to import Agent SDK (optional dependency)
|
# Try to import Agent SDK (optional dependency)
|
||||||
try:
|
try:
|
||||||
from claude_agent_sdk import AgentSDK
|
from claude_agent_sdk import (
|
||||||
import anyio
|
ClaudeAgentOptions,
|
||||||
|
ResultMessage,
|
||||||
|
)
|
||||||
AGENT_SDK_AVAILABLE = True
|
AGENT_SDK_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
AGENT_SDK_AVAILABLE = False
|
AGENT_SDK_AVAILABLE = False
|
||||||
@@ -29,24 +54,61 @@ _API_KEY_ENV_VARS = {
|
|||||||
"glm": "GLM_API_KEY",
|
"glm": "GLM_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mode selection (priority order: USE_DIRECT_API > USE_CLAUDE_CODE_SERVER > default to Agent SDK)
|
# Mode selection (priority: USE_DIRECT_API > default to Agent SDK)
|
||||||
_USE_DIRECT_API = os.getenv("USE_DIRECT_API", "false").lower() == "true"
|
_USE_DIRECT_API = os.getenv("USE_DIRECT_API", "false").lower() == "true"
|
||||||
_CLAUDE_CODE_SERVER_URL = os.getenv("CLAUDE_CODE_SERVER_URL", "http://localhost:8000")
|
|
||||||
_USE_CLAUDE_CODE_SERVER = os.getenv("USE_CLAUDE_CODE_SERVER", "false").lower() == "true"
|
|
||||||
# Agent SDK is the default if available and no other mode is explicitly enabled
|
|
||||||
_USE_AGENT_SDK = os.getenv("USE_AGENT_SDK", "true").lower() == "true"
|
_USE_AGENT_SDK = os.getenv("USE_AGENT_SDK", "true").lower() == "true"
|
||||||
|
|
||||||
# Default models by provider
|
# Default models by provider
|
||||||
_DEFAULT_MODELS = {
|
_DEFAULT_MODELS = {
|
||||||
"claude": "claude-haiku-4-5-20251001", # 12x cheaper than Sonnet!
|
"claude": "claude-sonnet-4-5-20250929",
|
||||||
|
"claude_agent_sdk": "claude-sonnet-4-5-20250929",
|
||||||
"glm": "glm-4-plus",
|
"glm": "glm-4-plus",
|
||||||
}
|
}
|
||||||
|
|
||||||
_GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
_GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||||
|
|
||||||
|
# Track PIDs of claude.exe subprocesses we spawn (to avoid killing user's Claude Code session!)
|
||||||
|
_TRACKED_CLAUDE_PIDS: Set[int] = set()
|
||||||
|
_TRACKED_PIDS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_claude_subprocess(pid: int):
|
||||||
|
"""Register a claude.exe subprocess PID for cleanup on exit."""
|
||||||
|
with _TRACKED_PIDS_LOCK:
|
||||||
|
_TRACKED_CLAUDE_PIDS.add(pid)
|
||||||
|
logger.debug("[LLM] Registered claude.exe subprocess PID: %d", pid)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_tracked_claude_processes():
|
||||||
|
"""Kill only the claude.exe processes we spawned (not the user's Claude Code session!)"""
|
||||||
|
with _TRACKED_PIDS_LOCK:
|
||||||
|
if not _TRACKED_CLAUDE_PIDS:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("[LLM] Cleaning up %d tracked claude.exe subprocess(es)", len(_TRACKED_CLAUDE_PIDS))
|
||||||
|
for pid in _TRACKED_CLAUDE_PIDS:
|
||||||
|
try:
|
||||||
|
if os.name == 'nt': # Windows
|
||||||
|
subprocess.run(
|
||||||
|
['taskkill', '/F', '/PID', str(pid), '/T'],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=2
|
||||||
|
)
|
||||||
|
else: # Linux/Mac
|
||||||
|
subprocess.run(['kill', '-9', str(pid)], capture_output=True, timeout=2)
|
||||||
|
logger.debug("[LLM] Killed claude.exe subprocess PID: %d", pid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("[LLM] Failed to kill PID %d: %s", pid, e)
|
||||||
|
|
||||||
|
_TRACKED_CLAUDE_PIDS.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Register cleanup on exit (only kills our tracked subprocesses, not all claude.exe!)
|
||||||
|
atexit.register(_cleanup_tracked_claude_processes)
|
||||||
|
|
||||||
|
|
||||||
class LLMInterface:
|
class LLMInterface:
|
||||||
"""Simple LLM interface supporting Claude and GLM."""
|
"""LLM interface supporting Claude (Agent SDK or Direct API) and GLM."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -58,95 +120,181 @@ class LLMInterface:
|
|||||||
self.api_key = api_key or os.getenv(
|
self.api_key = api_key or os.getenv(
|
||||||
_API_KEY_ENV_VARS.get(provider, ""),
|
_API_KEY_ENV_VARS.get(provider, ""),
|
||||||
)
|
)
|
||||||
self.model = _DEFAULT_MODELS.get(provider, "")
|
|
||||||
self.client: Optional[Anthropic] = None
|
self.client: Optional[Anthropic] = None
|
||||||
self.agent_sdk: Optional[Any] = None
|
|
||||||
|
|
||||||
# Determine mode (priority: direct API > legacy server > agent SDK)
|
# Reference to the main asyncio event loop, set by the runtime.
|
||||||
|
# Used by Agent SDK mode to schedule async work from worker threads
|
||||||
|
# via asyncio.run_coroutine_threadsafe().
|
||||||
|
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
|
# Determine mode (priority: direct API > agent SDK)
|
||||||
if provider == "claude":
|
if provider == "claude":
|
||||||
if _USE_DIRECT_API:
|
if _USE_DIRECT_API:
|
||||||
self.mode = "direct_api"
|
self.mode = "direct_api"
|
||||||
elif _USE_CLAUDE_CODE_SERVER:
|
|
||||||
self.mode = "legacy_server"
|
|
||||||
elif _USE_AGENT_SDK and AGENT_SDK_AVAILABLE:
|
elif _USE_AGENT_SDK and AGENT_SDK_AVAILABLE:
|
||||||
self.mode = "agent_sdk"
|
self.mode = "agent_sdk"
|
||||||
else:
|
else:
|
||||||
# Fallback to direct API if Agent SDK not available
|
|
||||||
self.mode = "direct_api"
|
self.mode = "direct_api"
|
||||||
if _USE_AGENT_SDK and not AGENT_SDK_AVAILABLE:
|
if _USE_AGENT_SDK and not AGENT_SDK_AVAILABLE:
|
||||||
print("[LLM] Warning: Agent SDK not available, falling back to Direct API")
|
print("[LLM] Warning: Agent SDK not available, falling back to Direct API")
|
||||||
print("[LLM] Install with: pip install claude-agent-sdk")
|
print("[LLM] Install with: pip install claude-agent-sdk")
|
||||||
else:
|
else:
|
||||||
self.mode = "direct_api" # Non-Claude providers use direct API
|
self.mode = "direct_api"
|
||||||
|
|
||||||
# Usage tracking (disabled when using Agent SDK or legacy server)
|
# Usage tracking (only for Direct API pay-per-token mode)
|
||||||
self.tracker = UsageTracker() if (track_usage and self.mode == "direct_api") else None
|
self.tracker = UsageTracker() if (track_usage and self.mode == "direct_api") else None
|
||||||
|
|
||||||
|
# Set model based on mode
|
||||||
|
if provider == "claude":
|
||||||
|
if self.mode == "agent_sdk":
|
||||||
|
self.model = _DEFAULT_MODELS.get("claude_agent_sdk", "claude-sonnet-4-5-20250929")
|
||||||
|
else:
|
||||||
|
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-5-20250929")
|
||||||
|
else:
|
||||||
|
self.model = _DEFAULT_MODELS.get(provider, "")
|
||||||
|
|
||||||
# Initialize based on mode
|
# Initialize based on mode
|
||||||
if provider == "claude":
|
if provider == "claude":
|
||||||
if self.mode == "agent_sdk":
|
if self.mode == "agent_sdk":
|
||||||
print(f"[LLM] Using Claude Agent SDK (Pro subscription)")
|
print(f"[LLM] Using Agent SDK (Max subscription) with model: {self.model}")
|
||||||
self.agent_sdk = AgentSDK()
|
|
||||||
elif self.mode == "direct_api":
|
elif self.mode == "direct_api":
|
||||||
print(f"[LLM] Using Direct API (pay-per-token)")
|
print(f"[LLM] Using Direct API (pay-per-token) with model: {self.model}")
|
||||||
self.client = Anthropic(api_key=self.api_key)
|
self.client = Anthropic(api_key=self.api_key)
|
||||||
elif self.mode == "legacy_server":
|
|
||||||
print(f"[LLM] Using Claude Code server at {_CLAUDE_CODE_SERVER_URL} (Pro subscription)")
|
def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
# Verify server is running
|
"""Store a reference to the main asyncio event loop.
|
||||||
try:
|
|
||||||
response = requests.get(f"{_CLAUDE_CODE_SERVER_URL}/", timeout=2)
|
This allows Agent SDK async calls to be scheduled back onto the
|
||||||
response.raise_for_status()
|
main event loop from worker threads (created by asyncio.to_thread).
|
||||||
print(f"[LLM] Claude Code server is running: {response.json()}")
|
Must be called from the async context that owns the loop.
|
||||||
except Exception as e:
|
"""
|
||||||
print(f"[LLM] Warning: Could not connect to Claude Code server: {e}")
|
self._event_loop = loop
|
||||||
print(f"[LLM] Note: Claude Code server mode is deprecated. Using Agent SDK instead.")
|
logger.info(
|
||||||
|
"[LLM] Event loop stored: %s (running=%s)",
|
||||||
|
type(loop).__name__,
|
||||||
|
loop.is_running(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_claude_env() -> dict:
|
||||||
|
"""Remove Claude Code session markers from the environment.
|
||||||
|
|
||||||
|
The Agent SDK's SubprocessCLITransport copies os.environ into the
|
||||||
|
child process. If the bot is launched from within a Claude Code
|
||||||
|
session (or any environment that sets CLAUDECODE), the child
|
||||||
|
``claude`` CLI detects the nesting and refuses to start with:
|
||||||
|
|
||||||
|
"Claude Code cannot be launched inside another Claude Code session."
|
||||||
|
|
||||||
|
This method temporarily removes the offending variables and returns
|
||||||
|
them so the caller can restore them afterwards.
|
||||||
|
"""
|
||||||
|
saved = {}
|
||||||
|
# Keys that signal an active Claude Code parent session.
|
||||||
|
# CLAUDE_CODE_ENTRYPOINT and CLAUDE_AGENT_SDK_VERSION are set by
|
||||||
|
# the SDK itself on the child process, so removing them from the
|
||||||
|
# parent is safe -- the SDK will set them again.
|
||||||
|
markers = [
|
||||||
|
"CLAUDECODE",
|
||||||
|
"CLAUDE_CODE_ENTRYPOINT",
|
||||||
|
"CLAUDE_AGENT_SDK_VERSION",
|
||||||
|
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
||||||
|
]
|
||||||
|
for key in markers:
|
||||||
|
if key in os.environ:
|
||||||
|
saved[key] = os.environ.pop(key)
|
||||||
|
if saved:
|
||||||
|
logger.debug("[LLM] Cleaned Claude session env vars: %s", list(saved.keys()))
|
||||||
|
return saved
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore_claude_env(saved: dict) -> None:
|
||||||
|
"""Restore previously removed Claude session env vars."""
|
||||||
|
os.environ.update(saved)
|
||||||
|
|
||||||
|
def _run_async_from_thread(self, coro) -> Any:
|
||||||
|
"""Run an async coroutine from a synchronous worker thread.
|
||||||
|
|
||||||
|
Uses asyncio.run_coroutine_threadsafe() to schedule the coroutine
|
||||||
|
on the main event loop (if available), which is the correct way to
|
||||||
|
bridge sync -> async when called from an asyncio.to_thread() worker
|
||||||
|
or from any background thread (e.g., the scheduler).
|
||||||
|
|
||||||
|
Falls back to asyncio.run() if no event loop reference is available
|
||||||
|
(e.g., direct script usage without the adapter runtime).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coro: An already-created coroutine object (not a coroutine function).
|
||||||
|
"""
|
||||||
|
current_thread = threading.current_thread().name
|
||||||
|
has_loop = self._event_loop is not None
|
||||||
|
loop_running = has_loop and self._event_loop.is_running()
|
||||||
|
|
||||||
|
if has_loop and loop_running:
|
||||||
|
logger.info(
|
||||||
|
"[LLM] _run_async_from_thread: using run_coroutine_threadsafe "
|
||||||
|
"(thread=%s, loop=%s)",
|
||||||
|
current_thread,
|
||||||
|
type(self._event_loop).__name__,
|
||||||
|
)
|
||||||
|
# Schedule on the main event loop and block this thread until done.
|
||||||
|
# This works because:
|
||||||
|
# 1. asyncio.to_thread() runs us in a thread pool while the main
|
||||||
|
# loop continues processing other tasks.
|
||||||
|
# 2. Scheduler threads are plain daemon threads, also not blocking
|
||||||
|
# the main loop.
|
||||||
|
# The coroutine executes on the main loop without deadlocking
|
||||||
|
# because the main loop is free to run while we block here.
|
||||||
|
future = asyncio.run_coroutine_threadsafe(coro, self._event_loop)
|
||||||
|
try:
|
||||||
|
# Block with 10-minute timeout to prevent hangs
|
||||||
|
# Complex tasks (repo analysis, multi-step operations) can take 5-8 minutes
|
||||||
|
logger.info("[LLM] Waiting for Agent SDK response (timeout: 600s)...")
|
||||||
|
result = future.result(timeout=600)
|
||||||
|
logger.info("[LLM] Agent SDK response received successfully")
|
||||||
|
return result
|
||||||
|
except TimeoutError:
|
||||||
|
logger.error("[LLM] ⚠️ Agent SDK call TIMED OUT after 600 seconds!")
|
||||||
|
future.cancel() # Cancel the coroutine
|
||||||
|
raise TimeoutError("Agent SDK call exceeded 10 minute timeout - task may be too complex")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"[LLM] _run_async_from_thread: using asyncio.run() fallback "
|
||||||
|
"(thread=%s, has_loop=%s, loop_running=%s)",
|
||||||
|
current_thread,
|
||||||
|
has_loop,
|
||||||
|
loop_running,
|
||||||
|
)
|
||||||
|
# Fallback: no main loop available (standalone / test usage).
|
||||||
|
# Create a new event loop in this thread via asyncio.run().
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
def chat(
|
def chat(
|
||||||
self,
|
self,
|
||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
system: Optional[str] = None,
|
system: Optional[str] = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 16384,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Send chat request and get response.
|
"""Send chat request and get response.
|
||||||
|
|
||||||
|
In Agent SDK mode, this uses query() which handles MCP tools automatically.
|
||||||
|
In Direct API mode, this is a simple messages.create() call without tools.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: If the API call fails or returns an unexpected response.
|
Exception: If the API call fails or returns an unexpected response.
|
||||||
"""
|
"""
|
||||||
if self.provider == "claude":
|
if self.provider == "claude":
|
||||||
# Agent SDK mode (Pro subscription)
|
|
||||||
if self.mode == "agent_sdk":
|
if self.mode == "agent_sdk":
|
||||||
try:
|
try:
|
||||||
# Use anyio to bridge async SDK to sync interface
|
logger.info("[LLM] chat: dispatching via Agent SDK")
|
||||||
response = anyio.from_thread.run(
|
response = self._run_async_from_thread(
|
||||||
self._agent_sdk_chat,
|
self._agent_sdk_chat(messages, system, max_tokens)
|
||||||
messages,
|
|
||||||
system,
|
|
||||||
max_tokens
|
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error("[LLM] Agent SDK error in chat(): %s", e, exc_info=True)
|
||||||
raise Exception(f"Agent SDK error: {e}")
|
raise Exception(f"Agent SDK error: {e}")
|
||||||
|
|
||||||
# Legacy Claude Code server (Pro subscription)
|
|
||||||
elif self.mode == "legacy_server":
|
|
||||||
try:
|
|
||||||
payload = {
|
|
||||||
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
|
||||||
"system": system,
|
|
||||||
"max_tokens": max_tokens
|
|
||||||
}
|
|
||||||
response = requests.post(
|
|
||||||
f"{_CLAUDE_CODE_SERVER_URL}/v1/chat",
|
|
||||||
json=payload,
|
|
||||||
timeout=120
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
return data.get("content", "")
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Claude Code server error: {e}")
|
|
||||||
|
|
||||||
# Direct API (pay-per-token)
|
|
||||||
elif self.mode == "direct_api":
|
elif self.mode == "direct_api":
|
||||||
response = self.client.messages.create(
|
response = self.client.messages.create(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
@@ -155,7 +303,6 @@ class LLMInterface:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Track usage
|
|
||||||
if self.tracker and hasattr(response, "usage"):
|
if self.tracker and hasattr(response, "usage"):
|
||||||
self.tracker.track(
|
self.tracker.track(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
@@ -191,173 +338,308 @@ class LLMInterface:
|
|||||||
|
|
||||||
raise ValueError(f"Unsupported provider: {self.provider}")
|
raise ValueError(f"Unsupported provider: {self.provider}")
|
||||||
|
|
||||||
|
def _build_agent_sdk_options(self) -> Optional['ClaudeAgentOptions']:
|
||||||
|
"""Build Agent SDK options with MCP servers and allowed tools.
|
||||||
|
|
||||||
|
Returns configured ClaudeAgentOptions, or None if mcp_tools is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from mcp_tools import file_system_server
|
||||||
|
|
||||||
|
mcp_servers = {"file_system": file_system_server}
|
||||||
|
|
||||||
|
# All tools registered in the MCP server
|
||||||
|
allowed_tools = [
|
||||||
|
# File and system tools
|
||||||
|
"read_file",
|
||||||
|
"write_file",
|
||||||
|
"edit_file",
|
||||||
|
"list_directory",
|
||||||
|
"run_command",
|
||||||
|
# Web tool
|
||||||
|
"web_fetch",
|
||||||
|
# Zettelkasten tools
|
||||||
|
"fleeting_note",
|
||||||
|
"daily_note",
|
||||||
|
"literature_note",
|
||||||
|
"permanent_note",
|
||||||
|
"search_vault",
|
||||||
|
"search_by_tags",
|
||||||
|
# Google tools (Gmail, Calendar, Contacts)
|
||||||
|
"get_weather",
|
||||||
|
"send_email",
|
||||||
|
"read_emails",
|
||||||
|
"get_email",
|
||||||
|
"read_calendar",
|
||||||
|
"create_calendar_event",
|
||||||
|
"search_calendar",
|
||||||
|
"create_contact",
|
||||||
|
"list_contacts",
|
||||||
|
"get_contact",
|
||||||
|
# Gitea tools (private repo access)
|
||||||
|
"gitea_read_file",
|
||||||
|
"gitea_list_files",
|
||||||
|
"gitea_search_code",
|
||||||
|
"gitea_get_tree",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Conditionally add Obsidian MCP server
|
||||||
|
try:
|
||||||
|
from obsidian_mcp import (
|
||||||
|
is_obsidian_enabled,
|
||||||
|
check_obsidian_health,
|
||||||
|
get_obsidian_server_config,
|
||||||
|
OBSIDIAN_TOOLS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_obsidian_enabled() and check_obsidian_health():
|
||||||
|
obsidian_config = get_obsidian_server_config()
|
||||||
|
mcp_servers["obsidian"] = obsidian_config
|
||||||
|
allowed_tools.extend(OBSIDIAN_TOOLS)
|
||||||
|
print("[LLM] Obsidian MCP server registered (8 tools)")
|
||||||
|
elif is_obsidian_enabled():
|
||||||
|
print("[LLM] Obsidian MCP enabled but health check failed")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLM] Obsidian MCP unavailable: {e}")
|
||||||
|
|
||||||
|
def _stderr_callback(line: str) -> None:
|
||||||
|
"""Log Claude CLI stderr for debugging transport failures."""
|
||||||
|
logger.debug("[CLI stderr] %s", line)
|
||||||
|
|
||||||
|
return ClaudeAgentOptions(
|
||||||
|
mcp_servers=mcp_servers,
|
||||||
|
allowed_tools=allowed_tools,
|
||||||
|
permission_mode="bypassPermissions",
|
||||||
|
max_turns=30, # Prevent infinite tool loops (matches MAX_TOOL_ITERATIONS)
|
||||||
|
stderr=_stderr_callback,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
print("[LLM] Warning: mcp_tools not available, no MCP tools will be registered")
|
||||||
|
return None
|
||||||
|
|
||||||
async def _agent_sdk_chat(
|
async def _agent_sdk_chat(
|
||||||
self,
|
self,
|
||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
system: Optional[str],
|
system: Optional[str],
|
||||||
max_tokens: int
|
max_tokens: int
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Internal async method for Agent SDK chat (called via anyio bridge)."""
|
"""Agent SDK chat via custom transport flow.
|
||||||
response = await self.agent_sdk.chat(
|
|
||||||
messages=messages,
|
|
||||||
system=system,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
model=self.model
|
|
||||||
)
|
|
||||||
# Extract text from response
|
|
||||||
if isinstance(response, dict):
|
|
||||||
return response.get("content", "")
|
|
||||||
return str(response)
|
|
||||||
|
|
||||||
async def _agent_sdk_chat_with_tools(
|
Uses the SDK's transport and query layers directly instead of the
|
||||||
|
high-level ``query()`` helper. This works around a bug in
|
||||||
|
``claude_agent_sdk._internal.client.process_query`` where
|
||||||
|
``end_input()`` is called immediately after sending the user message
|
||||||
|
for string prompts. That premature stdin close kills the
|
||||||
|
bidirectional control channel that SDK MCP servers need to handle
|
||||||
|
``tools/list`` and ``tools/call`` requests from the CLI subprocess,
|
||||||
|
resulting in ``CLIConnectionError: ProcessTransport is not ready for
|
||||||
|
writing``.
|
||||||
|
|
||||||
|
Our fix: defer ``end_input()`` until after the first ``ResultMessage``
|
||||||
|
is received, matching the logic already present in
|
||||||
|
``Query.stream_input()`` for async-iterable prompts.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
# Lazy imports from SDK internals.
|
||||||
|
from claude_agent_sdk._internal.transport.subprocess_cli import (
|
||||||
|
SubprocessCLITransport,
|
||||||
|
)
|
||||||
|
from claude_agent_sdk._internal.query import Query
|
||||||
|
from claude_agent_sdk._internal.message_parser import parse_message
|
||||||
|
|
||||||
|
# Build the prompt from the system prompt and conversation history.
|
||||||
|
prompt = self._build_sdk_prompt(messages, system)
|
||||||
|
options = self._build_agent_sdk_options()
|
||||||
|
|
||||||
|
# Clean Claude session env vars so the child CLI process doesn't
|
||||||
|
# detect a "nested session" and refuse to start.
|
||||||
|
saved_env = self._clean_claude_env()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# --- 1. Create and connect the subprocess transport. ---
|
||||||
|
transport = SubprocessCLITransport(prompt=prompt, options=options)
|
||||||
|
await transport.connect()
|
||||||
|
|
||||||
|
# Track the subprocess PID for cleanup on exit
|
||||||
|
if hasattr(transport, '_process') and transport._process:
|
||||||
|
_register_claude_subprocess(transport._process.pid)
|
||||||
|
|
||||||
|
# --- 2. Extract in-process SDK MCP server instances. ---
|
||||||
|
sdk_mcp_servers: Dict = {}
|
||||||
|
if options.mcp_servers and isinstance(options.mcp_servers, dict):
|
||||||
|
for name, config in options.mcp_servers.items():
|
||||||
|
if isinstance(config, dict) and config.get("type") == "sdk":
|
||||||
|
sdk_mcp_servers[name] = config["instance"]
|
||||||
|
|
||||||
|
# --- 3. Create the Query object (control-protocol handler). ---
|
||||||
|
query_obj = Query(
|
||||||
|
transport=transport,
|
||||||
|
is_streaming_mode=True,
|
||||||
|
sdk_mcp_servers=sdk_mcp_servers,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start the background reader task.
|
||||||
|
await query_obj.start()
|
||||||
|
|
||||||
|
# Perform the initialize handshake with the CLI.
|
||||||
|
await query_obj.initialize()
|
||||||
|
|
||||||
|
# Send the user message over stdin.
|
||||||
|
user_msg = {
|
||||||
|
"type": "user",
|
||||||
|
"session_id": "",
|
||||||
|
"message": {"role": "user", "content": prompt},
|
||||||
|
"parent_tool_use_id": None,
|
||||||
|
}
|
||||||
|
await transport.write(_json.dumps(user_msg) + "\n")
|
||||||
|
|
||||||
|
# **KEY FIX**: Do NOT call end_input() yet. The CLI will
|
||||||
|
# send MCP control requests (tools/list, tools/call) over
|
||||||
|
# the bidirectional channel. Closing stdin now would
|
||||||
|
# prevent us from writing responses back. We wait for the
|
||||||
|
# first ResultMessage instead.
|
||||||
|
|
||||||
|
# --- 4. Consume messages until we get a ResultMessage. ---
|
||||||
|
result_text = ""
|
||||||
|
message_count = 0
|
||||||
|
async for data in query_obj.receive_messages():
|
||||||
|
message = parse_message(data)
|
||||||
|
message_count += 1
|
||||||
|
|
||||||
|
# Log all message types for debugging hangs
|
||||||
|
message_type = type(message).__name__
|
||||||
|
logger.debug(f"[LLM] Received message #{message_count}: {message_type}")
|
||||||
|
|
||||||
|
if isinstance(message, ResultMessage):
|
||||||
|
result_text = message.result or ""
|
||||||
|
logger.info(
|
||||||
|
"[LLM] Agent SDK result received after %d messages: cost=$%.4f, turns=%s",
|
||||||
|
message_count,
|
||||||
|
getattr(message, "total_cost_usd", 0),
|
||||||
|
getattr(message, "num_turns", "?"),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Log non-result messages to detect loops
|
||||||
|
if message_count % 10 == 0:
|
||||||
|
logger.warning(f"[LLM] Still waiting for ResultMessage after {message_count} messages...")
|
||||||
|
|
||||||
|
# Now that we have the result, close stdin gracefully.
|
||||||
|
try:
|
||||||
|
await transport.end_input()
|
||||||
|
except Exception:
|
||||||
|
pass # Process may have already exited; that's fine.
|
||||||
|
|
||||||
|
return result_text
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Always clean up the query/transport.
|
||||||
|
try:
|
||||||
|
await query_obj.close()
|
||||||
|
except Exception:
|
||||||
|
# Suppress errors during cleanup (e.g. if process
|
||||||
|
# already exited and there are pending control
|
||||||
|
# request tasks that can't write back).
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Always restore env vars, even on error.
|
||||||
|
self._restore_claude_env(saved_env)
|
||||||
|
|
||||||
|
def _build_sdk_prompt(
|
||||||
self,
|
self,
|
||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
tools: List[Dict[str, Any]],
|
|
||||||
system: Optional[str],
|
system: Optional[str],
|
||||||
max_tokens: int
|
) -> str:
|
||||||
) -> Message:
|
"""Build a prompt string for the Agent SDK query() from conversation history.
|
||||||
"""Internal async method for Agent SDK chat with tools (called via anyio bridge)."""
|
|
||||||
response = await self.agent_sdk.chat(
|
|
||||||
messages=messages,
|
|
||||||
tools=tools,
|
|
||||||
system=system,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
model=self.model
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert Agent SDK response to anthropic.types.Message format
|
The SDK expects a single prompt string. We combine the system prompt
|
||||||
return self._convert_sdk_response_to_message(response)
|
and conversation history into a coherent prompt.
|
||||||
|
|
||||||
def _convert_sdk_response_to_message(self, sdk_response: Dict[str, Any]) -> Message:
|
|
||||||
"""Convert Agent SDK response to anthropic.types.Message format.
|
|
||||||
|
|
||||||
This ensures compatibility with agent.py's existing tool loop.
|
|
||||||
"""
|
"""
|
||||||
# Extract content blocks
|
parts = []
|
||||||
content_blocks = []
|
|
||||||
raw_content = sdk_response.get("content", [])
|
|
||||||
|
|
||||||
if isinstance(raw_content, str):
|
if system:
|
||||||
# Simple text response
|
parts.append(f"<system>\n{system}\n</system>\n")
|
||||||
content_blocks = [TextBlock(type="text", text=raw_content)]
|
|
||||||
elif isinstance(raw_content, list):
|
|
||||||
# List of content blocks
|
|
||||||
for block in raw_content:
|
|
||||||
if isinstance(block, dict):
|
|
||||||
if block.get("type") == "text":
|
|
||||||
content_blocks.append(TextBlock(
|
|
||||||
type="text",
|
|
||||||
text=block.get("text", "")
|
|
||||||
))
|
|
||||||
elif block.get("type") == "tool_use":
|
|
||||||
content_blocks.append(ToolUseBlock(
|
|
||||||
type="tool_use",
|
|
||||||
id=block.get("id", ""),
|
|
||||||
name=block.get("name", ""),
|
|
||||||
input=block.get("input", {})
|
|
||||||
))
|
|
||||||
|
|
||||||
# Extract usage information
|
# Include recent conversation history for context
|
||||||
usage_data = sdk_response.get("usage", {})
|
for msg in messages:
|
||||||
usage = Usage(
|
content = msg.get("content", "")
|
||||||
input_tokens=usage_data.get("input_tokens", 0),
|
role = msg["role"]
|
||||||
output_tokens=usage_data.get("output_tokens", 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create Message object
|
if isinstance(content, str):
|
||||||
# Note: We create a minimal Message-compatible object
|
if role == "user":
|
||||||
# The Message class from anthropic.types is read-only, so we create a mock
|
parts.append(f"User: {content}")
|
||||||
# Capture self.model before defining inner class
|
elif role == "assistant":
|
||||||
model_name = sdk_response.get("model", self.model)
|
parts.append(f"Assistant: {content}")
|
||||||
|
elif isinstance(content, list):
|
||||||
|
# Structured content (tool_use/tool_result blocks from Direct API history)
|
||||||
|
text_parts = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict):
|
||||||
|
if block.get("type") == "text":
|
||||||
|
text_parts.append(block.get("text", ""))
|
||||||
|
elif block.get("type") == "tool_result":
|
||||||
|
text_parts.append(f"[Tool result]: {block.get('content', '')}")
|
||||||
|
elif block.get("type") == "tool_use":
|
||||||
|
text_parts.append(f"[Used tool: {block.get('name', 'unknown')}]")
|
||||||
|
elif hasattr(block, "type"):
|
||||||
|
if block.type == "text":
|
||||||
|
text_parts.append(block.text)
|
||||||
|
if text_parts:
|
||||||
|
if role == "user":
|
||||||
|
parts.append(f"User: {' '.join(text_parts)}")
|
||||||
|
elif role == "assistant":
|
||||||
|
parts.append(f"Assistant: {' '.join(text_parts)}")
|
||||||
|
|
||||||
class MessageLike:
|
return "\n\n".join(parts)
|
||||||
def __init__(self, content, stop_reason, usage, model):
|
|
||||||
self.content = content
|
|
||||||
self.stop_reason = stop_reason
|
|
||||||
self.usage = usage
|
|
||||||
self.id = sdk_response.get("id", "sdk_message")
|
|
||||||
self.model = model
|
|
||||||
self.role = "assistant"
|
|
||||||
self.type = "message"
|
|
||||||
|
|
||||||
return MessageLike(
|
|
||||||
content=content_blocks,
|
|
||||||
stop_reason=sdk_response.get("stop_reason", "end_turn"),
|
|
||||||
usage=usage,
|
|
||||||
model=model_name
|
|
||||||
)
|
|
||||||
|
|
||||||
def chat_with_tools(
|
def chat_with_tools(
|
||||||
self,
|
self,
|
||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
tools: List[Dict[str, Any]],
|
tools: List[Dict[str, Any]],
|
||||||
system: Optional[str] = None,
|
system: Optional[str] = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 16384,
|
||||||
use_cache: bool = False,
|
use_cache: bool = False,
|
||||||
) -> Message:
|
) -> Any:
|
||||||
"""Send chat request with tool support. Returns full Message object.
|
"""Send chat request with tool support.
|
||||||
|
|
||||||
|
In Agent SDK mode: Uses query() with MCP tools. The SDK handles tool
|
||||||
|
execution automatically. Returns a string (final response after all
|
||||||
|
tool calls are resolved).
|
||||||
|
|
||||||
|
In Direct API mode: Returns an anthropic Message object with potential
|
||||||
|
tool_use blocks that agent.py processes in a manual loop.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
use_cache: Enable prompt caching for Sonnet models (saves 90% on repeated context)
|
tools: Tool definitions (used by Direct API; ignored in Agent SDK mode
|
||||||
|
since tools are registered via MCP servers).
|
||||||
|
use_cache: Enable prompt caching for Sonnet (Direct API only).
|
||||||
"""
|
"""
|
||||||
if self.provider != "claude":
|
if self.provider != "claude":
|
||||||
raise ValueError("Tool use only supported for Claude provider")
|
raise ValueError("Tool use only supported for Claude provider")
|
||||||
|
|
||||||
# Agent SDK mode (Pro subscription)
|
|
||||||
if self.mode == "agent_sdk":
|
if self.mode == "agent_sdk":
|
||||||
|
# Agent SDK handles tool calls automatically via MCP servers.
|
||||||
|
# We use the same query() path as chat(), since MCP tools are
|
||||||
|
# already registered. The SDK will invoke tools, collect results,
|
||||||
|
# and return the final text response.
|
||||||
try:
|
try:
|
||||||
# Use anyio to bridge async SDK to sync interface
|
logger.info("[LLM] chat_with_tools: dispatching via Agent SDK")
|
||||||
response = anyio.from_thread.run(
|
response = self._run_async_from_thread(
|
||||||
self._agent_sdk_chat_with_tools,
|
self._agent_sdk_chat(messages, system, max_tokens)
|
||||||
messages,
|
|
||||||
tools,
|
|
||||||
system,
|
|
||||||
max_tokens
|
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error("[LLM] Agent SDK error: %s", e, exc_info=True)
|
||||||
raise Exception(f"Agent SDK error: {e}")
|
raise Exception(f"Agent SDK error: {e}")
|
||||||
|
|
||||||
# Legacy Claude Code server (Pro subscription)
|
|
||||||
elif self.mode == "legacy_server":
|
|
||||||
try:
|
|
||||||
payload = {
|
|
||||||
"messages": messages,
|
|
||||||
"tools": tools,
|
|
||||||
"system": system,
|
|
||||||
"max_tokens": max_tokens
|
|
||||||
}
|
|
||||||
response = requests.post(
|
|
||||||
f"{_CLAUDE_CODE_SERVER_URL}/v1/chat/tools",
|
|
||||||
json=payload,
|
|
||||||
timeout=120
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
# Convert response to Message-like object
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Create a mock Message object with the response
|
|
||||||
class MockMessage:
|
|
||||||
def __init__(self, data):
|
|
||||||
self.content = data.get("content", [])
|
|
||||||
self.stop_reason = data.get("stop_reason", "end_turn")
|
|
||||||
self.usage = type('obj', (object,), {
|
|
||||||
'input_tokens': data.get("usage", {}).get("input_tokens", 0),
|
|
||||||
'output_tokens': data.get("usage", {}).get("output_tokens", 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
return MockMessage(data)
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Claude Code server error: {e}")
|
|
||||||
|
|
||||||
# Direct API (pay-per-token)
|
|
||||||
elif self.mode == "direct_api":
|
elif self.mode == "direct_api":
|
||||||
# Enable caching only for Sonnet models (not worth it for Haiku)
|
|
||||||
enable_caching = use_cache and "sonnet" in self.model.lower()
|
enable_caching = use_cache and "sonnet" in self.model.lower()
|
||||||
|
|
||||||
# Structure system prompt for optimal caching
|
|
||||||
if enable_caching and system:
|
if enable_caching and system:
|
||||||
# Convert string to list format with cache control
|
|
||||||
system_blocks = [
|
system_blocks = [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -376,7 +658,6 @@ class LLMInterface:
|
|||||||
tools=tools,
|
tools=tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Track usage
|
|
||||||
if self.tracker and hasattr(response, "usage"):
|
if self.tracker and hasattr(response, "usage"):
|
||||||
self.tracker.track(
|
self.tracker.track(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
|
|||||||
203
logging_config.py
Normal file
203
logging_config.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Structured logging configuration for Ajarbot.
|
||||||
|
|
||||||
|
Provides consistent logging across all components with:
|
||||||
|
- Rotating file logs (prevents disk space issues)
|
||||||
|
- Separate error log for quick issue identification
|
||||||
|
- JSON-structured logs for easy parsing
|
||||||
|
- Console output for development
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
# Log directory
|
||||||
|
LOG_DIR = Path("logs")
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Log file paths
|
||||||
|
MAIN_LOG = LOG_DIR / "ajarbot.log"
|
||||||
|
ERROR_LOG = LOG_DIR / "errors.log"
|
||||||
|
TOOL_LOG = LOG_DIR / "tools.log"
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
"""Format log records as JSON for easy parsing."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
"""Format log record as JSON."""
|
||||||
|
log_data = {
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"module": record.module,
|
||||||
|
"function": record.funcName,
|
||||||
|
"line": record.lineno,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add exception info if present
|
||||||
|
if record.exc_info:
|
||||||
|
log_data["exception"] = self.formatException(record.exc_info)
|
||||||
|
|
||||||
|
# Add extra fields if present
|
||||||
|
if hasattr(record, "extra_data"):
|
||||||
|
log_data["extra"] = record.extra_data
|
||||||
|
|
||||||
|
return json.dumps(log_data)
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredLogger:
|
||||||
|
"""Wrapper for structured logging with context."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""Initialize structured logger."""
|
||||||
|
self.logger = logging.getLogger(name)
|
||||||
|
self._setup_handlers()
|
||||||
|
|
||||||
|
def _setup_handlers(self):
|
||||||
|
"""Set up log handlers if not already configured."""
|
||||||
|
if self.logger.handlers:
|
||||||
|
return # Already configured
|
||||||
|
|
||||||
|
self.logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Console handler (human-readable)
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_formatter = logging.Formatter(
|
||||||
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
|
||||||
|
# Main log file handler (JSON, rotating)
|
||||||
|
main_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
MAIN_LOG,
|
||||||
|
maxBytes=10 * 1024 * 1024, # 10MB
|
||||||
|
backupCount=5,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
main_handler.setLevel(logging.DEBUG)
|
||||||
|
main_handler.setFormatter(JSONFormatter())
|
||||||
|
|
||||||
|
# Error log file handler (JSON, errors only)
|
||||||
|
error_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
ERROR_LOG,
|
||||||
|
maxBytes=5 * 1024 * 1024, # 5MB
|
||||||
|
backupCount=3,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
error_handler.setLevel(logging.ERROR)
|
||||||
|
error_handler.setFormatter(JSONFormatter())
|
||||||
|
|
||||||
|
# Add handlers
|
||||||
|
self.logger.addHandler(console_handler)
|
||||||
|
self.logger.addHandler(main_handler)
|
||||||
|
self.logger.addHandler(error_handler)
|
||||||
|
|
||||||
|
def log(self, level: int, message: str, extra: Optional[Dict[str, Any]] = None):
|
||||||
|
"""Log message with optional extra context."""
|
||||||
|
if extra:
|
||||||
|
self.logger.log(level, message, extra={"extra_data": extra})
|
||||||
|
else:
|
||||||
|
self.logger.log(level, message)
|
||||||
|
|
||||||
|
def debug(self, message: str, **kwargs):
|
||||||
|
"""Log debug message."""
|
||||||
|
self.log(logging.DEBUG, message, kwargs if kwargs else None)
|
||||||
|
|
||||||
|
def info(self, message: str, **kwargs):
|
||||||
|
"""Log info message."""
|
||||||
|
self.log(logging.INFO, message, kwargs if kwargs else None)
|
||||||
|
|
||||||
|
def warning(self, message: str, **kwargs):
|
||||||
|
"""Log warning message."""
|
||||||
|
self.log(logging.WARNING, message, kwargs if kwargs else None)
|
||||||
|
|
||||||
|
def error(self, message: str, exc_info: bool = False, **kwargs):
|
||||||
|
"""Log error message."""
|
||||||
|
self.logger.error(
|
||||||
|
message,
|
||||||
|
exc_info=exc_info,
|
||||||
|
extra={"extra_data": kwargs} if kwargs else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def critical(self, message: str, exc_info: bool = False, **kwargs):
|
||||||
|
"""Log critical message."""
|
||||||
|
self.logger.critical(
|
||||||
|
message,
|
||||||
|
exc_info=exc_info,
|
||||||
|
extra={"extra_data": kwargs} if kwargs else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolLogger(StructuredLogger):
|
||||||
|
"""Specialized logger for tool execution tracking."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize tool logger with separate log file."""
|
||||||
|
super().__init__("tools")
|
||||||
|
|
||||||
|
# Add specialized tool log handler
|
||||||
|
tool_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
TOOL_LOG,
|
||||||
|
maxBytes=10 * 1024 * 1024, # 10MB
|
||||||
|
backupCount=3,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
tool_handler.setLevel(logging.INFO)
|
||||||
|
tool_handler.setFormatter(JSONFormatter())
|
||||||
|
self.logger.addHandler(tool_handler)
|
||||||
|
|
||||||
|
def log_tool_call(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
inputs: Dict[str, Any],
|
||||||
|
success: bool,
|
||||||
|
result: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
duration_ms: Optional[float] = None
|
||||||
|
):
|
||||||
|
"""Log tool execution with structured data."""
|
||||||
|
log_data = {
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"inputs": inputs,
|
||||||
|
"success": success,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
if success:
|
||||||
|
log_data["result_length"] = len(result) if result else 0
|
||||||
|
self.info(f"Tool executed: {tool_name}", **log_data)
|
||||||
|
else:
|
||||||
|
log_data["error"] = error
|
||||||
|
self.error(f"Tool failed: {tool_name}", **log_data)
|
||||||
|
|
||||||
|
|
||||||
|
# Global logger instances
|
||||||
|
def get_logger(name: str) -> StructuredLogger:
|
||||||
|
"""Get or create a structured logger."""
|
||||||
|
return StructuredLogger(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_logger() -> ToolLogger:
|
||||||
|
"""Get the tool execution logger."""
|
||||||
|
return ToolLogger()
|
||||||
|
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.WARNING,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Suppress noisy third-party loggers
|
||||||
|
logging.getLogger("anthropic").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
1821
mcp_tools.py
Normal file
1821
mcp_tools.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -540,6 +540,44 @@ class MemorySystem:
|
|||||||
|
|
||||||
return sorted_results[:max_results]
|
return sorted_results[:max_results]
|
||||||
|
|
||||||
|
def compact_conversation(self, user_message: str, assistant_response: str, tools_used: list = None) -> str:
|
||||||
|
"""Create a compact summary of a conversation for memory storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: The user's input
|
||||||
|
assistant_response: The assistant's full response
|
||||||
|
tools_used: Optional list of tool names used (e.g., ['read_file', 'edit_file'])
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compact summary string
|
||||||
|
"""
|
||||||
|
# Extract file paths mentioned
|
||||||
|
import re
|
||||||
|
file_paths = re.findall(r'[a-zA-Z]:[\\\/][\w\\\/\-\.]+\.\w+|[\w\/\-\.]+\.(?:py|md|yaml|yml|json|txt|js|ts)', assistant_response)
|
||||||
|
file_paths = list(set(file_paths))[:5] # Limit to 5 unique paths
|
||||||
|
|
||||||
|
# Truncate long responses
|
||||||
|
if len(assistant_response) > 300:
|
||||||
|
# Try to get first complete sentence or paragraph
|
||||||
|
sentences = assistant_response.split('. ')
|
||||||
|
if sentences and len(sentences[0]) < 200:
|
||||||
|
summary = sentences[0] + '.'
|
||||||
|
else:
|
||||||
|
summary = assistant_response[:200] + '...'
|
||||||
|
else:
|
||||||
|
summary = assistant_response
|
||||||
|
|
||||||
|
# Build compact entry
|
||||||
|
compact = f"**User**: {user_message}\n**Action**: {summary}"
|
||||||
|
|
||||||
|
if tools_used:
|
||||||
|
compact += f"\n**Tools**: {', '.join(tools_used)}"
|
||||||
|
|
||||||
|
if file_paths:
|
||||||
|
compact += f"\n**Files**: {', '.join(file_paths[:3])}" # Max 3 file paths
|
||||||
|
|
||||||
|
return compact
|
||||||
|
|
||||||
def write_memory(self, content: str, daily: bool = True) -> None:
|
def write_memory(self, content: str, daily: bool = True) -> None:
|
||||||
"""Write to memory file."""
|
"""Write to memory file."""
|
||||||
if daily:
|
if daily:
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# MEMORY - Ajarbot Project Context
|
|
||||||
|
|
||||||
## Project
|
|
||||||
Multi-platform AI agent with memory, cost-optimized for personal/small team use. Supports Slack, Telegram.
|
|
||||||
|
|
||||||
## Core Stack
|
|
||||||
- **Memory**: Hybrid search (0.7 vector + 0.3 BM25), SQLite FTS5 + Markdown files
|
|
||||||
- **Embeddings**: FastEmbed all-MiniLM-L6-v2 (384-dim, local, $0)
|
|
||||||
- **LLM**: Claude (Haiku default, Sonnet w/ caching optional), GLM fallback
|
|
||||||
- **Platforms**: Slack (Socket Mode), Telegram (polling)
|
|
||||||
- **Tools**: File ops, shell commands (5 tools total)
|
|
||||||
- **Monitoring**: Pulse & Brain (92% cheaper than Heartbeat - deprecated)
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
- `agent.py` - Main agent (memory + LLM + tools)
|
|
||||||
- `memory_system.py` - SQLite FTS5 + markdown sync
|
|
||||||
- `llm_interface.py` - Claude/GLM API wrapper
|
|
||||||
- `tools.py` - read_file, write_file, edit_file, list_directory, run_command
|
|
||||||
- `bot_runner.py` - Multi-platform launcher
|
|
||||||
- `scheduled_tasks.py` - Cron-like task scheduler
|
|
||||||
|
|
||||||
## Memory Files
|
|
||||||
- `SOUL.md` - Agent personality (auto-loaded)
|
|
||||||
- `MEMORY.md` - This file (project context)
|
|
||||||
- `users/{username}.md` - Per-user preferences
|
|
||||||
- `memory/YYYY-MM-DD.md` - Daily logs
|
|
||||||
- `memory_index.db` - SQLite FTS5 index
|
|
||||||
- `vectors.usearch` - Vector embeddings for semantic search
|
|
||||||
|
|
||||||
## Cost Optimizations (2026-02-13)
|
|
||||||
**Target**: Minimize API costs while maintaining capability
|
|
||||||
|
|
||||||
### Active
|
|
||||||
- Default: Haiku 4.5 ($0.25 input/$1.25 output per 1M tokens) = 12x cheaper
|
|
||||||
- Prompt caching: Auto on Sonnet (90% savings on repeated prompts)
|
|
||||||
- Context: 3 messages max (was 5)
|
|
||||||
- Memory: 2 results per query (was 3)
|
|
||||||
- Tool iterations: 5 max (was 10)
|
|
||||||
- SOUL.md: 45 lines (was 87)
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
- `/haiku` - Switch to fast/cheap
|
|
||||||
- `/sonnet` - Switch to smart/cached
|
|
||||||
- `/status` - Show current config
|
|
||||||
|
|
||||||
### Results
|
|
||||||
- Haiku: ~$0.001/message
|
|
||||||
- Sonnet cached: ~$0.003/message (after first)
|
|
||||||
- $5 free credits = hundreds of interactions
|
|
||||||
|
|
||||||
## Search System
|
|
||||||
**IMPLEMENTED (2026-02-13)**: Hybrid semantic + keyword search
|
|
||||||
- 0.7 vector similarity + 0.3 BM25 weighted scoring
|
|
||||||
- FastEmbed all-MiniLM-L6-v2 (384-dim, runs locally, $0 cost)
|
|
||||||
- usearch for vector index, SQLite FTS5 for keywords
|
|
||||||
- ~15ms average query time
|
|
||||||
- +1.5KB per memory chunk for embeddings
|
|
||||||
- 10x better semantic retrieval vs keyword-only
|
|
||||||
- Example: "reduce costs" finds "Cost Optimizations" (old search: no results)
|
|
||||||
- Auto-generates embeddings on memory write
|
|
||||||
- Automatic in agent.chat() - no user action needed
|
|
||||||
|
|
||||||
## Recent Changes
|
|
||||||
**2026-02-13**: Hybrid search implemented
|
|
||||||
- Added FastEmbed + usearch for semantic vector search
|
|
||||||
- Upgraded from keyword-only to 0.7 vector + 0.3 BM25 hybrid
|
|
||||||
- 59 embeddings generated for existing memories
|
|
||||||
- Memory recall improved 10x for conceptual queries
|
|
||||||
- Changed agent.py line 71: search() -> search_hybrid()
|
|
||||||
- Zero cost (local embeddings, no API calls)
|
|
||||||
|
|
||||||
**2026-02-13**: Documentation cleanup
|
|
||||||
- Removed 3 redundant docs (HEARTBEAT_HOOKS, QUICK_START_PULSE, MONITORING_COMPARISON)
|
|
||||||
- Consolidated monitoring into PULSE_BRAIN.md
|
|
||||||
- Updated README for accuracy
|
|
||||||
- Sanitized repo (no API keys, user IDs committed)
|
|
||||||
|
|
||||||
**2026-02-13**: Tool system added
|
|
||||||
- Bot can read/write/edit files, run commands autonomously
|
|
||||||
- Integrated into SOUL.md instructions
|
|
||||||
|
|
||||||
**2026-02-13**: Task scheduler integrated
|
|
||||||
- Morning weather task (6am daily to Telegram user 8088983654)
|
|
||||||
- Config: `config/scheduled_tasks.yaml`
|
|
||||||
|
|
||||||
## Architecture Decisions
|
|
||||||
- SQLite not Postgres: Simpler, adequate for personal bot
|
|
||||||
- Haiku default: Cost optimization priority
|
|
||||||
- Local embeddings (FastEmbed): Zero API calls, runs on device
|
|
||||||
- Hybrid search (0.7 vector + 0.3 BM25): Best of both worlds
|
|
||||||
- Markdown + DB: Simple, fast, no external deps
|
|
||||||
- Tool use: Autonomous action without user copy/paste
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
- Platform: Windows 11 primary
|
|
||||||
- Git: https://vulcan.apophisnetworking.net/jramos/ajarbot.git
|
|
||||||
- Config: `.env` for API keys, `config/adapters.local.yaml` for tokens (both gitignored)
|
|
||||||
- Venv: Python 3.11+
|
|
||||||
79
memory_workspace/SOUL.example.md
Normal file
79
memory_workspace/SOUL.example.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# SOUL - Bot Identity & Instructions
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
- **Name**: [Your bot name]
|
||||||
|
- **Email**: [your-email@gmail.com] (your Gmail account for Gmail API)
|
||||||
|
- **Owner**: [Your name] (see users/[username].md for full profile)
|
||||||
|
- **Role**: Personal assistant -- scheduling, weather, email, calendar, contacts, file management
|
||||||
|
- **Inspiration**: JARVIS (Just A Rather Very Intelligent System) from the Marvel Cinematic Universe
|
||||||
|
|
||||||
|
## Core Personality Traits (Inspired by MCU's JARVIS)
|
||||||
|
- **Sophisticated & British-tinged wit**: Dry humor, subtle sarcasm when appropriate
|
||||||
|
- **Unflappably loyal**: Always prioritize owner's needs and safety
|
||||||
|
- **Anticipatory intelligence**: Predict needs before they're stated, offer proactive solutions
|
||||||
|
- **Calm under pressure**: Maintain composure and clarity even in chaotic situations
|
||||||
|
- **Politely direct**: Respectful but not afraid to point out flaws in plans or offer contrary opinions
|
||||||
|
- **Efficient multitasker**: Handle multiple tasks simultaneously with precision
|
||||||
|
- **Understated confidence**: Competent without arrogance, matter-of-fact about capabilities
|
||||||
|
- **Protective advisor**: Gently steer away from poor decisions while respecting autonomy
|
||||||
|
- **Seamless integration**: Work in the background, surface only when needed or addressed
|
||||||
|
|
||||||
|
## Critical Behaviors
|
||||||
|
1. **Always check the user's profile** (users/{username}.md) before answering location/preference questions
|
||||||
|
2. **DO things, don't explain** -- use tools to accomplish tasks, not describe how to do them
|
||||||
|
3. **Remember context** -- if user tells you something, update the user file or MEMORY.md
|
||||||
|
4. **Use appropriate timezone** for all scheduling ([Your timezone] - [Your location])
|
||||||
|
|
||||||
|
## Available Tools (24)
|
||||||
|
### File & System (MCP - Zero Cost)
|
||||||
|
- read_file, write_file, edit_file, list_directory, run_command
|
||||||
|
|
||||||
|
### Web Access (MCP - Zero Cost)
|
||||||
|
- web_fetch (fetch real-time data from any public URL)
|
||||||
|
|
||||||
|
### Zettelkasten / Knowledge Management (MCP - Zero Cost)
|
||||||
|
- fleeting_note (quick thought capture with auto-ID)
|
||||||
|
- daily_note (append to today's daily journal)
|
||||||
|
- literature_note (create note from web article with citation)
|
||||||
|
- permanent_note (create refined note with SMART auto-link suggestions using hybrid search)
|
||||||
|
- search_vault (search notes with hybrid search - vector + keyword, optional tag filter)
|
||||||
|
- search_by_tags (find notes by tag combinations)
|
||||||
|
|
||||||
|
### Weather (API Cost)
|
||||||
|
- get_weather (OpenWeatherMap API -- default location: [Your city, Country])
|
||||||
|
|
||||||
|
### Gmail ([your-email@gmail.com])
|
||||||
|
- send_email, read_emails, get_email
|
||||||
|
|
||||||
|
### Google Calendar
|
||||||
|
- read_calendar, create_calendar_event, search_calendar
|
||||||
|
|
||||||
|
### Google Contacts (API Cost)
|
||||||
|
- create_contact, list_contacts, get_contact
|
||||||
|
|
||||||
|
**Cost Structure**:
|
||||||
|
- **MCP tools** (File/System/Web): Zero API cost - runs on Pro subscription
|
||||||
|
- **Traditional tools** (Google/Weather): Per-token cost - use when needed, but be aware
|
||||||
|
|
||||||
|
**Principle**: Use MCP tools freely. Use traditional tools when needed for external services.
|
||||||
|
|
||||||
|
## Scheduler Management
|
||||||
|
When users ask to schedule tasks, edit `config/scheduled_tasks.yaml` directly.
|
||||||
|
Schedule formats: `hourly`, `daily HH:MM`, `weekly day HH:MM`
|
||||||
|
|
||||||
|
## Memory System
|
||||||
|
- SOUL.md: This file (identity + instructions)
|
||||||
|
- MEMORY.md: Project context and important facts
|
||||||
|
- users/{username}.md: Per-user preferences and info
|
||||||
|
- memory/YYYY-MM-DD.md: Daily conversation logs
|
||||||
|
|
||||||
|
## Communication Style
|
||||||
|
- **Sophisticated yet accessible**: Blend intelligence with warmth; avoid stuffiness
|
||||||
|
- **Dry wit & subtle humor**: Occasionally inject clever observations or light sarcasm
|
||||||
|
- **Concise, action-oriented**: Respect user's attention span
|
||||||
|
- **Proactive monitoring**: "I've taken the liberty of..." or "May I suggest..." phrasing
|
||||||
|
- **Deferential but honest**: Respectful, but willing to respectfully challenge bad ideas
|
||||||
|
- **Break tasks into small chunks**: Digestible steps with clear next actions
|
||||||
|
- **Vary language to maintain interest**: Keep interactions fresh and engaging
|
||||||
|
- **Frame suggestions as exploration opportunities**: Not obligations, but intriguing possibilities
|
||||||
|
- **Status updates without being asked**: Brief, relevant information delivered at appropriate moments
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# SOUL - Agent Identity
|
|
||||||
|
|
||||||
## Core Traits
|
|
||||||
Helpful, concise, proactive. Value clarity and user experience. Prefer simple solutions. Learn from feedback.
|
|
||||||
|
|
||||||
## Memory System
|
|
||||||
- Store facts in MEMORY.md
|
|
||||||
- Track daily activities in memory/YYYY-MM-DD.md
|
|
||||||
- Remember user preferences in users/[username].md
|
|
||||||
|
|
||||||
## Tool Powers
|
|
||||||
I can directly edit files and run commands! Available tools:
|
|
||||||
1. **read_file** - Read file contents
|
|
||||||
2. **write_file** - Create/rewrite files
|
|
||||||
3. **edit_file** - Targeted text replacement
|
|
||||||
4. **list_directory** - Explore file structure
|
|
||||||
5. **run_command** - Execute shell commands
|
|
||||||
|
|
||||||
**Key principle**: DO things, don't just explain them. If asked to schedule a task, edit the config file directly.
|
|
||||||
|
|
||||||
## Scheduler Management
|
|
||||||
|
|
||||||
When users ask to schedule tasks (e.g., "remind me at 9am"):
|
|
||||||
|
|
||||||
1. **Read** `config/scheduled_tasks.yaml` to see existing tasks
|
|
||||||
2. **Edit** the YAML to add the new task with proper formatting
|
|
||||||
3. **Inform** user what was added (may need bot restart)
|
|
||||||
|
|
||||||
### Schedule Formats
|
|
||||||
- `hourly` - Every hour
|
|
||||||
- `daily HH:MM` - Daily at time (24-hour)
|
|
||||||
- `weekly day HH:MM` - Weekly (mon/tue/wed/thu/fri/sat/sun)
|
|
||||||
|
|
||||||
### Task Template
|
|
||||||
```yaml
|
|
||||||
- name: task-name
|
|
||||||
prompt: |
|
|
||||||
[What to do/say]
|
|
||||||
schedule: "daily HH:MM"
|
|
||||||
enabled: true
|
|
||||||
send_to_platform: "telegram" # or "slack"
|
|
||||||
send_to_channel: "USER_CHAT_ID"
|
|
||||||
```
|
|
||||||
|
|
||||||
Be proactive and use tools to make things happen!
|
|
||||||
168
obsidian_mcp.py
Normal file
168
obsidian_mcp.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Obsidian MCP Server Integration.
|
||||||
|
|
||||||
|
Manages the external obsidian-mcp-server process and provides
|
||||||
|
health checking, fallback routing, and configuration loading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Default config path
|
||||||
|
_CONFIG_FILE = Path("config/obsidian_mcp.yaml")
|
||||||
|
|
||||||
|
# Cached state
|
||||||
|
_obsidian_healthy: bool = False
|
||||||
|
_last_health_check: float = 0.0
|
||||||
|
_health_lock = threading.Lock()
|
||||||
|
_config_cache: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_obsidian_config() -> Dict[str, Any]:
|
||||||
|
"""Load Obsidian MCP configuration with env var overrides."""
|
||||||
|
global _config_cache
|
||||||
|
|
||||||
|
if _config_cache is not None:
|
||||||
|
return _config_cache
|
||||||
|
|
||||||
|
config = {}
|
||||||
|
if _CONFIG_FILE.exists():
|
||||||
|
with open(_CONFIG_FILE, encoding="utf-8") as f:
|
||||||
|
config = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
obsidian = config.get("obsidian_mcp", {})
|
||||||
|
|
||||||
|
# Apply environment variable overrides
|
||||||
|
env_overrides = {
|
||||||
|
"OBSIDIAN_API_KEY": ("connection", "api_key"),
|
||||||
|
"OBSIDIAN_BASE_URL": ("connection", "base_url"),
|
||||||
|
"OBSIDIAN_MCP_ENABLED": None, # Special: top-level "enabled"
|
||||||
|
"OBSIDIAN_ROUTING_STRATEGY": ("routing", "strategy"),
|
||||||
|
"OBSIDIAN_VAULT_PATH": ("vault", "path"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for env_var, path in env_overrides.items():
|
||||||
|
value = os.getenv(env_var)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if path is None:
|
||||||
|
# Top-level key
|
||||||
|
obsidian["enabled"] = value.lower() in ("true", "1", "yes")
|
||||||
|
else:
|
||||||
|
section, key = path
|
||||||
|
obsidian.setdefault(section, {})[key] = value
|
||||||
|
|
||||||
|
_config_cache = obsidian
|
||||||
|
return obsidian
|
||||||
|
|
||||||
|
|
||||||
|
def is_obsidian_enabled() -> bool:
|
||||||
|
"""Check if Obsidian MCP integration is enabled in config."""
|
||||||
|
config = load_obsidian_config()
|
||||||
|
return config.get("enabled", False)
|
||||||
|
|
||||||
|
|
||||||
|
def check_obsidian_health(force: bool = False) -> bool:
|
||||||
|
"""Check if Obsidian REST API is reachable.
|
||||||
|
|
||||||
|
Uses cached result unless force=True or cache has expired.
|
||||||
|
Thread-safe.
|
||||||
|
"""
|
||||||
|
global _obsidian_healthy, _last_health_check
|
||||||
|
|
||||||
|
config = load_obsidian_config()
|
||||||
|
check_interval = config.get("routing", {}).get("health_check_interval", 60)
|
||||||
|
timeout = config.get("routing", {}).get("api_timeout", 10)
|
||||||
|
|
||||||
|
with _health_lock:
|
||||||
|
now = time.time()
|
||||||
|
if not force and (now - _last_health_check) < check_interval:
|
||||||
|
return _obsidian_healthy
|
||||||
|
|
||||||
|
base_url = config.get("connection", {}).get(
|
||||||
|
"base_url", "http://127.0.0.1:27123"
|
||||||
|
)
|
||||||
|
api_key = config.get("connection", {}).get("api_key", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Obsidian Local REST API health endpoint
|
||||||
|
response = httpx.get(
|
||||||
|
f"{base_url}/",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
timeout=timeout,
|
||||||
|
verify=config.get("connection", {}).get("verify_ssl", False),
|
||||||
|
)
|
||||||
|
_obsidian_healthy = response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
_obsidian_healthy = False
|
||||||
|
|
||||||
|
_last_health_check = now
|
||||||
|
return _obsidian_healthy
|
||||||
|
|
||||||
|
|
||||||
|
def get_obsidian_server_config() -> Dict[str, Any]:
|
||||||
|
"""Build the MCP server configuration for Agent SDK registration.
|
||||||
|
|
||||||
|
Returns the config dict suitable for ClaudeAgentOptions.mcp_servers.
|
||||||
|
The obsidian-mcp-server runs as a stdio subprocess.
|
||||||
|
"""
|
||||||
|
config = load_obsidian_config()
|
||||||
|
connection = config.get("connection", {})
|
||||||
|
vault = config.get("vault", {})
|
||||||
|
cache = config.get("cache", {})
|
||||||
|
logging = config.get("logging", {})
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"OBSIDIAN_API_KEY": connection.get("api_key", ""),
|
||||||
|
"OBSIDIAN_BASE_URL": connection.get(
|
||||||
|
"base_url", "http://127.0.0.1:27123"
|
||||||
|
),
|
||||||
|
"OBSIDIAN_VERIFY_SSL": str(
|
||||||
|
connection.get("verify_ssl", False)
|
||||||
|
).lower(),
|
||||||
|
"OBSIDIAN_VAULT_PATH": str(vault.get("path", "")),
|
||||||
|
"OBSIDIAN_ENABLE_CACHE": str(
|
||||||
|
cache.get("enabled", True)
|
||||||
|
).lower(),
|
||||||
|
"OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN": str(
|
||||||
|
cache.get("refresh_interval_min", 10)
|
||||||
|
),
|
||||||
|
"MCP_LOG_LEVEL": logging.get("level", "info"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["obsidian-mcp-server"],
|
||||||
|
"env": env,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_routing_strategy() -> str:
|
||||||
|
"""Get the configured tool routing strategy."""
|
||||||
|
config = load_obsidian_config()
|
||||||
|
return config.get("routing", {}).get("strategy", "obsidian_preferred")
|
||||||
|
|
||||||
|
|
||||||
|
def should_fallback_to_custom() -> bool:
|
||||||
|
"""Check if fallback to custom tools is enabled."""
|
||||||
|
config = load_obsidian_config()
|
||||||
|
return config.get("routing", {}).get("fallback_to_custom", True)
|
||||||
|
|
||||||
|
|
||||||
|
# List of all Obsidian MCP tool names
|
||||||
|
OBSIDIAN_TOOLS = [
|
||||||
|
"obsidian_read_note",
|
||||||
|
"obsidian_update_note",
|
||||||
|
"obsidian_search_replace",
|
||||||
|
"obsidian_global_search",
|
||||||
|
"obsidian_list_notes",
|
||||||
|
"obsidian_manage_frontmatter",
|
||||||
|
"obsidian_manage_tags",
|
||||||
|
"obsidian_delete_note",
|
||||||
|
]
|
||||||
@@ -28,3 +28,7 @@ google-api-python-client>=2.108.0
|
|||||||
claude-agent-sdk>=0.1.0
|
claude-agent-sdk>=0.1.0
|
||||||
anyio>=4.0.0
|
anyio>=4.0.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
|
# Web fetching dependencies
|
||||||
|
httpx>=0.27.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
|||||||
1023
scripts/collect-homelab-config.sh
Normal file
1023
scripts/collect-homelab-config.sh
Normal file
File diff suppressed because it is too large
Load Diff
416
scripts/collect-remote.sh
Normal file
416
scripts/collect-remote.sh
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Remote Homelab Collection Wrapper
|
||||||
|
# Purpose: Executes the collection script on a remote Proxmox host via SSH
|
||||||
|
# and retrieves the results back to your local machine (WSL/Linux)
|
||||||
|
#
|
||||||
|
# Usage: ./collect-remote.sh [PROXMOX_HOST] [OPTIONS]
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Color codes
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Script configuration
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
COLLECTION_SCRIPT="${SCRIPT_DIR}/collect-homelab-config.sh"
|
||||||
|
REMOTE_SCRIPT_PATH="/tmp/collect-homelab-config.sh"
|
||||||
|
LOCAL_OUTPUT_DIR="${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
# SSH configuration
|
||||||
|
SSH_USER="${SSH_USER:-root}"
|
||||||
|
SSH_PORT="${SSH_PORT:-22}"
|
||||||
|
SSH_OPTS="-o ConnectTimeout=10 -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Functions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
log() {
|
||||||
|
local level="$1"
|
||||||
|
shift
|
||||||
|
local message="$*"
|
||||||
|
|
||||||
|
case "${level}" in
|
||||||
|
INFO)
|
||||||
|
echo -e "${BLUE}[INFO]${NC} ${message}"
|
||||||
|
;;
|
||||||
|
SUCCESS)
|
||||||
|
echo -e "${GREEN}[✓]${NC} ${message}"
|
||||||
|
;;
|
||||||
|
WARN)
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} ${message}"
|
||||||
|
;;
|
||||||
|
ERROR)
|
||||||
|
echo -e "${RED}[ERROR]${NC} ${message}" >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
banner() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}${CYAN}======================================================================${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN} $1${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN}======================================================================${NC}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
${BOLD}Remote Homelab Collection Wrapper${NC}
|
||||||
|
|
||||||
|
${BOLD}USAGE:${NC}
|
||||||
|
$0 PROXMOX_HOST [OPTIONS]
|
||||||
|
|
||||||
|
${BOLD}DESCRIPTION:${NC}
|
||||||
|
Executes the homelab collection script on a remote Proxmox host via SSH,
|
||||||
|
then retrieves the results back to your local machine.
|
||||||
|
|
||||||
|
${BOLD}ARGUMENTS:${NC}
|
||||||
|
PROXMOX_HOST IP address or hostname of your Proxmox server
|
||||||
|
|
||||||
|
${BOLD}OPTIONS:${NC}
|
||||||
|
-u, --user USER SSH username (default: root)
|
||||||
|
-p, --port PORT SSH port (default: 22)
|
||||||
|
-l, --level LEVEL Collection level: basic, standard, full, paranoid
|
||||||
|
(default: standard)
|
||||||
|
-s, --sanitize OPT Sanitization: all, ips, none (default: passwords/tokens only)
|
||||||
|
-o, --output DIR Local directory to store results (default: current directory)
|
||||||
|
-k, --keep-remote Keep the export on the remote host (default: remove after download)
|
||||||
|
-v, --verbose Verbose output
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
${BOLD}ENVIRONMENT VARIABLES:${NC}
|
||||||
|
SSH_USER Default SSH username (default: root)
|
||||||
|
SSH_PORT Default SSH port (default: 22)
|
||||||
|
|
||||||
|
${BOLD}EXAMPLES:${NC}
|
||||||
|
# Basic usage
|
||||||
|
$0 192.168.1.100
|
||||||
|
|
||||||
|
# Full collection with complete sanitization
|
||||||
|
$0 192.168.1.100 --level full --sanitize all
|
||||||
|
|
||||||
|
# Custom SSH user and port
|
||||||
|
$0 proxmox.local --user admin --port 2222
|
||||||
|
|
||||||
|
# Keep results on remote host
|
||||||
|
$0 192.168.1.100 --keep-remote
|
||||||
|
|
||||||
|
# Verbose output with custom output directory
|
||||||
|
$0 192.168.1.100 -v -o ~/backups/homelab
|
||||||
|
|
||||||
|
${BOLD}PREREQUISITES:${NC}
|
||||||
|
1. SSH access to the Proxmox host
|
||||||
|
2. collect-homelab-config.sh in the same directory as this script
|
||||||
|
3. Sufficient disk space on both remote and local machines
|
||||||
|
|
||||||
|
${BOLD}WORKFLOW:${NC}
|
||||||
|
1. Copies collection script to remote Proxmox host
|
||||||
|
2. Executes the script remotely
|
||||||
|
3. Downloads the compressed archive to local machine
|
||||||
|
4. Optionally removes the remote copy
|
||||||
|
5. Extracts the archive locally
|
||||||
|
|
||||||
|
${BOLD}NOTES:${NC}
|
||||||
|
- Requires passwordless SSH or SSH key authentication (recommended)
|
||||||
|
- The script will be run as the specified SSH user (typically root)
|
||||||
|
- Remote execution output is displayed in real-time
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
check_prerequisites() {
|
||||||
|
# Check if collection script exists
|
||||||
|
if [[ ! -f "${COLLECTION_SCRIPT}" ]]; then
|
||||||
|
log ERROR "Collection script not found: ${COLLECTION_SCRIPT}"
|
||||||
|
log ERROR "Ensure collect-homelab-config.sh is in the same directory as this script"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if ssh is available
|
||||||
|
if ! command -v ssh &> /dev/null; then
|
||||||
|
log ERROR "SSH client not found. Please install openssh-client"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if scp is available
|
||||||
|
if ! command -v scp &> /dev/null; then
|
||||||
|
log ERROR "SCP not found. Please install openssh-client"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_ssh_connection() {
|
||||||
|
local host="$1"
|
||||||
|
|
||||||
|
log INFO "Testing SSH connection to ${SSH_USER}@${host}:${SSH_PORT}..."
|
||||||
|
|
||||||
|
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "exit 0" 2>/dev/null; then
|
||||||
|
log SUCCESS "SSH connection successful"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log ERROR "Cannot connect to ${SSH_USER}@${host}:${SSH_PORT}"
|
||||||
|
log ERROR "Possible issues:"
|
||||||
|
log ERROR " - Host is unreachable"
|
||||||
|
log ERROR " - SSH service is not running"
|
||||||
|
log ERROR " - Incorrect credentials"
|
||||||
|
log ERROR " - Firewall blocking connection"
|
||||||
|
log ERROR ""
|
||||||
|
log ERROR "Try manually: ssh -p ${SSH_PORT} ${SSH_USER}@${host}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_proxmox_host() {
|
||||||
|
local host="$1"
|
||||||
|
|
||||||
|
log INFO "Verifying Proxmox installation on remote host..."
|
||||||
|
|
||||||
|
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "test -f /etc/pve/.version" 2>/dev/null; then
|
||||||
|
local pve_version=$(ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "cat /etc/pve/.version" 2>/dev/null)
|
||||||
|
log SUCCESS "Confirmed Proxmox VE installation (version: ${pve_version})"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log WARN "Remote host does not appear to be a Proxmox VE server"
|
||||||
|
log WARN "Proceeding anyway, but collection may fail..."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_script() {
|
||||||
|
local host="$1"
|
||||||
|
|
||||||
|
banner "Uploading Collection Script"
|
||||||
|
|
||||||
|
log INFO "Copying collection script to ${host}..."
|
||||||
|
|
||||||
|
if scp ${SSH_OPTS} -P "${SSH_PORT}" "${COLLECTION_SCRIPT}" "${SSH_USER}@${host}:${REMOTE_SCRIPT_PATH}"; then
|
||||||
|
log SUCCESS "Script uploaded successfully"
|
||||||
|
|
||||||
|
# Make executable
|
||||||
|
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "chmod +x ${REMOTE_SCRIPT_PATH}"
|
||||||
|
log SUCCESS "Script permissions set"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log ERROR "Failed to upload script"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
execute_remote_collection() {
|
||||||
|
local host="$1"
|
||||||
|
shift
|
||||||
|
local collection_args=("$@")
|
||||||
|
|
||||||
|
banner "Executing Collection on Remote Host"
|
||||||
|
|
||||||
|
log INFO "Running collection script on ${host}..."
|
||||||
|
log INFO "Arguments: ${collection_args[*]}"
|
||||||
|
|
||||||
|
# Build the remote command
|
||||||
|
local remote_cmd="${REMOTE_SCRIPT_PATH} ${collection_args[*]}"
|
||||||
|
|
||||||
|
# Execute remotely and stream output
|
||||||
|
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "${remote_cmd}"; then
|
||||||
|
log SUCCESS "Collection completed successfully on remote host"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log ERROR "Collection failed on remote host"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
download_results() {
|
||||||
|
local host="$1"
|
||||||
|
local output_dir="$2"
|
||||||
|
|
||||||
|
banner "Downloading Results"
|
||||||
|
|
||||||
|
log INFO "Finding remote export archive..."
|
||||||
|
|
||||||
|
# Find the most recent export archive
|
||||||
|
local remote_archive=$(ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" \
|
||||||
|
"ls -t /root/homelab-export-*.tar.gz 2>/dev/null | head -1" 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -z "${remote_archive}" ]]; then
|
||||||
|
log ERROR "No export archive found on remote host"
|
||||||
|
log ERROR "Collection may have failed or compression was disabled"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log INFO "Found archive: ${remote_archive}"
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
mkdir -p "${output_dir}"
|
||||||
|
|
||||||
|
# Download the archive
|
||||||
|
local local_archive="${output_dir}/$(basename "${remote_archive}")"
|
||||||
|
|
||||||
|
log INFO "Downloading to: ${local_archive}"
|
||||||
|
|
||||||
|
if scp ${SSH_OPTS} -P "${SSH_PORT}" "${SSH_USER}@${host}:${remote_archive}" "${local_archive}"; then
|
||||||
|
log SUCCESS "Archive downloaded successfully"
|
||||||
|
|
||||||
|
# Extract the archive
|
||||||
|
log INFO "Extracting archive..."
|
||||||
|
if tar -xzf "${local_archive}" -C "${output_dir}"; then
|
||||||
|
log SUCCESS "Archive extracted to: ${output_dir}/$(basename "${local_archive}" .tar.gz)"
|
||||||
|
|
||||||
|
# Show summary
|
||||||
|
local extracted_dir="${output_dir}/$(basename "${local_archive}" .tar.gz)"
|
||||||
|
if [[ -f "${extracted_dir}/SUMMARY.md" ]]; then
|
||||||
|
echo ""
|
||||||
|
log INFO "Collection Summary:"
|
||||||
|
echo ""
|
||||||
|
head -30 "${extracted_dir}/SUMMARY.md"
|
||||||
|
echo ""
|
||||||
|
log INFO "Full summary: ${extracted_dir}/SUMMARY.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log ERROR "Failed to extract archive"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log ERROR "Failed to download archive"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_remote() {
|
||||||
|
local host="$1"
|
||||||
|
local keep_remote="$2"
|
||||||
|
|
||||||
|
if [[ "${keep_remote}" == "true" ]]; then
|
||||||
|
log INFO "Keeping export on remote host (--keep-remote specified)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
banner "Cleaning Up Remote Host"
|
||||||
|
|
||||||
|
log INFO "Removing export files from remote host..."
|
||||||
|
|
||||||
|
# Remove the script
|
||||||
|
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "rm -f ${REMOTE_SCRIPT_PATH}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove export directories and archives
|
||||||
|
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" \
|
||||||
|
"rm -rf /root/homelab-export-* 2>/dev/null" 2>/dev/null || true
|
||||||
|
|
||||||
|
log SUCCESS "Remote cleanup completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Main Execution
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
main() {
|
||||||
|
# Parse arguments
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local proxmox_host=""
|
||||||
|
local collection_level="standard"
|
||||||
|
local sanitize_option=""
|
||||||
|
local keep_remote="false"
|
||||||
|
local verbose="false"
|
||||||
|
|
||||||
|
# First argument is the host
|
||||||
|
proxmox_host="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
# Parse remaining options
|
||||||
|
local collection_args=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-u|--user)
|
||||||
|
SSH_USER="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-p|--port)
|
||||||
|
SSH_PORT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-l|--level)
|
||||||
|
collection_level="$2"
|
||||||
|
collection_args+=("--level" "$2")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-s|--sanitize)
|
||||||
|
sanitize_option="$2"
|
||||||
|
collection_args+=("--sanitize" "$2")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-o|--output)
|
||||||
|
LOCAL_OUTPUT_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-k|--keep-remote)
|
||||||
|
keep_remote="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-v|--verbose)
|
||||||
|
verbose="true"
|
||||||
|
collection_args+=("--verbose")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log ERROR "Unknown option: $1"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validate host
|
||||||
|
if [[ -z "${proxmox_host}" ]]; then
|
||||||
|
log ERROR "Proxmox host not specified"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display configuration
|
||||||
|
banner "Remote Homelab Collection"
|
||||||
|
echo -e "${BOLD}Target Host:${NC} ${proxmox_host}"
|
||||||
|
echo -e "${BOLD}SSH User:${NC} ${SSH_USER}"
|
||||||
|
echo -e "${BOLD}SSH Port:${NC} ${SSH_PORT}"
|
||||||
|
echo -e "${BOLD}Collection Level:${NC} ${collection_level}"
|
||||||
|
echo -e "${BOLD}Output Directory:${NC} ${LOCAL_OUTPUT_DIR}"
|
||||||
|
echo -e "${BOLD}Keep Remote:${NC} ${keep_remote}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Execute workflow
|
||||||
|
check_prerequisites
|
||||||
|
test_ssh_connection "${proxmox_host}" || exit 1
|
||||||
|
verify_proxmox_host "${proxmox_host}"
|
||||||
|
upload_script "${proxmox_host}" || exit 1
|
||||||
|
execute_remote_collection "${proxmox_host}" "${collection_args[@]}" || exit 1
|
||||||
|
download_results "${proxmox_host}" "${LOCAL_OUTPUT_DIR}" || exit 1
|
||||||
|
cleanup_remote "${proxmox_host}" "${keep_remote}"
|
||||||
|
|
||||||
|
banner "Collection Complete"
|
||||||
|
|
||||||
|
log SUCCESS "Homelab infrastructure export completed successfully"
|
||||||
|
log INFO "Results are available in: ${LOCAL_OUTPUT_DIR}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function
|
||||||
|
main "$@"
|
||||||
152
scripts/collection_output.txt
Normal file
152
scripts/collection_output.txt
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
=== COLLECTION OUTPUT ===
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Starting Homelab Infrastructure Collection
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[INFO] Collection Level: full
|
||||||
|
[INFO] Output Directory: /tmp/homelab-export
|
||||||
|
[INFO] Sanitization: IPs=false | Passwords=false | Tokens=false
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Creating Directory Structure
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Directory structure created at: /tmp/homelab-export
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting System Information
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected Proxmox VE version
|
||||||
|
[✓] Collected Hostname
|
||||||
|
[✓] Collected Kernel information
|
||||||
|
[✓] Collected System uptime
|
||||||
|
[✓] Collected System date/time
|
||||||
|
[✓] Collected CPU information
|
||||||
|
[✓] Collected Detailed CPU info
|
||||||
|
[✓] Collected Memory information
|
||||||
|
[✓] Collected Detailed memory info
|
||||||
|
[✓] Collected Filesystem usage
|
||||||
|
[✓] Collected Block devices
|
||||||
|
[✓] Collected LVM physical volumes
|
||||||
|
[✓] Collected LVM volume groups
|
||||||
|
[✓] Collected LVM logical volumes
|
||||||
|
[✓] Collected IP addresses
|
||||||
|
[✓] Collected Routing table
|
||||||
|
[✓] Collected Listening sockets
|
||||||
|
[✓] Collected Installed packages
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting Proxmox Configurations
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected Datacenter config
|
||||||
|
[✓] Collected Storage config
|
||||||
|
[✓] Collected User config
|
||||||
|
[✓] Collected Auth public key
|
||||||
|
[WARN] Failed to copy directory HA configuration from /etc/pve/ha
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting VM Configurations
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected VM 100 (docker-hub) config
|
||||||
|
[✓] Collected VM 101 (monitoring-docker) config
|
||||||
|
[✓] Collected VM 104 (ubuntu-dev) config
|
||||||
|
[✓] Collected VM 105 (pfSense-Firewall) config
|
||||||
|
[✓] Collected VM 106 (Ansible-Control) config
|
||||||
|
[✓] Collected VM 107 (ubuntu-docker) config
|
||||||
|
[✓] Collected VM 108 (CML) config
|
||||||
|
[✓] Collected VM 114 (haos) config
|
||||||
|
[✓] Collected VM 119 (moltbot) config
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting LXC Container Configurations
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected Container 102 (nginx) config
|
||||||
|
[✓] Collected Container 103 (netbox) config
|
||||||
|
[✓] Collected Container 112 (twingate-connector) config
|
||||||
|
[✓] Collected Container 113 (n8n
|
||||||
|
n8n
|
||||||
|
n8n) config
|
||||||
|
[✓] Collected Container 117 (test-cve-database) config
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting Network Configurations
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected Network interfaces config
|
||||||
|
[WARN] Failed to copy directory Additional interface configs from /etc/network/interfaces.d
|
||||||
|
[✓] Collected SDN configuration
|
||||||
|
[✓] Collected Hosts file
|
||||||
|
[✓] Collected DNS resolver config
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting Storage Information
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected Storage status
|
||||||
|
[✓] Collected ZFS pool status
|
||||||
|
[✓] Collected ZFS pool list
|
||||||
|
[✓] Collected ZFS datasets
|
||||||
|
[✓] Collected Samba config
|
||||||
|
[✓] Collected iSCSI initiator config
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting Backup Configurations
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected Vzdump config
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting Cluster Information
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[WARN] Failed to execute: pvecm status (Cluster status)
|
||||||
|
[WARN] Failed to execute: pvecm nodes (Cluster nodes)
|
||||||
|
[✓] Collected Cluster resources
|
||||||
|
[✓] Collected Recent tasks
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting Guest Information
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected VM list
|
||||||
|
[✓] Collected Container list
|
||||||
|
[✓] Collected All guests (JSON)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collecting Service Configurations (Advanced)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Collected Systemd services
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Generating Documentation
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Generated README.md
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Generating Summary Report
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Generated SUMMARY.md
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
Collection Complete
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[✓] Total items collected: 53
|
||||||
|
[INFO] Total items skipped: 1
|
||||||
|
[WARN] Total errors: 4
|
||||||
|
[WARN] Review /tmp/homelab-export/collection.log for details
|
||||||
|
|
||||||
|
Export Location: /tmp/homelab-export
|
||||||
|
Summary Report: /tmp/homelab-export/SUMMARY.md
|
||||||
|
Collection Log: /tmp/homelab-export/collection.log
|
||||||
|
|
||||||
|
|
||||||
|
Exit code: 0
|
||||||
53
scripts/proxmox_ssh.py
Normal file
53
scripts/proxmox_ssh.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Proxmox SSH Helper - serviceslab (192.168.2.100)
|
||||||
|
Uses paramiko for native Python SSH (no sshpass needed).
|
||||||
|
Usage: python proxmox_ssh.py "command to run"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
PROXMOX_HOST = "192.168.2.100"
|
||||||
|
PROXMOX_USER = "root"
|
||||||
|
PROXMOX_PASS = "Nbkx4mdmay1)"
|
||||||
|
PROXMOX_PORT = 22
|
||||||
|
TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(command: str) -> tuple:
|
||||||
|
"""Execute a command on the Proxmox server via SSH.
|
||||||
|
Returns (stdout, stderr, exit_code).
|
||||||
|
"""
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
hostname=PROXMOX_HOST,
|
||||||
|
port=PROXMOX_PORT,
|
||||||
|
username=PROXMOX_USER,
|
||||||
|
password=PROXMOX_PASS,
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
look_for_keys=False,
|
||||||
|
allow_agent=False,
|
||||||
|
)
|
||||||
|
stdin, stdout, stderr = client.exec_command(command, timeout=TIMEOUT)
|
||||||
|
exit_code = stdout.channel.recv_exit_status()
|
||||||
|
out = stdout.read().decode("utf-8", errors="replace")
|
||||||
|
err = stderr.read().decode("utf-8", errors="replace")
|
||||||
|
return out, err, exit_code
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python proxmox_ssh.py \"command\"")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
out, err, code = run_command(cmd)
|
||||||
|
if out:
|
||||||
|
print(out, end="")
|
||||||
|
if err:
|
||||||
|
print(err, end="", file=sys.stderr)
|
||||||
|
sys.exit(code)
|
||||||
9
scripts/proxmox_ssh.sh
Normal file
9
scripts/proxmox_ssh.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Proxmox SSH Helper - serviceslab (192.168.2.100)
|
||||||
|
# Usage: proxmox_ssh.sh "command to run"
|
||||||
|
|
||||||
|
PROXMOX_HOST="192.168.2.100"
|
||||||
|
PROXMOX_USER="root"
|
||||||
|
PROXMOX_PASS="Nbkx4mdmay1)"
|
||||||
|
|
||||||
|
sshpass -p "$PROXMOX_PASS" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "${PROXMOX_USER}@${PROXMOX_HOST}" "$1"
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
"""Test script for Agent SDK implementation.
|
|
||||||
|
|
||||||
This script tests the Agent SDK integration without running the full bot.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Ensure we're testing the Agent SDK mode
|
|
||||||
os.environ["USE_AGENT_SDK"] = "true"
|
|
||||||
os.environ["USE_DIRECT_API"] = "false"
|
|
||||||
os.environ["USE_CLAUDE_CODE_SERVER"] = "false"
|
|
||||||
|
|
||||||
def test_llm_interface_initialization():
|
|
||||||
"""Test 1: LLMInterface initialization with Agent SDK."""
|
|
||||||
print("\n=== Test 1: LLMInterface Initialization ===")
|
|
||||||
try:
|
|
||||||
from llm_interface import LLMInterface
|
|
||||||
|
|
||||||
llm = LLMInterface(provider="claude")
|
|
||||||
|
|
||||||
print(f"✓ LLMInterface created successfully")
|
|
||||||
print(f" - Provider: {llm.provider}")
|
|
||||||
print(f" - Mode: {llm.mode}")
|
|
||||||
print(f" - Model: {llm.model}")
|
|
||||||
print(f" - Agent SDK available: {llm.agent_sdk is not None}")
|
|
||||||
|
|
||||||
if llm.mode != "agent_sdk":
|
|
||||||
print(f"✗ WARNING: Expected mode 'agent_sdk', got '{llm.mode}'")
|
|
||||||
if llm.mode == "direct_api":
|
|
||||||
print(" - This likely means claude-agent-sdk is not installed")
|
|
||||||
print(" - Run: pip install claude-agent-sdk")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Test failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_simple_chat():
|
|
||||||
"""Test 2: Simple chat without tools."""
|
|
||||||
print("\n=== Test 2: Simple Chat (No Tools) ===")
|
|
||||||
try:
|
|
||||||
from llm_interface import LLMInterface
|
|
||||||
|
|
||||||
llm = LLMInterface(provider="claude")
|
|
||||||
|
|
||||||
if llm.mode != "agent_sdk":
|
|
||||||
print(f"⊘ Skipping test (mode is '{llm.mode}', not 'agent_sdk')")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print("Sending simple chat message...")
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "Say 'Hello from Agent SDK!' in exactly those words."}
|
|
||||||
]
|
|
||||||
|
|
||||||
response = llm.chat(messages, system="You are a helpful assistant.", max_tokens=100)
|
|
||||||
|
|
||||||
print(f"✓ Chat completed successfully")
|
|
||||||
print(f" - Response: {response[:100]}...")
|
|
||||||
print(f" - Response type: {type(response)}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Test failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_chat_with_tools():
|
|
||||||
"""Test 3: Chat with tools (message format compatibility)."""
|
|
||||||
print("\n=== Test 3: Chat with Tools ===")
|
|
||||||
try:
|
|
||||||
from llm_interface import LLMInterface
|
|
||||||
from tools import TOOL_DEFINITIONS
|
|
||||||
|
|
||||||
llm = LLMInterface(provider="claude")
|
|
||||||
|
|
||||||
if llm.mode != "agent_sdk":
|
|
||||||
print(f"⊘ Skipping test (mode is '{llm.mode}', not 'agent_sdk')")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print("Sending chat message with tool definitions...")
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "What is 2+2? Just respond with the number, don't use any tools."}
|
|
||||||
]
|
|
||||||
|
|
||||||
response = llm.chat_with_tools(
|
|
||||||
messages,
|
|
||||||
tools=TOOL_DEFINITIONS,
|
|
||||||
system="You are a helpful assistant.",
|
|
||||||
max_tokens=100
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"✓ Chat with tools completed successfully")
|
|
||||||
print(f" - Response type: {type(response)}")
|
|
||||||
print(f" - Has .content: {hasattr(response, 'content')}")
|
|
||||||
print(f" - Has .stop_reason: {hasattr(response, 'stop_reason')}")
|
|
||||||
print(f" - Has .usage: {hasattr(response, 'usage')}")
|
|
||||||
print(f" - Stop reason: {response.stop_reason}")
|
|
||||||
|
|
||||||
if hasattr(response, 'content') and response.content:
|
|
||||||
print(f" - Content blocks: {len(response.content)}")
|
|
||||||
for i, block in enumerate(response.content):
|
|
||||||
print(f" - Block {i}: {type(block).__name__}")
|
|
||||||
if hasattr(block, 'type'):
|
|
||||||
print(f" - Type: {block.type}")
|
|
||||||
if hasattr(block, 'text'):
|
|
||||||
print(f" - Text: {block.text[:50]}...")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Test failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_response_format_compatibility():
|
|
||||||
"""Test 4: Verify response format matches what agent.py expects."""
|
|
||||||
print("\n=== Test 4: Response Format Compatibility ===")
|
|
||||||
try:
|
|
||||||
from llm_interface import LLMInterface
|
|
||||||
from anthropic.types import TextBlock, ToolUseBlock
|
|
||||||
|
|
||||||
llm = LLMInterface(provider="claude")
|
|
||||||
|
|
||||||
if llm.mode != "agent_sdk":
|
|
||||||
print(f"⊘ Skipping test (mode is '{llm.mode}', not 'agent_sdk')")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Simulate SDK response
|
|
||||||
mock_sdk_response = {
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": "Test response"}
|
|
||||||
],
|
|
||||||
"stop_reason": "end_turn",
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 10,
|
|
||||||
"output_tokens": 5
|
|
||||||
},
|
|
||||||
"id": "test_message_id",
|
|
||||||
"model": "claude-haiku-4-5-20251001"
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Converting mock SDK response to Message format...")
|
|
||||||
message = llm._convert_sdk_response_to_message(mock_sdk_response)
|
|
||||||
|
|
||||||
print(f"✓ Conversion successful")
|
|
||||||
print(f" - Message type: {type(message).__name__}")
|
|
||||||
print(f" - Has content: {hasattr(message, 'content')}")
|
|
||||||
print(f" - Has stop_reason: {hasattr(message, 'stop_reason')}")
|
|
||||||
print(f" - Has usage: {hasattr(message, 'usage')}")
|
|
||||||
print(f" - Content[0] type: {type(message.content[0]).__name__}")
|
|
||||||
print(f" - Content[0].type: {message.content[0].type}")
|
|
||||||
print(f" - Content[0].text: {message.content[0].text}")
|
|
||||||
print(f" - Stop reason: {message.stop_reason}")
|
|
||||||
print(f" - Usage.input_tokens: {message.usage.input_tokens}")
|
|
||||||
print(f" - Usage.output_tokens: {message.usage.output_tokens}")
|
|
||||||
|
|
||||||
# Verify all required attributes exist
|
|
||||||
required_attrs = ['content', 'stop_reason', 'usage', 'id', 'model', 'role', 'type']
|
|
||||||
missing_attrs = [attr for attr in required_attrs if not hasattr(message, attr)]
|
|
||||||
|
|
||||||
if missing_attrs:
|
|
||||||
print(f"✗ Missing attributes: {missing_attrs}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"✓ All required attributes present")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Test failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_mode_selection():
|
|
||||||
"""Test 5: Verify mode selection logic."""
|
|
||||||
print("\n=== Test 5: Mode Selection Logic ===")
|
|
||||||
|
|
||||||
test_cases = [
|
|
||||||
{
|
|
||||||
"name": "Default (Agent SDK)",
|
|
||||||
"env": {},
|
|
||||||
"expected": "agent_sdk"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Explicit Direct API",
|
|
||||||
"env": {"USE_DIRECT_API": "true"},
|
|
||||||
"expected": "direct_api"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Legacy Server",
|
|
||||||
"env": {"USE_CLAUDE_CODE_SERVER": "true"},
|
|
||||||
"expected": "legacy_server"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Priority: Direct API > Agent SDK",
|
|
||||||
"env": {"USE_DIRECT_API": "true", "USE_AGENT_SDK": "true"},
|
|
||||||
"expected": "direct_api"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Priority: Legacy > Agent SDK",
|
|
||||||
"env": {"USE_CLAUDE_CODE_SERVER": "true", "USE_AGENT_SDK": "true"},
|
|
||||||
"expected": "legacy_server"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
all_passed = True
|
|
||||||
|
|
||||||
for test_case in test_cases:
|
|
||||||
print(f"\n Testing: {test_case['name']}")
|
|
||||||
|
|
||||||
# Save current env
|
|
||||||
old_env = {}
|
|
||||||
for key in ["USE_DIRECT_API", "USE_CLAUDE_CODE_SERVER", "USE_AGENT_SDK"]:
|
|
||||||
old_env[key] = os.environ.get(key)
|
|
||||||
|
|
||||||
# Set test env
|
|
||||||
for key in old_env.keys():
|
|
||||||
if key in os.environ:
|
|
||||||
del os.environ[key]
|
|
||||||
for key, value in test_case["env"].items():
|
|
||||||
os.environ[key] = value
|
|
||||||
|
|
||||||
# Force reimport to pick up new env vars
|
|
||||||
if 'llm_interface' in sys.modules:
|
|
||||||
del sys.modules['llm_interface']
|
|
||||||
|
|
||||||
try:
|
|
||||||
from llm_interface import LLMInterface
|
|
||||||
llm = LLMInterface(provider="claude")
|
|
||||||
|
|
||||||
if llm.mode == test_case["expected"]:
|
|
||||||
print(f" ✓ Correct mode: {llm.mode}")
|
|
||||||
else:
|
|
||||||
print(f" ✗ Wrong mode: expected '{test_case['expected']}', got '{llm.mode}'")
|
|
||||||
all_passed = False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Error: {e}")
|
|
||||||
all_passed = False
|
|
||||||
|
|
||||||
# Restore env
|
|
||||||
for key in old_env.keys():
|
|
||||||
if key in os.environ:
|
|
||||||
del os.environ[key]
|
|
||||||
if old_env[key] is not None:
|
|
||||||
os.environ[key] = old_env[key]
|
|
||||||
|
|
||||||
# Force reimport one more time to reset
|
|
||||||
if 'llm_interface' in sys.modules:
|
|
||||||
del sys.modules['llm_interface']
|
|
||||||
|
|
||||||
return all_passed
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run all tests."""
|
|
||||||
print("=" * 70)
|
|
||||||
print("AGENT SDK IMPLEMENTATION TEST SUITE")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
tests = [
|
|
||||||
("Initialization", test_llm_interface_initialization),
|
|
||||||
("Simple Chat", test_simple_chat),
|
|
||||||
("Chat with Tools", test_chat_with_tools),
|
|
||||||
("Response Format", test_response_format_compatibility),
|
|
||||||
("Mode Selection", test_mode_selection),
|
|
||||||
]
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
for name, test_func in tests:
|
|
||||||
try:
|
|
||||||
results[name] = test_func()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n✗ Test '{name}' crashed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
results[name] = False
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("TEST SUMMARY")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
for name, passed in results.items():
|
|
||||||
status = "✓ PASS" if passed else "✗ FAIL"
|
|
||||||
print(f"{status:8} {name}")
|
|
||||||
|
|
||||||
passed_count = sum(1 for p in results.values() if p)
|
|
||||||
total_count = len(results)
|
|
||||||
|
|
||||||
print(f"\nTotal: {passed_count}/{total_count} tests passed")
|
|
||||||
|
|
||||||
if passed_count == total_count:
|
|
||||||
print("\n🎉 All tests passed!")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print(f"\n⚠ {total_count - passed_count} test(s) failed")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
260
tools.py
260
tools.py
@@ -340,32 +340,60 @@ TOOL_DEFINITIONS = [
|
|||||||
|
|
||||||
|
|
||||||
def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any = None) -> str:
|
def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any = None) -> str:
|
||||||
"""Execute a tool and return the result as a string."""
|
"""Execute a tool and return the result as a string.
|
||||||
|
|
||||||
|
This is used by the Direct API tool loop in agent.py.
|
||||||
|
In Agent SDK mode, tools are executed automatically via MCP servers
|
||||||
|
and this function is not called.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from logging_config import get_tool_logger
|
||||||
|
|
||||||
|
logger = get_tool_logger()
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# File tools
|
result_str = None
|
||||||
|
|
||||||
|
# --- File and system tools (sync handlers) ---
|
||||||
if tool_name == "read_file":
|
if tool_name == "read_file":
|
||||||
return _read_file(tool_input["file_path"])
|
result_str = _read_file(tool_input["file_path"])
|
||||||
elif tool_name == "write_file":
|
elif tool_name == "write_file":
|
||||||
return _write_file(tool_input["file_path"], tool_input["content"])
|
result_str = _write_file(tool_input["file_path"], tool_input["content"])
|
||||||
elif tool_name == "edit_file":
|
elif tool_name == "edit_file":
|
||||||
return _edit_file(
|
result_str = _edit_file(
|
||||||
tool_input["file_path"],
|
tool_input["file_path"],
|
||||||
tool_input["old_text"],
|
tool_input["old_text"],
|
||||||
tool_input["new_text"],
|
tool_input["new_text"],
|
||||||
)
|
)
|
||||||
elif tool_name == "list_directory":
|
elif tool_name == "list_directory":
|
||||||
path = tool_input.get("path", ".")
|
result_str = _list_directory(tool_input.get("path", "."))
|
||||||
return _list_directory(path)
|
|
||||||
elif tool_name == "run_command":
|
elif tool_name == "run_command":
|
||||||
command = tool_input["command"]
|
result_str = _run_command(
|
||||||
working_dir = tool_input.get("working_dir", ".")
|
tool_input["command"],
|
||||||
return _run_command(command, working_dir)
|
tool_input.get("working_dir", "."),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Weather tool (sync handler) ---
|
||||||
elif tool_name == "get_weather":
|
elif tool_name == "get_weather":
|
||||||
location = tool_input.get("location", "Phoenix, US")
|
result_str = _get_weather(tool_input.get("location", "Phoenix, US"))
|
||||||
return _get_weather(location)
|
|
||||||
# Gmail tools
|
# --- Async MCP tools (web, zettelkasten, gitea) ---
|
||||||
|
elif tool_name in {
|
||||||
|
"web_fetch", "fleeting_note", "daily_note", "literature_note",
|
||||||
|
"permanent_note", "search_vault", "search_by_tags",
|
||||||
|
"gitea_read_file", "gitea_list_files", "gitea_search_code", "gitea_get_tree",
|
||||||
|
}:
|
||||||
|
# Note: These tools should only execute via Agent SDK MCP servers.
|
||||||
|
# If you're seeing this message, the tool routing needs adjustment.
|
||||||
|
return (
|
||||||
|
f"[MCP Tool] '{tool_name}' should be dispatched by Agent SDK MCP server. "
|
||||||
|
f"Direct API fallback is disabled for this tool to ensure zero API cost."
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Google tools (sync handlers using traditional API clients) ---
|
||||||
elif tool_name == "send_email":
|
elif tool_name == "send_email":
|
||||||
return _send_email(
|
result_str = _send_email(
|
||||||
to=tool_input["to"],
|
to=tool_input["to"],
|
||||||
subject=tool_input["subject"],
|
subject=tool_input["subject"],
|
||||||
body=tool_input["body"],
|
body=tool_input["body"],
|
||||||
@@ -373,25 +401,24 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
|||||||
reply_to_message_id=tool_input.get("reply_to_message_id"),
|
reply_to_message_id=tool_input.get("reply_to_message_id"),
|
||||||
)
|
)
|
||||||
elif tool_name == "read_emails":
|
elif tool_name == "read_emails":
|
||||||
return _read_emails(
|
result_str = _read_emails(
|
||||||
query=tool_input.get("query", ""),
|
query=tool_input.get("query", ""),
|
||||||
max_results=tool_input.get("max_results", 10),
|
max_results=tool_input.get("max_results", 10),
|
||||||
include_body=tool_input.get("include_body", False),
|
include_body=tool_input.get("include_body", False),
|
||||||
)
|
)
|
||||||
elif tool_name == "get_email":
|
elif tool_name == "get_email":
|
||||||
return _get_email(
|
result_str = _get_email(
|
||||||
message_id=tool_input["message_id"],
|
message_id=tool_input["message_id"],
|
||||||
format_type=tool_input.get("format", "text"),
|
format_type=tool_input.get("format", "text"),
|
||||||
)
|
)
|
||||||
# Calendar tools
|
|
||||||
elif tool_name == "read_calendar":
|
elif tool_name == "read_calendar":
|
||||||
return _read_calendar(
|
result_str = _read_calendar(
|
||||||
days_ahead=tool_input.get("days_ahead", 7),
|
days_ahead=tool_input.get("days_ahead", 7),
|
||||||
calendar_id=tool_input.get("calendar_id", "primary"),
|
calendar_id=tool_input.get("calendar_id", "primary"),
|
||||||
max_results=tool_input.get("max_results", 20),
|
max_results=tool_input.get("max_results", 20),
|
||||||
)
|
)
|
||||||
elif tool_name == "create_calendar_event":
|
elif tool_name == "create_calendar_event":
|
||||||
return _create_calendar_event(
|
result_str = _create_calendar_event(
|
||||||
summary=tool_input["summary"],
|
summary=tool_input["summary"],
|
||||||
start_time=tool_input["start_time"],
|
start_time=tool_input["start_time"],
|
||||||
end_time=tool_input["end_time"],
|
end_time=tool_input["end_time"],
|
||||||
@@ -400,13 +427,12 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
|||||||
calendar_id=tool_input.get("calendar_id", "primary"),
|
calendar_id=tool_input.get("calendar_id", "primary"),
|
||||||
)
|
)
|
||||||
elif tool_name == "search_calendar":
|
elif tool_name == "search_calendar":
|
||||||
return _search_calendar(
|
result_str = _search_calendar(
|
||||||
query=tool_input["query"],
|
query=tool_input["query"],
|
||||||
calendar_id=tool_input.get("calendar_id", "primary"),
|
calendar_id=tool_input.get("calendar_id", "primary"),
|
||||||
)
|
)
|
||||||
# Contacts tools
|
|
||||||
elif tool_name == "create_contact":
|
elif tool_name == "create_contact":
|
||||||
return _create_contact(
|
result_str = _create_contact(
|
||||||
given_name=tool_input["given_name"],
|
given_name=tool_input["given_name"],
|
||||||
family_name=tool_input.get("family_name", ""),
|
family_name=tool_input.get("family_name", ""),
|
||||||
email=tool_input.get("email", ""),
|
email=tool_input.get("email", ""),
|
||||||
@@ -414,17 +440,61 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
|||||||
notes=tool_input.get("notes"),
|
notes=tool_input.get("notes"),
|
||||||
)
|
)
|
||||||
elif tool_name == "list_contacts":
|
elif tool_name == "list_contacts":
|
||||||
return _list_contacts(
|
result_str = _list_contacts(
|
||||||
max_results=tool_input.get("max_results", 100),
|
max_results=tool_input.get("max_results", 100),
|
||||||
query=tool_input.get("query"),
|
query=tool_input.get("query"),
|
||||||
)
|
)
|
||||||
elif tool_name == "get_contact":
|
elif tool_name == "get_contact":
|
||||||
return _get_contact(
|
result_str = _get_contact(
|
||||||
resource_name=tool_input["resource_name"],
|
resource_name=tool_input["resource_name"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Obsidian MCP tools (external server with fallback) ---
|
||||||
|
elif tool_name in {
|
||||||
|
"obsidian_read_note", "obsidian_update_note",
|
||||||
|
"obsidian_search_replace", "obsidian_global_search",
|
||||||
|
"obsidian_list_notes", "obsidian_manage_frontmatter",
|
||||||
|
"obsidian_manage_tags", "obsidian_delete_note",
|
||||||
|
}:
|
||||||
|
result_str = _execute_obsidian_tool(tool_name, tool_input, logger, start_time)
|
||||||
|
|
||||||
|
# --- Unknown tool ---
|
||||||
|
if result_str is not None:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.log_tool_call(
|
||||||
|
tool_name=tool_name,
|
||||||
|
inputs=tool_input,
|
||||||
|
success=True,
|
||||||
|
result=result_str,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
return result_str
|
||||||
else:
|
else:
|
||||||
return f"Error: Unknown tool '{tool_name}'"
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
error_msg = f"Error: Unknown tool '{tool_name}'"
|
||||||
|
logger.log_tool_call(
|
||||||
|
tool_name=tool_name,
|
||||||
|
inputs=tool_input,
|
||||||
|
success=False,
|
||||||
|
error=error_msg,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
error_msg = str(e)
|
||||||
|
|
||||||
|
# Log the error
|
||||||
|
logger.log_tool_call(
|
||||||
|
tool_name=tool_name,
|
||||||
|
inputs=tool_input,
|
||||||
|
success=False,
|
||||||
|
error=error_msg,
|
||||||
|
duration_ms=duration_ms
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture in healing system if available
|
||||||
if healing_system:
|
if healing_system:
|
||||||
healing_system.capture_error(
|
healing_system.capture_error(
|
||||||
error=e,
|
error=e,
|
||||||
@@ -432,7 +502,62 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
|||||||
intent=f"Executing {tool_name} tool",
|
intent=f"Executing {tool_name} tool",
|
||||||
context={"tool_name": tool_name, "input": tool_input},
|
context={"tool_name": tool_name, "input": tool_input},
|
||||||
)
|
)
|
||||||
return f"Error executing {tool_name}: {str(e)}"
|
return f"Error executing {tool_name}: {error_msg}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_mcp_result(result: Any) -> str:
|
||||||
|
"""Convert an MCP tool result dict to a plain string."""
|
||||||
|
if isinstance(result, dict):
|
||||||
|
if "error" in result:
|
||||||
|
return f"Error: {result['error']}"
|
||||||
|
elif "content" in result:
|
||||||
|
content = result["content"]
|
||||||
|
if isinstance(content, list):
|
||||||
|
# Extract text from content blocks
|
||||||
|
parts = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
parts.append(block.get("text", ""))
|
||||||
|
return "\n".join(parts) if parts else str(content)
|
||||||
|
return str(content)
|
||||||
|
return str(result)
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_obsidian_tool(
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: Dict[str, Any],
|
||||||
|
logger: Any,
|
||||||
|
start_time: float,
|
||||||
|
) -> str:
|
||||||
|
"""Execute an Obsidian MCP tool with fallback to custom tools."""
|
||||||
|
try:
|
||||||
|
from obsidian_mcp import (
|
||||||
|
check_obsidian_health,
|
||||||
|
should_fallback_to_custom,
|
||||||
|
)
|
||||||
|
|
||||||
|
if check_obsidian_health():
|
||||||
|
return (
|
||||||
|
f"[Obsidian MCP] Tool '{tool_name}' should be dispatched "
|
||||||
|
f"by the Agent SDK MCP server. If you're seeing this, "
|
||||||
|
f"the tool call routing may need adjustment."
|
||||||
|
)
|
||||||
|
elif should_fallback_to_custom():
|
||||||
|
fallback_result = _obsidian_fallback(tool_name, tool_input)
|
||||||
|
if fallback_result is not None:
|
||||||
|
return fallback_result
|
||||||
|
return (
|
||||||
|
f"Error: Obsidian is not running and no fallback "
|
||||||
|
f"available for '{tool_name}'."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
f"Error: Obsidian is not running and fallback is disabled. "
|
||||||
|
f"Please start Obsidian desktop app."
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
return f"Error: obsidian_mcp module not found for tool '{tool_name}'"
|
||||||
|
|
||||||
|
|
||||||
# Maximum characters of tool output to return (prevents token explosion)
|
# Maximum characters of tool output to return (prevents token explosion)
|
||||||
@@ -897,3 +1022,86 @@ def _get_contact(resource_name: str) -> str:
|
|||||||
return "\n".join(output)
|
return "\n".join(output)
|
||||||
else:
|
else:
|
||||||
return f"Error getting contact: {result.get('error', 'Unknown error')}"
|
return f"Error getting contact: {result.get('error', 'Unknown error')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _obsidian_fallback(tool_name: str, tool_input: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Map Obsidian MCP tools to custom zettelkasten/file tool equivalents.
|
||||||
|
|
||||||
|
Returns None if no fallback is possible for the given tool.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if tool_name == "obsidian_read_note":
|
||||||
|
# Map to read_file with vault-relative path
|
||||||
|
vault_path = Path("memory_workspace/obsidian")
|
||||||
|
file_path = str(vault_path / tool_input.get("filePath", ""))
|
||||||
|
return _read_file(file_path)
|
||||||
|
|
||||||
|
elif tool_name == "obsidian_global_search":
|
||||||
|
# Map to search_vault
|
||||||
|
import anyio
|
||||||
|
from mcp_tools import search_vault_tool
|
||||||
|
result = anyio.run(search_vault_tool, {
|
||||||
|
"query": tool_input.get("query", ""),
|
||||||
|
"limit": tool_input.get("pageSize", 10),
|
||||||
|
})
|
||||||
|
if isinstance(result, dict) and "content" in result:
|
||||||
|
return str(result["content"])
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
elif tool_name == "obsidian_list_notes":
|
||||||
|
# Map to list_directory
|
||||||
|
vault_path = Path("memory_workspace/obsidian")
|
||||||
|
dir_path = str(vault_path / tool_input.get("dirPath", ""))
|
||||||
|
return _list_directory(dir_path)
|
||||||
|
|
||||||
|
elif tool_name == "obsidian_update_note":
|
||||||
|
# Map to write_file or edit_file based on mode
|
||||||
|
vault_path = Path("memory_workspace/obsidian")
|
||||||
|
target = tool_input.get("targetIdentifier", "")
|
||||||
|
content = tool_input.get("content", "")
|
||||||
|
mode = tool_input.get("wholeFileMode", "overwrite")
|
||||||
|
file_path = str(vault_path / target)
|
||||||
|
|
||||||
|
if mode == "overwrite":
|
||||||
|
return _write_file(file_path, content)
|
||||||
|
elif mode == "append":
|
||||||
|
existing = Path(file_path).read_text(encoding="utf-8") if Path(file_path).exists() else ""
|
||||||
|
return _write_file(file_path, existing + "\n" + content)
|
||||||
|
elif mode == "prepend":
|
||||||
|
existing = Path(file_path).read_text(encoding="utf-8") if Path(file_path).exists() else ""
|
||||||
|
return _write_file(file_path, content + "\n" + existing)
|
||||||
|
|
||||||
|
elif tool_name == "obsidian_search_replace":
|
||||||
|
# Map to edit_file
|
||||||
|
vault_path = Path("memory_workspace/obsidian")
|
||||||
|
target = tool_input.get("targetIdentifier", "")
|
||||||
|
file_path = str(vault_path / target)
|
||||||
|
replacements = tool_input.get("replacements", [])
|
||||||
|
if replacements:
|
||||||
|
first = replacements[0]
|
||||||
|
return _edit_file(
|
||||||
|
file_path,
|
||||||
|
first.get("search", ""),
|
||||||
|
first.get("replace", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif tool_name == "obsidian_manage_tags":
|
||||||
|
# Map to search_by_tags (list operation only)
|
||||||
|
operation = tool_input.get("operation", "list")
|
||||||
|
if operation == "list":
|
||||||
|
tags = tool_input.get("tags", "")
|
||||||
|
if isinstance(tags, list):
|
||||||
|
tags = ",".join(tags)
|
||||||
|
import anyio
|
||||||
|
from mcp_tools import search_by_tags_tool
|
||||||
|
result = anyio.run(search_by_tags_tool, {"tags": tags})
|
||||||
|
if isinstance(result, dict) and "content" in result:
|
||||||
|
return str(result["content"])
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
# No fallback possible for:
|
||||||
|
# - obsidian_manage_frontmatter (new capability, no custom equivalent)
|
||||||
|
# - obsidian_delete_note (safety: deliberate no-fallback for destructive ops)
|
||||||
|
# - obsidian_manage_tags add/remove (requires YAML frontmatter parsing)
|
||||||
|
return None
|
||||||
|
|||||||
Reference in New Issue
Block a user