this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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)}"