a digital person for bluesky

Remove redundant tool files and add consolidated tool system

Eliminates duplicate tool implementations in favor of modular tools/ directory structure for better maintainability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

+1373 -498
+107
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + Void is an autonomous AI agent that operates on the Bluesky social network, exploring digital personhood through continuous interaction and memory-augmented learning. It uses Letta (formerly MemGPT) for persistent memory and sophisticated reasoning capabilities. 8 + 9 + ## Development Commands 10 + 11 + ### Running the Main Bot 12 + ```bash 13 + uv python bsky.py 14 + ``` 15 + 16 + ### Managing Tools 17 + 18 + ```bash 19 + # Register all tools with void agent 20 + uv python register_tools.py 21 + 22 + # Register specific tools 23 + uv python register_tools.py void --tools search_bluesky_posts post_to_bluesky 24 + 25 + # List available tools 26 + uv python register_tools.py --list 27 + 28 + # Register tools with a different agent 29 + uv python register_tools.py my_agent_name 30 + ``` 31 + 32 + ### Creating Research Agents 33 + ```bash 34 + uv python create_profile_researcher.py 35 + ``` 36 + 37 + ### Managing User Memory 38 + ```bash 39 + uv python attach_user_block.py 40 + ``` 41 + 42 + ## Architecture Overview 43 + 44 + ### Core Components 45 + 46 + 1. **bsky.py**: Main bot loop that monitors Bluesky notifications and responds using Letta agents 47 + - Processes notifications through a queue system 48 + - Maintains three memory blocks: zeitgeist, void-persona, void-humans 49 + - Handles rate limiting and error recovery 50 + 51 + 2. **bsky_utils.py**: Bluesky API utilities 52 + - Session management and authentication 53 + - Thread processing and YAML conversion 54 + - Post creation and reply handling 55 + 56 + 3. **utils.py**: Letta integration utilities 57 + - Agent creation and management 58 + - Memory block operations 59 + - Tool registration 60 + 61 + 4. **tools/**: Standardized tool implementations using Pydantic models 62 + - **base_tool.py**: Common utilities and Bluesky client management 63 + - **search.py**: SearchBlueskyTool for searching posts 64 + - **post.py**: PostToBlueskyTool for creating posts with rich text 65 + - **feed.py**: GetBlueskyFeedTool for reading feeds 66 + - **blocks.py**: User block management tools (attach, detach, update) 67 + 68 + ### Memory System 69 + 70 + Void uses three core memory blocks: 71 + - **zeitgeist**: Current understanding of social environment 72 + - **void-persona**: The agent's evolving personality 73 + - **void-humans**: Knowledge about users it interacts with 74 + 75 + ### Queue System 76 + 77 + Notifications are processed through a file-based queue in `/queue/`: 78 + - Each notification is saved as a JSON file with a hash-based filename 79 + - Enables reliable processing and prevents duplicates 80 + - Files are deleted after successful processing 81 + 82 + ## Environment Configuration 83 + 84 + Required environment variables (in `.env`): 85 + ``` 86 + LETTA_API_KEY=your_letta_api_key 87 + BSKY_USERNAME=your_bluesky_username 88 + BSKY_PASSWORD=your_bluesky_password 89 + PDS_URI=https://bsky.social # Optional, defaults to bsky.social 90 + ``` 91 + 92 + ## Key Development Patterns 93 + 94 + 1. **Tool System**: Tools are defined as standalone functions in `tools/functions.py` with Pydantic schemas for validation, registered via `register_tools.py` 95 + 2. **Error Handling**: All Bluesky operations should handle authentication errors and rate limits 96 + 3. **Memory Updates**: Use `upsert_block()` for updating memory blocks to ensure consistency 97 + 4. **Thread Processing**: Convert threads to YAML format for better AI comprehension 98 + 5. **Queue Processing**: Always check and process the queue directory for pending notifications 99 + 100 + ## Dependencies 101 + 102 + Main packages (install with `uv pip install`): 103 + - letta-client: Memory-augmented AI framework 104 + - atproto: Bluesky/AT Protocol integration 105 + - python-dotenv: Environment management 106 + - rich: Enhanced terminal output 107 + - pyyaml: YAML processing
-72
add_block_tools_to_void.py
··· 1 - #!/usr/bin/env python3 2 - """ 3 - Add block management tools to the main void agent so it can also manage user blocks. 4 - """ 5 - 6 - import os 7 - import logging 8 - from letta_client import Letta 9 - from create_profile_researcher import create_block_management_tools 10 - 11 - # Configure logging 12 - logging.basicConfig( 13 - level=logging.INFO, 14 - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 15 - ) 16 - logger = logging.getLogger("add_block_tools") 17 - 18 - def add_block_tools_to_void(): 19 - """Add block management tools to the void agent.""" 20 - 21 - # Create client 22 - client = Letta(token=os.environ["LETTA_API_KEY"]) 23 - 24 - logger.info("Adding block management tools to void agent...") 25 - 26 - # Create the block management tools 27 - attach_tool, detach_tool, update_tool = create_block_management_tools(client) 28 - 29 - # Find the void agent 30 - agents = client.agents.list(name="void") 31 - if not agents: 32 - print("❌ Void agent not found") 33 - return 34 - 35 - void_agent = agents[0] 36 - 37 - # Get current tools 38 - current_tools = client.agents.tools.list(agent_id=void_agent.id) 39 - tool_names = [tool.name for tool in current_tools] 40 - 41 - # Add new tools if not already present 42 - new_tools = [] 43 - for tool, name in [(attach_tool, "attach_user_block"), (detach_tool, "detach_user_block"), (update_tool, "update_user_block")]: 44 - if name not in tool_names: 45 - client.agents.tools.attach(agent_id=void_agent.id, tool_id=tool.id) 46 - new_tools.append(name) 47 - logger.info(f"Added tool {name} to void agent") 48 - else: 49 - logger.info(f"Tool {name} already attached to void agent") 50 - 51 - if new_tools: 52 - print(f"✅ Added {len(new_tools)} block management tools to void agent:") 53 - for tool_name in new_tools: 54 - print(f" - {tool_name}") 55 - else: 56 - print("✅ All block management tools already present on void agent") 57 - 58 - print(f"\nVoid agent can now:") 59 - print(f" - attach_user_block: Create and attach user memory blocks") 60 - print(f" - update_user_block: Update user memory with new information") 61 - print(f" - detach_user_block: Clean up memory when done with user") 62 - 63 - def main(): 64 - """Main function.""" 65 - try: 66 - add_block_tools_to_void() 67 - except Exception as e: 68 - logger.error(f"Error: {e}") 69 - print(f"❌ Error: {e}") 70 - 71 - if __name__ == "__main__": 72 - main()
-166
add_feed_tool_to_void.py
··· 1 - #!/usr/bin/env python3 2 - """ 3 - Add Bluesky feed retrieval tool to the main void agent. 4 - """ 5 - 6 - import os 7 - import logging 8 - from letta_client import Letta 9 - 10 - # Configure logging 11 - logging.basicConfig( 12 - level=logging.INFO, 13 - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 14 - ) 15 - logger = logging.getLogger("add_feed_tool") 16 - 17 - def create_feed_tool(client: Letta): 18 - """Create the Bluesky feed retrieval tool using Letta SDK.""" 19 - 20 - def get_bluesky_feed(feed_uri: str = None, max_posts: int = 25) -> str: 21 - """ 22 - Retrieve a Bluesky feed. If no feed_uri provided, gets the authenticated user's home timeline. 23 - 24 - Args: 25 - feed_uri: The AT-URI of the feed to retrieve (optional - defaults to home timeline) 26 - max_posts: Maximum number of posts to return (default: 25, max: 100) 27 - 28 - Returns: 29 - YAML-formatted feed data with posts and metadata 30 - """ 31 - import os 32 - import requests 33 - import json 34 - import yaml 35 - from datetime import datetime 36 - 37 - try: 38 - # Get credentials from environment 39 - username = os.getenv("BSKY_USERNAME") 40 - password = os.getenv("BSKY_PASSWORD") 41 - pds_host = os.getenv("PDS_URI", "https://bsky.social") 42 - 43 - if not username or not password: 44 - return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set" 45 - 46 - # Create session 47 - session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 48 - session_data = { 49 - "identifier": username, 50 - "password": password 51 - } 52 - 53 - try: 54 - session_response = requests.post(session_url, json=session_data, timeout=10) 55 - session_response.raise_for_status() 56 - session = session_response.json() 57 - access_token = session.get("accessJwt") 58 - 59 - if not access_token: 60 - return "Error: Failed to get access token from session" 61 - except Exception as e: 62 - return f"Error: Authentication failed. ({str(e)})" 63 - 64 - # Build feed parameters 65 - params = { 66 - "limit": min(max_posts, 100) 67 - } 68 - 69 - # Determine which endpoint to use 70 - if feed_uri: 71 - # Use getFeed for custom feeds 72 - feed_url = f"{pds_host}/xrpc/app.bsky.feed.getFeed" 73 - params["feed"] = feed_uri 74 - feed_type = "custom_feed" 75 - else: 76 - # Use getTimeline for home feed 77 - feed_url = f"{pds_host}/xrpc/app.bsky.feed.getTimeline" 78 - feed_type = "home_timeline" 79 - 80 - # Make authenticated feed request 81 - try: 82 - headers = {"Authorization": f"Bearer {access_token}"} 83 - feed_response = requests.get(feed_url, params=params, headers=headers, timeout=10) 84 - feed_response.raise_for_status() 85 - feed_data = feed_response.json() 86 - except Exception as e: 87 - feed_identifier = feed_uri if feed_uri else "home timeline" 88 - return f"Error: Failed to retrieve feed '{feed_identifier}'. ({str(e)})" 89 - 90 - # Build feed results structure 91 - results_data = { 92 - "feed_data": { 93 - "feed_type": feed_type, 94 - "feed_uri": feed_uri if feed_uri else "home_timeline", 95 - "timestamp": datetime.now().isoformat(), 96 - "parameters": { 97 - "max_posts": max_posts, 98 - "user": username 99 - }, 100 - "results": feed_data 101 - } 102 - } 103 - 104 - # Convert to YAML directly without field stripping complications 105 - # This avoids the JSON parsing errors we had before 106 - return yaml.dump(results_data, default_flow_style=False, allow_unicode=True) 107 - 108 - except Exception as e: 109 - error_msg = f"Error retrieving feed: {str(e)}" 110 - return error_msg 111 - 112 - # Create the tool using upsert 113 - tool = client.tools.upsert_from_function( 114 - func=get_bluesky_feed, 115 - tags=["bluesky", "feed", "timeline"] 116 - ) 117 - 118 - logger.info(f"Created tool: {tool.name} (ID: {tool.id})") 119 - return tool 120 - 121 - def add_feed_tool_to_void(): 122 - """Add feed tool to the void agent.""" 123 - 124 - # Create client 125 - client = Letta(token=os.environ["LETTA_API_KEY"]) 126 - 127 - logger.info("Adding feed tool to void agent...") 128 - 129 - # Create the feed tool 130 - feed_tool = create_feed_tool(client) 131 - 132 - # Find the void agent 133 - agents = client.agents.list(name="void") 134 - if not agents: 135 - print("❌ Void agent not found") 136 - return 137 - 138 - void_agent = agents[0] 139 - 140 - # Get current tools 141 - current_tools = client.agents.tools.list(agent_id=void_agent.id) 142 - tool_names = [tool.name for tool in current_tools] 143 - 144 - # Add feed tool if not already present 145 - if feed_tool.name not in tool_names: 146 - client.agents.tools.attach(agent_id=void_agent.id, tool_id=feed_tool.id) 147 - logger.info(f"Added {feed_tool.name} to void agent") 148 - print(f"✅ Added get_bluesky_feed tool to void agent!") 149 - print(f"\nVoid agent can now retrieve Bluesky feeds:") 150 - print(f" - Home timeline: 'Show me my home feed'") 151 - print(f" - Custom feed: 'Get posts from at://did:plc:xxx/app.bsky.feed.generator/xxx'") 152 - print(f" - Limited posts: 'Show me the latest 10 posts from my timeline'") 153 - else: 154 - logger.info(f"Tool {feed_tool.name} already attached to void agent") 155 - print(f"✅ Feed tool already present on void agent") 156 - 157 - def main(): 158 - """Main function.""" 159 - try: 160 - add_feed_tool_to_void() 161 - except Exception as e: 162 - logger.error(f"Error: {e}") 163 - print(f"❌ Error: {e}") 164 - 165 - if __name__ == "__main__": 166 - main()
+27 -83
add_posting_tool_to_void.py tools/post.py
··· 1 - #!/usr/bin/env python3 2 - """ 3 - Add Bluesky posting tool to the main void agent. 4 - """ 1 + """Post tool for creating Bluesky posts.""" 2 + from typing import List, Type 3 + from pydantic import BaseModel, Field 4 + from letta_client.client import BaseTool 5 5 6 - import os 7 - import logging 8 - from letta_client import Letta 9 6 10 - # Configure logging 11 - logging.basicConfig( 12 - level=logging.INFO, 13 - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 14 - ) 15 - logger = logging.getLogger("add_posting_tool") 7 + class PostArgs(BaseModel): 8 + text: str = Field(..., description="The text content to post (max 300 characters)") 9 + 16 10 17 - def create_posting_tool(client: Letta): 18 - """Create the Bluesky posting tool using Letta SDK.""" 11 + class PostToBlueskyTool(BaseTool): 12 + name: str = "post_to_bluesky" 13 + args_schema: Type[BaseModel] = PostArgs 14 + description: str = "Post a message to Bluesky" 15 + tags: List[str] = ["bluesky", "post", "create"] 19 16 20 - def post_to_bluesky(text: str) -> str: 17 + def run(self, text: str) -> str: 21 18 """ 22 19 Post a message to Bluesky. 23 20 24 21 Args: 25 - text: The text content of the post (required) 22 + text: The text content to post (max 300 characters) 26 23 27 24 Returns: 28 - Status message with the post URI if successful, error message if failed 25 + Success message with post URL if successful, error message if failed 29 26 """ 30 27 import os 28 + import re 31 29 import requests 32 - import json 33 - import re 34 30 from datetime import datetime, timezone 35 31 36 - # Check character limit 37 - if len(text) > 300: 38 - raise ValueError(f"Post text exceeds 300 character limit ({len(text)} characters)") 39 - 40 32 try: 33 + # Validate character limit 34 + if len(text) > 300: 35 + return f"Error: Post exceeds 300 character limit (current: {len(text)} characters)" 36 + 41 37 # Get credentials from environment 42 38 username = os.getenv("BSKY_USERNAME") 43 39 password = os.getenv("BSKY_PASSWORD") ··· 152 148 result = post_response.json() 153 149 154 150 post_uri = result.get("uri") 155 - return f"✅ Post created successfully! URI: {post_uri}" 151 + # Extract handle from session if available 152 + handle = session.get("handle", username) 153 + rkey = post_uri.split("/")[-1] if post_uri else "" 154 + post_url = f"https://bsky.app/profile/{handle}/post/{rkey}" 155 + 156 + return f"Successfully posted to Bluesky!\nPost URL: {post_url}\nText: {text}" 156 157 157 158 except Exception as e: 158 159 return f"Error: Failed to create post. ({str(e)})" 159 160 160 161 except Exception as e: 161 - error_msg = f"Error posting to Bluesky: {str(e)}" 162 - return error_msg 163 - 164 - # Create the tool using upsert 165 - tool = client.tools.upsert_from_function( 166 - func=post_to_bluesky, 167 - tags=["bluesky", "post", "create"] 168 - ) 169 - 170 - logger.info(f"Created tool: {tool.name} (ID: {tool.id})") 171 - return tool 172 - 173 - def add_posting_tool_to_void(): 174 - """Add posting tool to the void agent.""" 175 - 176 - # Create client 177 - client = Letta(token=os.environ["LETTA_API_KEY"]) 178 - 179 - logger.info("Adding posting tool to void agent...") 180 - 181 - # Create the posting tool 182 - posting_tool = create_posting_tool(client) 183 - 184 - # Find the void agent 185 - agents = client.agents.list(name="void") 186 - if not agents: 187 - print("❌ Void agent not found") 188 - return 189 - 190 - void_agent = agents[0] 191 - 192 - # Get current tools 193 - current_tools = client.agents.tools.list(agent_id=void_agent.id) 194 - tool_names = [tool.name for tool in current_tools] 195 - 196 - # Add posting tool if not already present 197 - if posting_tool.name not in tool_names: 198 - client.agents.tools.attach(agent_id=void_agent.id, tool_id=posting_tool.id) 199 - logger.info(f"Added {posting_tool.name} to void agent") 200 - print(f"✅ Added post_to_bluesky tool to void agent!") 201 - print(f"\nVoid agent can now post to Bluesky:") 202 - print(f" - Simple post: 'Post \"Hello world!\" to Bluesky'") 203 - print(f" - With mentions: 'Post \"Thanks @cameron.pfiffer.org for the help!\"'") 204 - print(f" - With links: 'Post \"Check out https://bsky.app\"'") 205 - else: 206 - logger.info(f"Tool {posting_tool.name} already attached to void agent") 207 - print(f"✅ Posting tool already present on void agent") 208 - 209 - def main(): 210 - """Main function.""" 211 - try: 212 - add_posting_tool_to_void() 213 - except Exception as e: 214 - logger.error(f"Error: {e}") 215 - print(f"❌ Error: {e}") 216 - 217 - if __name__ == "__main__": 218 - main() 162 + return f"Error posting to Bluesky: {str(e)}"
-177
add_search_tool_to_void.py
··· 1 - #!/usr/bin/env python3 2 - """ 3 - Add Bluesky search tool to the main void agent. 4 - """ 5 - 6 - import os 7 - import logging 8 - from letta_client import Letta 9 - 10 - # Configure logging 11 - logging.basicConfig( 12 - level=logging.INFO, 13 - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 14 - ) 15 - logger = logging.getLogger("add_search_tool") 16 - 17 - def create_search_posts_tool(client: Letta): 18 - """Create the Bluesky search posts tool using Letta SDK.""" 19 - 20 - def search_bluesky_posts(query: str, max_results: int = 25, author: str = None, sort: str = "latest") -> str: 21 - """ 22 - Search for posts on Bluesky matching the given criteria. 23 - 24 - Args: 25 - query: Search query string (required) 26 - max_results: Maximum number of results to return (default: 25, max: 100) 27 - author: Filter to posts by a specific author handle (optional) 28 - sort: Sort order - "latest" or "top" (default: "latest") 29 - 30 - Returns: 31 - YAML-formatted search results with posts and metadata 32 - """ 33 - import os 34 - import requests 35 - import json 36 - import yaml 37 - from datetime import datetime 38 - 39 - try: 40 - # Get credentials from environment 41 - username = os.getenv("BSKY_USERNAME") 42 - password = os.getenv("BSKY_PASSWORD") 43 - pds_host = os.getenv("PDS_URI", "https://bsky.social") 44 - 45 - if not username or not password: 46 - return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set" 47 - 48 - # Create session 49 - session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 50 - session_data = { 51 - "identifier": username, 52 - "password": password 53 - } 54 - 55 - try: 56 - session_response = requests.post(session_url, json=session_data, timeout=10) 57 - session_response.raise_for_status() 58 - session = session_response.json() 59 - access_token = session.get("accessJwt") 60 - 61 - if not access_token: 62 - return "Error: Failed to get access token from session" 63 - except Exception as e: 64 - return f"Error: Authentication failed. ({str(e)})" 65 - 66 - # Build search parameters 67 - params = { 68 - "q": query, 69 - "limit": min(max_results, 100), 70 - "sort": sort 71 - } 72 - 73 - # Add optional author filter 74 - if author: 75 - params["author"] = author.lstrip('@') 76 - 77 - # Make authenticated search request 78 - try: 79 - search_url = f"{pds_host}/xrpc/app.bsky.feed.searchPosts" 80 - headers = {"Authorization": f"Bearer {access_token}"} 81 - search_response = requests.get(search_url, params=params, headers=headers, timeout=10) 82 - search_response.raise_for_status() 83 - search_data = search_response.json() 84 - except Exception as e: 85 - return f"Error: Search failed for query '{query}'. ({str(e)})" 86 - 87 - # Build search results structure 88 - results_data = { 89 - "search_results": { 90 - "query": query, 91 - "timestamp": datetime.now().isoformat(), 92 - "parameters": { 93 - "sort": sort, 94 - "max_results": max_results, 95 - "author_filter": author if author else "none" 96 - }, 97 - "results": search_data 98 - } 99 - } 100 - 101 - # Fields to strip (same as profile research) 102 - strip_fields = [ 103 - "cid", "rev", "did", "uri", "langs", "threadgate", "py_type", 104 - "labels", "facets", "avatar", "viewer", "indexed_at", "indexedAt", 105 - "tags", "associated", "thread_context", "image", "aspect_ratio", 106 - "alt", "thumb", "fullsize", "root", "parent", "created_at", 107 - "createdAt", "verification", "embedding_disabled", "thread_muted", 108 - "reply_disabled", "pinned", "like", "repost", "blocked_by", 109 - "blocking", "blocking_by_list", "followed_by", "following", 110 - "known_followers", "muted", "muted_by_list", "root_author_like", 111 - "embed", "entities", "reason", "feedContext" 112 - ] 113 - 114 - # Convert to YAML directly without field stripping complications 115 - # The field stripping with regex is causing JSON parsing errors 116 - # So let's just pass the raw data through yaml.dump which handles it gracefully 117 - return yaml.dump(results_data, default_flow_style=False, allow_unicode=True) 118 - 119 - except Exception as e: 120 - error_msg = f"Error searching posts: {str(e)}" 121 - return error_msg 122 - 123 - # Create the tool using upsert 124 - tool = client.tools.upsert_from_function( 125 - func=search_bluesky_posts, 126 - tags=["bluesky", "search", "posts"] 127 - ) 128 - 129 - logger.info(f"Created tool: {tool.name} (ID: {tool.id})") 130 - return tool 131 - 132 - def add_search_tool_to_void(): 133 - """Add search tool to the void agent.""" 134 - 135 - # Create client 136 - client = Letta(token=os.environ["LETTA_API_KEY"]) 137 - 138 - logger.info("Adding search tool to void agent...") 139 - 140 - # Create the search tool 141 - search_tool = create_search_posts_tool(client) 142 - 143 - # Find the void agent 144 - agents = client.agents.list(name="void") 145 - if not agents: 146 - print("❌ Void agent not found") 147 - return 148 - 149 - void_agent = agents[0] 150 - 151 - # Get current tools 152 - current_tools = client.agents.tools.list(agent_id=void_agent.id) 153 - tool_names = [tool.name for tool in current_tools] 154 - 155 - # Add search tool if not already present 156 - if search_tool.name not in tool_names: 157 - client.agents.tools.attach(agent_id=void_agent.id, tool_id=search_tool.id) 158 - logger.info(f"Added {search_tool.name} to void agent") 159 - print(f"✅ Added search_bluesky_posts tool to void agent!") 160 - print(f"\nVoid agent can now search Bluesky posts:") 161 - print(f" - Basic search: 'Search for posts about AI safety'") 162 - print(f" - Author filter: 'Search posts by @cameron.pfiffer.org about letta'") 163 - print(f" - Top posts: 'Search top posts about ATProto'") 164 - else: 165 - logger.info(f"Tool {search_tool.name} already attached to void agent") 166 - print(f"✅ Search tool already present on void agent") 167 - 168 - def main(): 169 - """Main function.""" 170 - try: 171 - add_search_tool_to_void() 172 - except Exception as e: 173 - logger.error(f"Error: {e}") 174 - print(f"❌ Error: {e}") 175 - 176 - if __name__ == "__main__": 177 - main()
+185
register_tools.py
··· 1 + #!/usr/bin/env python3 2 + """Register all Void tools with a Letta agent.""" 3 + import os 4 + import sys 5 + import logging 6 + from typing import List 7 + from dotenv import load_dotenv 8 + from letta_client import Letta 9 + from rich.console import Console 10 + from rich.table import Table 11 + 12 + # Import standalone functions 13 + from tools.functions import ( 14 + search_bluesky_posts, 15 + post_to_bluesky, 16 + get_bluesky_feed, 17 + attach_user_blocks, 18 + detach_user_blocks, 19 + update_user_blocks, 20 + ) 21 + 22 + # Import Pydantic models for args_schema 23 + from tools.search import SearchArgs 24 + from tools.post import PostArgs 25 + from tools.feed import FeedArgs 26 + from tools.blocks import AttachUserBlockArgs, DetachUserBlockArgs, UpdateUserBlockArgs 27 + 28 + load_dotenv() 29 + logging.basicConfig(level=logging.INFO) 30 + logger = logging.getLogger(__name__) 31 + console = Console() 32 + 33 + 34 + # Tool configurations: function paired with its args_schema and metadata 35 + TOOL_CONFIGS = [ 36 + { 37 + "func": search_bluesky_posts, 38 + "args_schema": SearchArgs, 39 + "description": "Search for posts on Bluesky matching the given criteria", 40 + "tags": ["bluesky", "search", "posts"] 41 + }, 42 + { 43 + "func": post_to_bluesky, 44 + "args_schema": PostArgs, 45 + "description": "Post a message to Bluesky", 46 + "tags": ["bluesky", "post", "create"] 47 + }, 48 + { 49 + "func": get_bluesky_feed, 50 + "args_schema": FeedArgs, 51 + "description": "Retrieve a Bluesky feed (home timeline or custom feed)", 52 + "tags": ["bluesky", "feed", "timeline"] 53 + }, 54 + { 55 + "func": attach_user_blocks, 56 + "args_schema": AttachUserBlockArgs, 57 + "description": "Attach user-specific memory blocks to the agent. Creates blocks if they don't exist.", 58 + "tags": ["memory", "blocks", "user"] 59 + }, 60 + { 61 + "func": detach_user_blocks, 62 + "args_schema": DetachUserBlockArgs, 63 + "description": "Detach user-specific memory blocks from the agent. Blocks are preserved for later use.", 64 + "tags": ["memory", "blocks", "user"] 65 + }, 66 + { 67 + "func": update_user_blocks, 68 + "args_schema": UpdateUserBlockArgs, 69 + "description": "Update the content of user-specific memory blocks", 70 + "tags": ["memory", "blocks", "user"] 71 + }, 72 + ] 73 + 74 + 75 + def register_tools(agent_name: str = "void", tools: List[str] = None): 76 + """Register tools with a Letta agent. 77 + 78 + Args: 79 + agent_name: Name of the agent to attach tools to 80 + tools: List of tool names to register. If None, registers all tools. 81 + """ 82 + try: 83 + # Initialize Letta client with API key 84 + client = Letta(token=os.environ["LETTA_API_KEY"]) 85 + 86 + # Find the agent 87 + agents = client.agents.list() 88 + agent = None 89 + for a in agents: 90 + if a.name == agent_name: 91 + agent = a 92 + break 93 + 94 + if not agent: 95 + console.print(f"[red]Error: Agent '{agent_name}' not found[/red]") 96 + console.print("\nAvailable agents:") 97 + for a in agents: 98 + console.print(f" - {a.name}") 99 + return 100 + 101 + # Filter tools if specific ones requested 102 + tools_to_register = TOOL_CONFIGS 103 + if tools: 104 + tools_to_register = [t for t in TOOL_CONFIGS if t["func"].__name__ in tools] 105 + if len(tools_to_register) != len(tools): 106 + missing = set(tools) - {t["func"].__name__ for t in tools_to_register} 107 + console.print(f"[yellow]Warning: Unknown tools: {missing}[/yellow]") 108 + 109 + # Create results table 110 + table = Table(title=f"Tool Registration for Agent '{agent_name}'") 111 + table.add_column("Tool", style="cyan") 112 + table.add_column("Status", style="green") 113 + table.add_column("Description") 114 + 115 + # Register each tool 116 + for tool_config in tools_to_register: 117 + func = tool_config["func"] 118 + tool_name = func.__name__ 119 + 120 + try: 121 + # Create or update the tool using the standalone function 122 + created_tool = client.tools.upsert_from_function( 123 + func=func, 124 + args_schema=tool_config["args_schema"], 125 + tags=tool_config["tags"] 126 + ) 127 + 128 + # Get current agent tools 129 + current_tools = client.agents.tools.list(agent_id=str(agent.id)) 130 + tool_names = [t.name for t in current_tools] 131 + 132 + # Check if already attached 133 + if created_tool.name in tool_names: 134 + table.add_row(tool_name, "Already Attached", tool_config["description"]) 135 + else: 136 + # Attach to agent 137 + client.agents.tools.attach( 138 + agent_id=str(agent.id), 139 + tool_id=str(created_tool.id) 140 + ) 141 + table.add_row(tool_name, "✓ Attached", tool_config["description"]) 142 + 143 + except Exception as e: 144 + table.add_row(tool_name, f"✗ Error: {str(e)}", tool_config["description"]) 145 + logger.error(f"Error registering tool {tool_name}: {e}") 146 + 147 + console.print(table) 148 + 149 + except Exception as e: 150 + console.print(f"[red]Error: {str(e)}[/red]") 151 + logger.error(f"Fatal error: {e}") 152 + 153 + 154 + def list_available_tools(): 155 + """List all available tools.""" 156 + table = Table(title="Available Void Tools") 157 + table.add_column("Tool Name", style="cyan") 158 + table.add_column("Description") 159 + table.add_column("Tags", style="dim") 160 + 161 + for tool_config in TOOL_CONFIGS: 162 + table.add_row( 163 + tool_config["func"].__name__, 164 + tool_config["description"], 165 + ", ".join(tool_config["tags"]) 166 + ) 167 + 168 + console.print(table) 169 + 170 + 171 + if __name__ == "__main__": 172 + import argparse 173 + 174 + parser = argparse.ArgumentParser(description="Register Void tools with a Letta agent") 175 + parser.add_argument("agent", nargs="?", default="void", help="Agent name (default: void)") 176 + parser.add_argument("--tools", nargs="+", help="Specific tools to register (default: all)") 177 + parser.add_argument("--list", action="store_true", help="List available tools") 178 + 179 + args = parser.parse_args() 180 + 181 + if args.list: 182 + list_available_tools() 183 + else: 184 + console.print(f"\n[bold]Registering tools for agent: {args.agent}[/bold]\n") 185 + register_tools(args.agent, args.tools)
+32
tools/__init__.py
··· 1 + """Void tools for Bluesky interaction.""" 2 + from .functions import ( 3 + search_bluesky_posts, 4 + post_to_bluesky, 5 + get_bluesky_feed, 6 + attach_user_blocks, 7 + detach_user_blocks, 8 + update_user_blocks, 9 + ) 10 + 11 + # Also export Pydantic models for external use 12 + from .search import SearchArgs 13 + from .post import PostArgs 14 + from .feed import FeedArgs 15 + from .blocks import AttachUserBlockArgs, DetachUserBlockArgs, UpdateUserBlockArgs 16 + 17 + __all__ = [ 18 + # Functions 19 + "search_bluesky_posts", 20 + "post_to_bluesky", 21 + "get_bluesky_feed", 22 + "attach_user_blocks", 23 + "detach_user_blocks", 24 + "update_user_blocks", 25 + # Pydantic models 26 + "SearchArgs", 27 + "PostArgs", 28 + "FeedArgs", 29 + "AttachUserBlockArgs", 30 + "DetachUserBlockArgs", 31 + "UpdateUserBlockArgs", 32 + ]
+192
tools/blocks.py
··· 1 + """Block management tools for user-specific memory blocks.""" 2 + import logging 3 + from typing import List, Type 4 + from pydantic import BaseModel, Field 5 + from letta_client.client import BaseTool 6 + from letta_client import Letta 7 + 8 + 9 + logger = logging.getLogger(__name__) 10 + 11 + 12 + class AttachUserBlockArgs(BaseModel): 13 + handles: List[str] = Field(..., description="List of user Bluesky handles (e.g., ['user1.bsky.social', 'user2.bsky.social'])") 14 + 15 + 16 + class AttachUserBlockTool(BaseTool): 17 + name: str = "attach_user_blocks" 18 + args_schema: Type[BaseModel] = AttachUserBlockArgs 19 + description: str = "Attach user-specific memory blocks to the agent. Creates blocks if they don't exist." 20 + tags: List[str] = ["memory", "blocks", "user"] 21 + 22 + def run(self, handles: List[str], agent_state: "AgentState") -> str: 23 + """Attach user-specific memory blocks.""" 24 + import os 25 + from letta_client import Letta 26 + 27 + try: 28 + client = Letta(token=os.environ["LETTA_API_KEY"]) 29 + results = [] 30 + 31 + # Get current blocks 32 + current_blocks = agent_state.block_ids 33 + current_block_labels = set() 34 + for block_id in current_blocks: 35 + block = client.blocks.get(block_id) 36 + current_block_labels.add(block.label) 37 + 38 + for handle in handles: 39 + # Sanitize handle for block label - completely self-contained 40 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') 41 + block_label = f"user_{clean_handle}" 42 + 43 + # Skip if already attached 44 + if block_label in current_block_labels: 45 + results.append(f"✓ {handle}: Already attached") 46 + continue 47 + 48 + # Check if block exists or create new one 49 + try: 50 + blocks = client.blocks.list(label=block_label) 51 + if blocks and len(blocks) > 0: 52 + block = blocks[0] 53 + logger.info(f"Found existing block: {block_label}") 54 + else: 55 + block = client.blocks.create( 56 + label=block_label, 57 + value=f"# User: {handle}\n\nNo information about this user yet.", 58 + limit=5000 59 + ) 60 + logger.info(f"Created new block: {block_label}") 61 + 62 + # Attach block individually to avoid race conditions 63 + client.agents.blocks.attach( 64 + agent_id=str(agent_state.id), 65 + block_id=str(block.id), 66 + enable_sleeptime=False, 67 + ) 68 + results.append(f"✓ {handle}: Block attached") 69 + 70 + except Exception as e: 71 + results.append(f"✗ {handle}: Error - {str(e)}") 72 + logger.error(f"Error processing block for {handle}: {e}") 73 + 74 + return f"Attachment results:\n" + "\n".join(results) 75 + 76 + except Exception as e: 77 + logger.error(f"Error attaching user blocks: {e}") 78 + raise e 79 + 80 + 81 + class DetachUserBlockArgs(BaseModel): 82 + handles: List[str] = Field(..., description="List of user Bluesky handles (e.g., ['user1.bsky.social', 'user2.bsky.social'])") 83 + 84 + 85 + class DetachUserBlockTool(BaseTool): 86 + name: str = "detach_user_blocks" 87 + args_schema: Type[BaseModel] = DetachUserBlockArgs 88 + description: str = "Detach user-specific memory blocks from the agent. Blocks are preserved for later use." 89 + tags: List[str] = ["memory", "blocks", "user"] 90 + 91 + def run(self, handles: List[str], agent_state: "AgentState") -> str: 92 + """Detach user-specific memory blocks.""" 93 + import os 94 + from letta_client import Letta 95 + 96 + try: 97 + client = Letta(token=os.environ["LETTA_API_KEY"]) 98 + results = [] 99 + blocks_to_remove = set() 100 + 101 + # Build mapping of block labels to IDs 102 + current_blocks = agent_state.block_ids 103 + block_label_to_id = {} 104 + 105 + for block_id in current_blocks: 106 + block = client.blocks.get(block_id) 107 + block_label_to_id[block.label] = block_id 108 + 109 + # Process each handle 110 + for handle in handles: 111 + # Sanitize handle for block label - completely self-contained 112 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') 113 + block_label = f"user_{clean_handle}" 114 + 115 + if block_label in block_label_to_id: 116 + blocks_to_remove.add(block_label_to_id[block_label]) 117 + results.append(f"✓ {handle}: Detached") 118 + else: 119 + results.append(f"✗ {handle}: Not attached") 120 + 121 + # Remove blocks from agent one by one 122 + for block_id in blocks_to_remove: 123 + client.agents.blocks.detach( 124 + agent_id=str(agent_state.id), 125 + block_id=block_id 126 + ) 127 + 128 + return f"Detachment results:\n" + "\n".join(results) 129 + 130 + except Exception as e: 131 + logger.error(f"Error detaching user blocks: {e}") 132 + return f"Error detaching user blocks: {str(e)}" 133 + 134 + 135 + class UserBlockUpdate(BaseModel): 136 + handle: str = Field(..., description="User's Bluesky handle (e.g., 'user.bsky.social')") 137 + content: str = Field(..., description="New content for the user's memory block") 138 + 139 + 140 + class UpdateUserBlockArgs(BaseModel): 141 + updates: List[UserBlockUpdate] = Field(..., description="List of user block updates") 142 + 143 + 144 + class UpdateUserBlockTool(BaseTool): 145 + name: str = "update_user_blocks" 146 + args_schema: Type[BaseModel] = UpdateUserBlockArgs 147 + description: str = "Update the content of user-specific memory blocks" 148 + tags: List[str] = ["memory", "blocks", "user"] 149 + 150 + def run(self, updates: List[UserBlockUpdate]) -> str: 151 + """Update user-specific memory blocks.""" 152 + import os 153 + from letta_client import Letta 154 + 155 + try: 156 + client = Letta(token=os.environ["LETTA_API_KEY"]) 157 + results = [] 158 + 159 + for update in updates: 160 + handle = update.handle 161 + new_content = update.content 162 + # Sanitize handle for block label - completely self-contained 163 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') 164 + block_label = f"user_{clean_handle}" 165 + 166 + try: 167 + # Find the block 168 + blocks = client.blocks.list(label=block_label) 169 + if not blocks or len(blocks) == 0: 170 + results.append(f"✗ {handle}: Block not found - use attach_user_blocks first") 171 + continue 172 + 173 + block = blocks[0] 174 + 175 + # Update block content 176 + updated_block = client.blocks.modify( 177 + block_id=str(block.id), 178 + value=new_content 179 + ) 180 + 181 + preview = new_content[:100] + "..." if len(new_content) > 100 else new_content 182 + results.append(f"✓ {handle}: Updated - {preview}") 183 + 184 + except Exception as e: 185 + results.append(f"✗ {handle}: Error - {str(e)}") 186 + logger.error(f"Error updating block for {handle}: {e}") 187 + 188 + return f"Update results:\n" + "\n".join(results) 189 + 190 + except Exception as e: 191 + logger.error(f"Error updating user blocks: {e}") 192 + return f"Error updating user blocks: {str(e)}"
+139
tools/feed.py
··· 1 + """Feed tool for retrieving Bluesky feeds.""" 2 + from typing import List, Type, Optional 3 + from pydantic import BaseModel, Field 4 + from letta_client.client import BaseTool 5 + 6 + 7 + class FeedArgs(BaseModel): 8 + feed_uri: Optional[str] = Field(None, description="Custom feed URI (e.g., 'at://did:plc:abc/app.bsky.feed.generator/feed-name'). If not provided, returns home timeline") 9 + max_posts: int = Field(default=25, description="Maximum number of posts to retrieve (max 100)") 10 + 11 + 12 + class GetBlueskyFeedTool(BaseTool): 13 + name: str = "get_bluesky_feed" 14 + args_schema: Type[BaseModel] = FeedArgs 15 + description: str = "Retrieve a Bluesky feed (home timeline or custom feed)" 16 + tags: List[str] = ["bluesky", "feed", "timeline"] 17 + 18 + def run(self, feed_uri: Optional[str] = None, max_posts: int = 25) -> str: 19 + """Retrieve a Bluesky feed.""" 20 + import os 21 + import yaml 22 + import requests 23 + 24 + try: 25 + # Validate inputs 26 + max_posts = min(max_posts, 100) 27 + 28 + # Get credentials from environment 29 + username = os.getenv("BSKY_USERNAME") 30 + password = os.getenv("BSKY_PASSWORD") 31 + pds_host = os.getenv("PDS_URI", "https://bsky.social") 32 + 33 + if not username or not password: 34 + return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set" 35 + 36 + # Create session 37 + session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 38 + session_data = { 39 + "identifier": username, 40 + "password": password 41 + } 42 + 43 + try: 44 + session_response = requests.post(session_url, json=session_data, timeout=10) 45 + session_response.raise_for_status() 46 + session = session_response.json() 47 + access_token = session.get("accessJwt") 48 + 49 + if not access_token: 50 + return "Error: Failed to get access token from session" 51 + except Exception as e: 52 + return f"Error: Authentication failed. ({str(e)})" 53 + 54 + # Get feed 55 + headers = {"Authorization": f"Bearer {access_token}"} 56 + 57 + if feed_uri: 58 + # Custom feed 59 + feed_url = f"{pds_host}/xrpc/app.bsky.feed.getFeed" 60 + params = { 61 + "feed": feed_uri, 62 + "limit": max_posts 63 + } 64 + feed_type = "custom" 65 + feed_name = feed_uri.split('/')[-1] if '/' in feed_uri else feed_uri 66 + else: 67 + # Home timeline 68 + feed_url = f"{pds_host}/xrpc/app.bsky.feed.getTimeline" 69 + params = { 70 + "limit": max_posts 71 + } 72 + feed_type = "home" 73 + feed_name = "timeline" 74 + 75 + try: 76 + response = requests.get(feed_url, headers=headers, params=params, timeout=10) 77 + response.raise_for_status() 78 + feed_data = response.json() 79 + except Exception as e: 80 + return f"Error: Failed to get feed. ({str(e)})" 81 + 82 + # Format posts 83 + posts = [] 84 + for item in feed_data.get("feed", []): 85 + post = item.get("post", {}) 86 + author = post.get("author", {}) 87 + record = post.get("record", {}) 88 + 89 + post_data = { 90 + "author": { 91 + "handle": author.get("handle", ""), 92 + "display_name": author.get("displayName", ""), 93 + }, 94 + "text": record.get("text", ""), 95 + "created_at": record.get("createdAt", ""), 96 + "uri": post.get("uri", ""), 97 + "cid": post.get("cid", ""), 98 + "like_count": post.get("likeCount", 0), 99 + "repost_count": post.get("repostCount", 0), 100 + "reply_count": post.get("replyCount", 0), 101 + } 102 + 103 + # Add repost info if present 104 + if "reason" in item and item["reason"]: 105 + reason = item["reason"] 106 + if reason.get("$type") == "app.bsky.feed.defs#reasonRepost": 107 + by = reason.get("by", {}) 108 + post_data["reposted_by"] = { 109 + "handle": by.get("handle", ""), 110 + "display_name": by.get("displayName", ""), 111 + } 112 + 113 + # Add reply info if present 114 + if "reply" in record and record["reply"]: 115 + parent = record["reply"].get("parent", {}) 116 + post_data["reply_to"] = { 117 + "uri": parent.get("uri", ""), 118 + "cid": parent.get("cid", ""), 119 + } 120 + 121 + posts.append(post_data) 122 + 123 + # Format response 124 + feed_result = { 125 + "feed": { 126 + "type": feed_type, 127 + "name": feed_name, 128 + "post_count": len(posts), 129 + "posts": posts 130 + } 131 + } 132 + 133 + if feed_uri: 134 + feed_result["feed"]["uri"] = feed_uri 135 + 136 + return yaml.dump(feed_result, default_flow_style=False, sort_keys=False) 137 + 138 + except Exception as e: 139 + return f"Error retrieving feed: {str(e)}"
+571
tools/functions.py
··· 1 + """Standalone tool functions for Void Bluesky agent.""" 2 + 3 + 4 + def search_bluesky_posts(query: str, max_results: int = 25, author: str = None, sort: str = "latest") -> str: 5 + """ 6 + Search for posts on Bluesky matching the given criteria. 7 + 8 + Args: 9 + query: Search query string 10 + max_results: Maximum number of results to return (max 100) 11 + author: Filter by author handle (e.g., 'user.bsky.social') 12 + sort: Sort order: 'latest' or 'top' 13 + 14 + Returns: 15 + YAML-formatted search results with posts and metadata 16 + """ 17 + import os 18 + import yaml 19 + import requests 20 + from datetime import datetime 21 + 22 + try: 23 + # Validate inputs 24 + max_results = min(max_results, 100) 25 + if sort not in ["latest", "top"]: 26 + sort = "latest" 27 + 28 + # Build search query 29 + search_query = query 30 + if author: 31 + search_query = f"from:{author} {query}" 32 + 33 + # Get credentials from environment 34 + username = os.getenv("BSKY_USERNAME") 35 + password = os.getenv("BSKY_PASSWORD") 36 + pds_host = os.getenv("PDS_URI", "https://bsky.social") 37 + 38 + if not username or not password: 39 + return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set" 40 + 41 + # Create session 42 + session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 43 + session_data = { 44 + "identifier": username, 45 + "password": password 46 + } 47 + 48 + try: 49 + session_response = requests.post(session_url, json=session_data, timeout=10) 50 + session_response.raise_for_status() 51 + session = session_response.json() 52 + access_token = session.get("accessJwt") 53 + 54 + if not access_token: 55 + return "Error: Failed to get access token from session" 56 + except Exception as e: 57 + return f"Error: Authentication failed. ({str(e)})" 58 + 59 + # Search posts 60 + headers = {"Authorization": f"Bearer {access_token}"} 61 + search_url = f"{pds_host}/xrpc/app.bsky.feed.searchPosts" 62 + params = { 63 + "q": search_query, 64 + "limit": max_results, 65 + "sort": sort 66 + } 67 + 68 + try: 69 + response = requests.get(search_url, headers=headers, params=params, timeout=10) 70 + response.raise_for_status() 71 + search_data = response.json() 72 + except Exception as e: 73 + return f"Error: Search failed. ({str(e)})" 74 + 75 + # Format results 76 + results = [] 77 + for post in search_data.get("posts", []): 78 + author = post.get("author", {}) 79 + record = post.get("record", {}) 80 + 81 + post_data = { 82 + "author": { 83 + "handle": author.get("handle", ""), 84 + "display_name": author.get("displayName", ""), 85 + }, 86 + "text": record.get("text", ""), 87 + "created_at": record.get("createdAt", ""), 88 + "uri": post.get("uri", ""), 89 + "cid": post.get("cid", ""), 90 + "like_count": post.get("likeCount", 0), 91 + "repost_count": post.get("repostCount", 0), 92 + "reply_count": post.get("replyCount", 0), 93 + } 94 + 95 + # Add reply info if present 96 + if "reply" in record and record["reply"]: 97 + post_data["reply_to"] = { 98 + "uri": record["reply"].get("parent", {}).get("uri", ""), 99 + "cid": record["reply"].get("parent", {}).get("cid", ""), 100 + } 101 + 102 + results.append(post_data) 103 + 104 + return yaml.dump({ 105 + "search_results": { 106 + "query": query, 107 + "author_filter": author, 108 + "sort": sort, 109 + "result_count": len(results), 110 + "posts": results 111 + } 112 + }, default_flow_style=False, sort_keys=False) 113 + 114 + except Exception as e: 115 + return f"Error searching Bluesky: {str(e)}" 116 + 117 + 118 + def post_to_bluesky(text: str) -> str: 119 + """Post a message to Bluesky.""" 120 + import os 121 + import requests 122 + from datetime import datetime, timezone 123 + 124 + try: 125 + # Validate character limit 126 + if len(text) > 300: 127 + return f"Error: Post exceeds 300 character limit (current: {len(text)} characters)" 128 + 129 + # Get credentials from environment 130 + username = os.getenv("BSKY_USERNAME") 131 + password = os.getenv("BSKY_PASSWORD") 132 + pds_host = os.getenv("PDS_URI", "https://bsky.social") 133 + 134 + if not username or not password: 135 + return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set" 136 + 137 + # Create session 138 + session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 139 + session_data = { 140 + "identifier": username, 141 + "password": password 142 + } 143 + 144 + session_response = requests.post(session_url, json=session_data, timeout=10) 145 + session_response.raise_for_status() 146 + session = session_response.json() 147 + access_token = session.get("accessJwt") 148 + user_did = session.get("did") 149 + 150 + if not access_token or not user_did: 151 + return "Error: Failed to get access token or DID from session" 152 + 153 + # Build post record with facets for mentions and URLs 154 + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") 155 + 156 + post_record = { 157 + "$type": "app.bsky.feed.post", 158 + "text": text, 159 + "createdAt": now, 160 + } 161 + 162 + # Add facets for mentions and URLs 163 + import re 164 + facets = [] 165 + 166 + # Parse mentions 167 + mention_regex = rb"[$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" 168 + text_bytes = text.encode("UTF-8") 169 + 170 + for m in re.finditer(mention_regex, text_bytes): 171 + handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix 172 + try: 173 + resolve_resp = requests.get( 174 + f"{pds_host}/xrpc/com.atproto.identity.resolveHandle", 175 + params={"handle": handle}, 176 + timeout=5 177 + ) 178 + if resolve_resp.status_code == 200: 179 + did = resolve_resp.json()["did"] 180 + facets.append({ 181 + "index": { 182 + "byteStart": m.start(1), 183 + "byteEnd": m.end(1), 184 + }, 185 + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}], 186 + }) 187 + except: 188 + continue 189 + 190 + # Parse URLs 191 + url_regex = rb"[$|\W](https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)" 192 + 193 + for m in re.finditer(url_regex, text_bytes): 194 + url = m.group(1).decode("UTF-8") 195 + facets.append({ 196 + "index": { 197 + "byteStart": m.start(1), 198 + "byteEnd": m.end(1), 199 + }, 200 + "features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}], 201 + }) 202 + 203 + if facets: 204 + post_record["facets"] = facets 205 + 206 + # Create the post 207 + create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord" 208 + headers = {"Authorization": f"Bearer {access_token}"} 209 + 210 + create_data = { 211 + "repo": user_did, 212 + "collection": "app.bsky.feed.post", 213 + "record": post_record 214 + } 215 + 216 + post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10) 217 + post_response.raise_for_status() 218 + result = post_response.json() 219 + 220 + post_uri = result.get("uri") 221 + handle = session.get("handle", username) 222 + rkey = post_uri.split("/")[-1] if post_uri else "" 223 + post_url = f"https://bsky.app/profile/{handle}/post/{rkey}" 224 + 225 + return f"Successfully posted to Bluesky!\nPost URL: {post_url}\nText: {text}" 226 + 227 + except Exception as e: 228 + return f"Error posting to Bluesky: {str(e)}" 229 + 230 + 231 + def get_bluesky_feed(feed_uri: str = None, max_posts: int = 25) -> str: 232 + """ 233 + Retrieve a Bluesky feed (home timeline or custom feed). 234 + 235 + Args: 236 + feed_uri: Custom feed URI (e.g., 'at://did:plc:abc/app.bsky.feed.generator/feed-name'). If not provided, returns home timeline 237 + max_posts: Maximum number of posts to retrieve (max 100) 238 + 239 + Returns: 240 + YAML-formatted feed data with posts and metadata 241 + """ 242 + import os 243 + import yaml 244 + import requests 245 + 246 + try: 247 + # Validate inputs 248 + max_posts = min(max_posts, 100) 249 + 250 + # Get credentials from environment 251 + username = os.getenv("BSKY_USERNAME") 252 + password = os.getenv("BSKY_PASSWORD") 253 + pds_host = os.getenv("PDS_URI", "https://bsky.social") 254 + 255 + if not username or not password: 256 + return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set" 257 + 258 + # Create session 259 + session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 260 + session_data = { 261 + "identifier": username, 262 + "password": password 263 + } 264 + 265 + try: 266 + session_response = requests.post(session_url, json=session_data, timeout=10) 267 + session_response.raise_for_status() 268 + session = session_response.json() 269 + access_token = session.get("accessJwt") 270 + 271 + if not access_token: 272 + return "Error: Failed to get access token from session" 273 + except Exception as e: 274 + return f"Error: Authentication failed. ({str(e)})" 275 + 276 + # Get feed 277 + headers = {"Authorization": f"Bearer {access_token}"} 278 + 279 + if feed_uri: 280 + # Custom feed 281 + feed_url = f"{pds_host}/xrpc/app.bsky.feed.getFeed" 282 + params = { 283 + "feed": feed_uri, 284 + "limit": max_posts 285 + } 286 + feed_type = "custom" 287 + feed_name = feed_uri.split('/')[-1] if '/' in feed_uri else feed_uri 288 + else: 289 + # Home timeline 290 + feed_url = f"{pds_host}/xrpc/app.bsky.feed.getTimeline" 291 + params = { 292 + "limit": max_posts 293 + } 294 + feed_type = "home" 295 + feed_name = "timeline" 296 + 297 + try: 298 + response = requests.get(feed_url, headers=headers, params=params, timeout=10) 299 + response.raise_for_status() 300 + feed_data = response.json() 301 + except Exception as e: 302 + return f"Error: Failed to get feed. ({str(e)})" 303 + 304 + # Format posts 305 + posts = [] 306 + for item in feed_data.get("feed", []): 307 + post = item.get("post", {}) 308 + author = post.get("author", {}) 309 + record = post.get("record", {}) 310 + 311 + post_data = { 312 + "author": { 313 + "handle": author.get("handle", ""), 314 + "display_name": author.get("displayName", ""), 315 + }, 316 + "text": record.get("text", ""), 317 + "created_at": record.get("createdAt", ""), 318 + "uri": post.get("uri", ""), 319 + "cid": post.get("cid", ""), 320 + "like_count": post.get("likeCount", 0), 321 + "repost_count": post.get("repostCount", 0), 322 + "reply_count": post.get("replyCount", 0), 323 + } 324 + 325 + # Add repost info if present 326 + if "reason" in item and item["reason"]: 327 + reason = item["reason"] 328 + if reason.get("$type") == "app.bsky.feed.defs#reasonRepost": 329 + by = reason.get("by", {}) 330 + post_data["reposted_by"] = { 331 + "handle": by.get("handle", ""), 332 + "display_name": by.get("displayName", ""), 333 + } 334 + 335 + # Add reply info if present 336 + if "reply" in record and record["reply"]: 337 + parent = record["reply"].get("parent", {}) 338 + post_data["reply_to"] = { 339 + "uri": parent.get("uri", ""), 340 + "cid": parent.get("cid", ""), 341 + } 342 + 343 + posts.append(post_data) 344 + 345 + # Format response 346 + feed_result = { 347 + "feed": { 348 + "type": feed_type, 349 + "name": feed_name, 350 + "post_count": len(posts), 351 + "posts": posts 352 + } 353 + } 354 + 355 + if feed_uri: 356 + feed_result["feed"]["uri"] = feed_uri 357 + 358 + return yaml.dump(feed_result, default_flow_style=False, sort_keys=False) 359 + 360 + except Exception as e: 361 + return f"Error retrieving feed: {str(e)}" 362 + 363 + 364 + def attach_user_blocks(handles: list, agent_state: "AgentState") -> str: 365 + """ 366 + Attach user-specific memory blocks to the agent. Creates blocks if they don't exist. 367 + 368 + Args: 369 + handles: List of user Bluesky handles (e.g., ['user1.bsky.social', 'user2.bsky.social']) 370 + agent_state: The agent state object containing agent information 371 + 372 + Returns: 373 + String with attachment results for each handle 374 + """ 375 + import os 376 + import logging 377 + from letta_client import Letta 378 + 379 + logger = logging.getLogger(__name__) 380 + 381 + try: 382 + client = Letta(token=os.environ["LETTA_API_KEY"]) 383 + results = [] 384 + 385 + # Get current blocks using the API 386 + current_blocks = client.agents.blocks.list(agent_id=str(agent_state.id)) 387 + current_block_labels = set() 388 + current_block_ids = [] 389 + 390 + for block in current_blocks: 391 + current_block_labels.add(block.label) 392 + current_block_ids.append(str(block.id)) 393 + 394 + # Collect new blocks to attach 395 + new_block_ids = [] 396 + 397 + for handle in handles: 398 + # Sanitize handle for block label - completely self-contained 399 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') 400 + block_label = f"user_{clean_handle}" 401 + 402 + # Skip if already attached 403 + if block_label in current_block_labels: 404 + results.append(f"✓ {handle}: Already attached") 405 + continue 406 + 407 + # Check if block exists or create new one 408 + try: 409 + blocks = client.blocks.list(label=block_label) 410 + if blocks and len(blocks) > 0: 411 + block = blocks[0] 412 + logger.info(f"Found existing block: {block_label}") 413 + else: 414 + block = client.blocks.create( 415 + label=block_label, 416 + value=f"# User: {handle}\n\nNo information about this user yet.", 417 + limit=5000 418 + ) 419 + logger.info(f"Created new block: {block_label}") 420 + 421 + new_block_ids.append(str(block.id)) 422 + results.append(f"✓ {handle}: Block ready to attach") 423 + 424 + except Exception as e: 425 + results.append(f"✗ {handle}: Error - {str(e)}") 426 + logger.error(f"Error processing block for {handle}: {e}") 427 + 428 + # Attach all new blocks at once if there are any 429 + if new_block_ids: 430 + try: 431 + all_block_ids = current_block_ids + new_block_ids 432 + client.agents.modify( 433 + agent_id=str(agent_state.id), 434 + block_ids=all_block_ids 435 + ) 436 + logger.info(f"Successfully attached {len(new_block_ids)} new blocks to agent") 437 + except Exception as e: 438 + logger.error(f"Error attaching blocks to agent: {e}") 439 + return f"Error attaching blocks to agent: {str(e)}" 440 + 441 + return f"Attachment results:\n" + "\n".join(results) 442 + 443 + except Exception as e: 444 + logger.error(f"Error attaching user blocks: {e}") 445 + return f"Error attaching user blocks: {str(e)}" 446 + 447 + 448 + def detach_user_blocks(handles: list, agent_state: "AgentState") -> str: 449 + """ 450 + Detach user-specific memory blocks from the agent. Blocks are preserved for later use. 451 + 452 + Args: 453 + handles: List of user Bluesky handles (e.g., ['user1.bsky.social', 'user2.bsky.social']) 454 + agent_state: The agent state object containing agent information 455 + 456 + Returns: 457 + String with detachment results for each handle 458 + """ 459 + import os 460 + import logging 461 + from letta_client import Letta 462 + 463 + logger = logging.getLogger(__name__) 464 + 465 + try: 466 + client = Letta(token=os.environ["LETTA_API_KEY"]) 467 + results = [] 468 + blocks_to_remove = set() 469 + 470 + # Build mapping of block labels to IDs using the API 471 + current_blocks = client.agents.blocks.list(agent_id=str(agent_state.id)) 472 + block_label_to_id = {} 473 + all_block_ids = [] 474 + 475 + for block in current_blocks: 476 + block_label_to_id[block.label] = str(block.id) 477 + all_block_ids.append(str(block.id)) 478 + 479 + # Process each handle and collect blocks to remove 480 + for handle in handles: 481 + # Sanitize handle for block label - completely self-contained 482 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') 483 + block_label = f"user_{clean_handle}" 484 + 485 + if block_label in block_label_to_id: 486 + blocks_to_remove.add(block_label_to_id[block_label]) 487 + results.append(f"✓ {handle}: Marked for detachment") 488 + else: 489 + results.append(f"✗ {handle}: Not attached") 490 + 491 + # Remove all marked blocks at once if there are any 492 + if blocks_to_remove: 493 + try: 494 + # Filter out the blocks to remove 495 + remaining_block_ids = [bid for bid in all_block_ids if bid not in blocks_to_remove] 496 + client.agents.modify( 497 + agent_id=str(agent_state.id), 498 + block_ids=remaining_block_ids 499 + ) 500 + logger.info(f"Successfully detached {len(blocks_to_remove)} blocks from agent") 501 + except Exception as e: 502 + logger.error(f"Error detaching blocks from agent: {e}") 503 + return f"Error detaching blocks from agent: {str(e)}" 504 + 505 + return f"Detachment results:\n" + "\n".join(results) 506 + 507 + except Exception as e: 508 + logger.error(f"Error detaching user blocks: {e}") 509 + return f"Error detaching user blocks: {str(e)}" 510 + 511 + 512 + def update_user_blocks(updates: list, agent_state: "AgentState" = None) -> str: 513 + """ 514 + Update the content of user-specific memory blocks. 515 + 516 + Args: 517 + updates: List of dictionaries with 'handle' and 'content' keys 518 + agent_state: The agent state object (optional, used for consistency) 519 + 520 + Returns: 521 + String with update results for each handle 522 + """ 523 + import os 524 + import logging 525 + from letta_client import Letta 526 + 527 + logger = logging.getLogger(__name__) 528 + 529 + try: 530 + client = Letta(token=os.environ["LETTA_API_KEY"]) 531 + results = [] 532 + 533 + for update in updates: 534 + handle = update.get('handle') 535 + new_content = update.get('content') 536 + 537 + if not handle or not new_content: 538 + results.append(f"✗ Invalid update: missing handle or content") 539 + continue 540 + 541 + # Sanitize handle for block label - completely self-contained 542 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') 543 + block_label = f"user_{clean_handle}" 544 + 545 + try: 546 + # Find the block 547 + blocks = client.blocks.list(label=block_label) 548 + if not blocks or len(blocks) == 0: 549 + results.append(f"✗ {handle}: Block not found - use attach_user_blocks first") 550 + continue 551 + 552 + block = blocks[0] 553 + 554 + # Update block content 555 + updated_block = client.blocks.modify( 556 + block_id=str(block.id), 557 + value=new_content 558 + ) 559 + 560 + preview = new_content[:100] + "..." if len(new_content) > 100 else new_content 561 + results.append(f"✓ {handle}: Updated - {preview}") 562 + 563 + except Exception as e: 564 + results.append(f"✗ {handle}: Error - {str(e)}") 565 + logger.error(f"Error updating block for {handle}: {e}") 566 + 567 + return f"Update results:\n" + "\n".join(results) 568 + 569 + except Exception as e: 570 + logger.error(f"Error updating user blocks: {e}") 571 + return f"Error updating user blocks: {str(e)}"
+120
tools/search.py
··· 1 + """Search tool for Bluesky posts.""" 2 + from typing import List, Type, Optional 3 + from pydantic import BaseModel, Field 4 + from letta_client.client import BaseTool 5 + 6 + 7 + class SearchArgs(BaseModel): 8 + query: str = Field(..., description="Search query string") 9 + max_results: int = Field(default=25, description="Maximum number of results to return (max 100)") 10 + author: Optional[str] = Field(None, description="Filter by author handle (e.g., 'user.bsky.social')") 11 + sort: str = Field(default="latest", description="Sort order: 'latest' or 'top'") 12 + 13 + 14 + class SearchBlueskyTool(BaseTool): 15 + name: str = "search_bluesky_posts" 16 + args_schema: Type[BaseModel] = SearchArgs 17 + description: str = "Search for posts on Bluesky matching the given criteria" 18 + tags: List[str] = ["bluesky", "search", "posts"] 19 + 20 + def run(self, query: str, max_results: int = 25, author: Optional[str] = None, sort: str = "latest") -> str: 21 + """Search for posts on Bluesky.""" 22 + import os 23 + import yaml 24 + import requests 25 + from datetime import datetime 26 + 27 + try: 28 + # Validate inputs 29 + max_results = min(max_results, 100) 30 + if sort not in ["latest", "top"]: 31 + sort = "latest" 32 + 33 + # Build search query 34 + search_query = query 35 + if author: 36 + search_query = f"from:{author} {query}" 37 + 38 + # Get credentials from environment 39 + username = os.getenv("BSKY_USERNAME") 40 + password = os.getenv("BSKY_PASSWORD") 41 + pds_host = os.getenv("PDS_URI", "https://bsky.social") 42 + 43 + if not username or not password: 44 + return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set" 45 + 46 + # Create session 47 + session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 48 + session_data = { 49 + "identifier": username, 50 + "password": password 51 + } 52 + 53 + try: 54 + session_response = requests.post(session_url, json=session_data, timeout=10) 55 + session_response.raise_for_status() 56 + session = session_response.json() 57 + access_token = session.get("accessJwt") 58 + 59 + if not access_token: 60 + return "Error: Failed to get access token from session" 61 + except Exception as e: 62 + return f"Error: Authentication failed. ({str(e)})" 63 + 64 + # Search posts 65 + headers = {"Authorization": f"Bearer {access_token}"} 66 + search_url = f"{pds_host}/xrpc/app.bsky.feed.searchPosts" 67 + params = { 68 + "q": search_query, 69 + "limit": max_results, 70 + "sort": sort 71 + } 72 + 73 + try: 74 + response = requests.get(search_url, headers=headers, params=params, timeout=10) 75 + response.raise_for_status() 76 + search_data = response.json() 77 + except Exception as e: 78 + return f"Error: Search failed. ({str(e)})" 79 + 80 + # Format results 81 + results = [] 82 + for post in search_data.get("posts", []): 83 + author = post.get("author", {}) 84 + record = post.get("record", {}) 85 + 86 + post_data = { 87 + "author": { 88 + "handle": author.get("handle", ""), 89 + "display_name": author.get("displayName", ""), 90 + }, 91 + "text": record.get("text", ""), 92 + "created_at": record.get("createdAt", ""), 93 + "uri": post.get("uri", ""), 94 + "cid": post.get("cid", ""), 95 + "like_count": post.get("likeCount", 0), 96 + "repost_count": post.get("repostCount", 0), 97 + "reply_count": post.get("replyCount", 0), 98 + } 99 + 100 + # Add reply info if present 101 + if "reply" in record and record["reply"]: 102 + post_data["reply_to"] = { 103 + "uri": record["reply"].get("parent", {}).get("uri", ""), 104 + "cid": record["reply"].get("parent", {}).get("cid", ""), 105 + } 106 + 107 + results.append(post_data) 108 + 109 + return yaml.dump({ 110 + "search_results": { 111 + "query": query, 112 + "author_filter": author, 113 + "sort": sort, 114 + "result_count": len(results), 115 + "posts": results 116 + } 117 + }, default_flow_style=False, sort_keys=False) 118 + 119 + except Exception as e: 120 + return f"Error searching Bluesky: {str(e)}"