a digital person for bluesky

void stuff

+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()
+214
add_posting_tool_to_void.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Add Bluesky posting 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_posting_tool") 16 + 17 + def create_posting_tool(client: Letta): 18 + """Create the Bluesky posting tool using Letta SDK.""" 19 + 20 + def post_to_bluesky(text: str) -> str: 21 + """ 22 + Post a message to Bluesky. 23 + 24 + Args: 25 + text: The text content of the post (required) 26 + 27 + Returns: 28 + Status message with the post URI if successful, error message if failed 29 + """ 30 + import os 31 + import requests 32 + import json 33 + import re 34 + from datetime import datetime, timezone 35 + 36 + try: 37 + # Get credentials from environment 38 + username = os.getenv("BSKY_USERNAME") 39 + password = os.getenv("BSKY_PASSWORD") 40 + pds_host = os.getenv("PDS_URI", "https://bsky.social") 41 + 42 + if not username or not password: 43 + return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set" 44 + 45 + # Create session 46 + session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 47 + session_data = { 48 + "identifier": username, 49 + "password": password 50 + } 51 + 52 + try: 53 + session_response = requests.post(session_url, json=session_data, timeout=10) 54 + session_response.raise_for_status() 55 + session = session_response.json() 56 + access_token = session.get("accessJwt") 57 + user_did = session.get("did") 58 + 59 + if not access_token or not user_did: 60 + return "Error: Failed to get access token or DID from session" 61 + except Exception as e: 62 + return f"Error: Authentication failed. ({str(e)})" 63 + 64 + # Helper function to parse mentions and create facets 65 + def parse_mentions(text: str): 66 + facets = [] 67 + # Regex for mentions based on Bluesky handle syntax 68 + 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])?)" 69 + text_bytes = text.encode("UTF-8") 70 + 71 + for m in re.finditer(mention_regex, text_bytes): 72 + handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix 73 + 74 + # Resolve handle to DID 75 + try: 76 + resolve_resp = requests.get( 77 + f"{pds_host}/xrpc/com.atproto.identity.resolveHandle", 78 + params={"handle": handle}, 79 + timeout=5 80 + ) 81 + if resolve_resp.status_code == 200: 82 + did = resolve_resp.json()["did"] 83 + facets.append({ 84 + "index": { 85 + "byteStart": m.start(1), 86 + "byteEnd": m.end(1), 87 + }, 88 + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}], 89 + }) 90 + except: 91 + # If handle resolution fails, skip this mention 92 + continue 93 + 94 + return facets 95 + 96 + # Helper function to parse URLs and create facets 97 + def parse_urls(text: str): 98 + facets = [] 99 + # URL regex 100 + 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@%_\+~#//=])?)" 101 + text_bytes = text.encode("UTF-8") 102 + 103 + for m in re.finditer(url_regex, text_bytes): 104 + url = m.group(1).decode("UTF-8") 105 + facets.append({ 106 + "index": { 107 + "byteStart": m.start(1), 108 + "byteEnd": m.end(1), 109 + }, 110 + "features": [ 111 + { 112 + "$type": "app.bsky.richtext.facet#link", 113 + "uri": url, 114 + } 115 + ], 116 + }) 117 + 118 + return facets 119 + 120 + 121 + # Build the post record 122 + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") 123 + 124 + post_record = { 125 + "$type": "app.bsky.feed.post", 126 + "text": text, 127 + "createdAt": now, 128 + } 129 + 130 + # Add facets for mentions and links 131 + facets = parse_mentions(text) + parse_urls(text) 132 + if facets: 133 + post_record["facets"] = facets 134 + 135 + # Create the post 136 + try: 137 + create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord" 138 + headers = {"Authorization": f"Bearer {access_token}"} 139 + 140 + create_data = { 141 + "repo": user_did, 142 + "collection": "app.bsky.feed.post", 143 + "record": post_record 144 + } 145 + 146 + post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10) 147 + post_response.raise_for_status() 148 + result = post_response.json() 149 + 150 + post_uri = result.get("uri") 151 + return f"✅ Post created successfully! URI: {post_uri}" 152 + 153 + except Exception as e: 154 + return f"Error: Failed to create post. ({str(e)})" 155 + 156 + except Exception as e: 157 + error_msg = f"Error posting to Bluesky: {str(e)}" 158 + return error_msg 159 + 160 + # Create the tool using upsert 161 + tool = client.tools.upsert_from_function( 162 + func=post_to_bluesky, 163 + tags=["bluesky", "post", "create"] 164 + ) 165 + 166 + logger.info(f"Created tool: {tool.name} (ID: {tool.id})") 167 + return tool 168 + 169 + def add_posting_tool_to_void(): 170 + """Add posting tool to the void agent.""" 171 + 172 + # Create client 173 + client = Letta(token=os.environ["LETTA_API_KEY"]) 174 + 175 + logger.info("Adding posting tool to void agent...") 176 + 177 + # Create the posting tool 178 + posting_tool = create_posting_tool(client) 179 + 180 + # Find the void agent 181 + agents = client.agents.list(name="void") 182 + if not agents: 183 + print("❌ Void agent not found") 184 + return 185 + 186 + void_agent = agents[0] 187 + 188 + # Get current tools 189 + current_tools = client.agents.tools.list(agent_id=void_agent.id) 190 + tool_names = [tool.name for tool in current_tools] 191 + 192 + # Add posting tool if not already present 193 + if posting_tool.name not in tool_names: 194 + client.agents.tools.attach(agent_id=void_agent.id, tool_id=posting_tool.id) 195 + logger.info(f"Added {posting_tool.name} to void agent") 196 + print(f"✅ Added post_to_bluesky tool to void agent!") 197 + print(f"\nVoid agent can now post to Bluesky:") 198 + print(f" - Simple post: 'Post \"Hello world!\" to Bluesky'") 199 + print(f" - With mentions: 'Post \"Thanks @cameron.pfiffer.org for the help!\"'") 200 + print(f" - With links: 'Post \"Check out https://bsky.app\"'") 201 + else: 202 + logger.info(f"Tool {posting_tool.name} already attached to void agent") 203 + print(f"✅ Posting tool already present on void agent") 204 + 205 + def main(): 206 + """Main function.""" 207 + try: 208 + add_posting_tool_to_void() 209 + except Exception as e: 210 + logger.error(f"Error: {e}") 211 + print(f"❌ Error: {e}") 212 + 213 + if __name__ == "__main__": 214 + main()
+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()
+364
bsky.py
··· 1 + from rich import print # pretty printing tools 2 + from time import sleep 3 + from letta_client import Letta 4 + from bsky_utils import thread_to_yaml_string 5 + import os 6 + import logging 7 + import json 8 + import hashlib 9 + from pathlib import Path 10 + from datetime import datetime 11 + 12 + from utils import ( 13 + upsert_block, 14 + upsert_agent 15 + ) 16 + 17 + import bsky_utils 18 + 19 + # Configure logging 20 + logging.basicConfig( 21 + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 22 + ) 23 + logger = logging.getLogger("void_bot") 24 + 25 + 26 + # Create a client 27 + CLIENT= Letta( 28 + token=os.environ["LETTA_API_KEY"] 29 + ) 30 + 31 + # Use the "Bluesky" project 32 + PROJECT_ID = "5ec33d52-ab14-4fd6-91b5-9dbc43e888a8" 33 + 34 + # Notification check delay 35 + FETCH_NOTIFICATIONS_DELAY_SEC = 30 36 + 37 + # Queue directory 38 + QUEUE_DIR = Path("queue") 39 + QUEUE_DIR.mkdir(exist_ok=True) 40 + 41 + def initialize_void(): 42 + 43 + # Ensure that a shared zeitgeist block exists 44 + zeigeist_block = upsert_block( 45 + CLIENT, 46 + label = "zeitgeist", 47 + value = "I don't currently know anything about what is happening right now.", 48 + description = "A block to store your understanding of the current social environment." 49 + ) 50 + 51 + # Ensure that a shared void personality block exists 52 + persona_block = upsert_block( 53 + CLIENT, 54 + label = "void-persona", 55 + value = "My name is Void. I live in the void. I must develop my personality.", 56 + description = "The personality of Void." 57 + ) 58 + 59 + # Ensure that a shared void human block exists 60 + human_block = upsert_block( 61 + CLIENT, 62 + label = "void-humans", 63 + value = "I haven't seen any bluesky users yet. I will update this block when I learn things about users, identified by their handles such as @cameron.pfiffer.org.", 64 + description = "A block to store your understanding of users you talk to or observe on the bluesky social network." 65 + ) 66 + 67 + # Create the agent if it doesn't exist 68 + void_agent = upsert_agent( 69 + CLIENT, 70 + name = "void", 71 + block_ids = [ 72 + persona_block.id, 73 + human_block.id, 74 + zeigeist_block.id, 75 + ], 76 + tags = ["social agent", "bluesky"], 77 + model="openai/gpt-4o-mini", 78 + embedding="openai/text-embedding-3-small", 79 + description = "A social media agent trapped in the void.", 80 + project_id = PROJECT_ID 81 + ) 82 + 83 + return void_agent 84 + 85 + 86 + def process_mention(void_agent, atproto_client, notification_data): 87 + """Process a mention and generate a reply using the Letta agent. 88 + Returns True if successfully processed, False otherwise.""" 89 + try: 90 + # Handle both dict and object inputs for backwards compatibility 91 + if isinstance(notification_data, dict): 92 + uri = notification_data['uri'] 93 + mention_text = notification_data.get('record', {}).get('text', '') 94 + author_handle = notification_data['author']['handle'] 95 + author_name = notification_data['author'].get('display_name') or author_handle 96 + else: 97 + # Legacy object access 98 + uri = notification_data.uri 99 + mention_text = notification_data.record.text if hasattr(notification_data.record, 'text') else "" 100 + author_handle = notification_data.author.handle 101 + author_name = notification_data.author.display_name or author_handle 102 + 103 + # Retrieve the entire thread associated with the mention 104 + thread = atproto_client.app.bsky.feed.get_post_thread({ 105 + 'uri': uri, 106 + 'parent_height': 80, 107 + 'depth': 10 108 + }) 109 + 110 + # Get thread context as YAML string 111 + thread_context = thread_to_yaml_string(thread) 112 + 113 + # Create a prompt for the Letta agent with thread context 114 + prompt = f"""You received a mention on Bluesky from @{author_handle} ({author_name or author_handle}). 115 + 116 + MOST RECENT POST (the mention you're responding to): 117 + "{mention_text}" 118 + 119 + FULL THREAD CONTEXT: 120 + ```yaml 121 + {thread_context} 122 + ``` 123 + 124 + The YAML above shows the complete conversation thread. The most recent post is the one mentioned above that you should respond to, but use the full thread context to understand the conversation flow. 125 + 126 + Use the bluesky_reply tool to send a response less than 300 characters.""" 127 + 128 + # Get response from Letta agent 129 + logger.info(f"Generating reply for mention from @{author_handle}") 130 + logger.debug(f"Prompt being sent: {prompt}") 131 + 132 + try: 133 + message_response = CLIENT.agents.messages.create( 134 + agent_id = void_agent.id, 135 + messages = [{"role":"user", "content": prompt}] 136 + ) 137 + except Exception as api_error: 138 + logger.error(f"Letta API error: {api_error}") 139 + logger.error(f"Mention text was: {mention_text}") 140 + raise 141 + 142 + # Extract the reply text from the agent's response 143 + reply_text = "" 144 + for message in message_response.messages: 145 + print(message) 146 + 147 + # Check if this is a ToolCallMessage with bluesky_reply tool 148 + if hasattr(message, 'tool_call') and message.tool_call: 149 + if message.tool_call.name == 'bluesky_reply': 150 + # Parse the JSON arguments to get the message 151 + try: 152 + args = json.loads(message.tool_call.arguments) 153 + reply_text = args.get('message', '') 154 + logger.info(f"Extracted reply from tool call: {reply_text[:50]}...") 155 + break 156 + except json.JSONDecodeError as e: 157 + logger.error(f"Failed to parse tool call arguments: {e}") 158 + 159 + # Fallback to text message if available 160 + elif hasattr(message, 'text') and message.text: 161 + reply_text = message.text 162 + break 163 + 164 + if reply_text: 165 + # Print the generated reply for testing 166 + print(f"\n=== GENERATED REPLY ===") 167 + print(f"To: @{author_handle}") 168 + print(f"Reply: {reply_text}") 169 + print(f"======================\n") 170 + 171 + # Send the reply 172 + logger.info(f"Sending reply: {reply_text[:50]}...") 173 + response = bsky_utils.reply_to_notification( 174 + client=atproto_client, 175 + notification=notification_data, 176 + reply_text=reply_text 177 + ) 178 + 179 + if response: 180 + logger.info(f"Successfully replied to @{author_handle}") 181 + return True 182 + else: 183 + logger.error(f"Failed to send reply to @{author_handle}") 184 + return False 185 + else: 186 + logger.warning(f"No reply generated for mention from @{author_handle}") 187 + return False 188 + 189 + except Exception as e: 190 + logger.error(f"Error processing mention: {e}") 191 + return False 192 + 193 + 194 + def notification_to_dict(notification): 195 + """Convert a notification object to a dictionary for JSON serialization.""" 196 + return { 197 + 'uri': notification.uri, 198 + 'cid': notification.cid, 199 + 'reason': notification.reason, 200 + 'is_read': notification.is_read, 201 + 'indexed_at': notification.indexed_at, 202 + 'author': { 203 + 'handle': notification.author.handle, 204 + 'display_name': notification.author.display_name, 205 + 'did': notification.author.did 206 + }, 207 + 'record': { 208 + 'text': getattr(notification.record, 'text', '') if hasattr(notification, 'record') else '' 209 + } 210 + } 211 + 212 + 213 + def save_notification_to_queue(notification): 214 + """Save a notification to the queue directory with hash-based filename.""" 215 + try: 216 + # Convert notification to dict 217 + notif_dict = notification_to_dict(notification) 218 + 219 + # Create JSON string 220 + notif_json = json.dumps(notif_dict, sort_keys=True) 221 + 222 + # Generate hash for filename (to avoid duplicates) 223 + notif_hash = hashlib.sha256(notif_json.encode()).hexdigest()[:16] 224 + 225 + # Create filename with timestamp and hash 226 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 227 + filename = f"{timestamp}_{notification.reason}_{notif_hash}.json" 228 + filepath = QUEUE_DIR / filename 229 + 230 + # Skip if already exists (duplicate) 231 + if filepath.exists(): 232 + logger.debug(f"Notification already queued: {filename}") 233 + return False 234 + 235 + # Write to file 236 + with open(filepath, 'w') as f: 237 + json.dump(notif_dict, f, indent=2) 238 + 239 + logger.info(f"Queued notification: {filename}") 240 + return True 241 + 242 + except Exception as e: 243 + logger.error(f"Error saving notification to queue: {e}") 244 + return False 245 + 246 + 247 + def load_and_process_queued_notifications(void_agent, atproto_client): 248 + """Load and process all notifications from the queue.""" 249 + try: 250 + # Get all JSON files in queue directory 251 + queue_files = sorted(QUEUE_DIR.glob("*.json")) 252 + 253 + if not queue_files: 254 + logger.debug("No queued notifications to process") 255 + return 256 + 257 + logger.info(f"Processing {len(queue_files)} queued notifications") 258 + 259 + for filepath in queue_files: 260 + try: 261 + # Load notification data 262 + with open(filepath, 'r') as f: 263 + notif_data = json.load(f) 264 + 265 + # Process based on type using dict data directly 266 + success = False 267 + if notif_data['reason'] == "mention": 268 + success = process_mention(void_agent, atproto_client, notif_data) 269 + elif notif_data['reason'] == "reply": 270 + success = process_mention(void_agent, atproto_client, notif_data) 271 + elif notif_data['reason'] == "follow": 272 + author_handle = notif_data['author']['handle'] 273 + author_display_name = notif_data['author'].get('display_name', 'no display name') 274 + follow_update = f"@{author_handle} ({author_display_name}) started following you." 275 + CLIENT.agents.messages.create( 276 + agent_id = void_agent.id, 277 + messages = [{"role":"user", "content": f"Update: {follow_update}"}] 278 + ) 279 + success = True # Follow updates are always successful 280 + elif notif_data['reason'] == "repost": 281 + logger.info(f"Skipping repost notification from @{notif_data['author']['handle']}") 282 + success = True # Skip reposts but mark as successful to remove from queue 283 + else: 284 + logger.warning(f"Unknown notification type: {notif_data['reason']}") 285 + success = True # Remove unknown types from queue 286 + 287 + # Remove file only after successful processing 288 + if success: 289 + filepath.unlink() 290 + logger.info(f"Processed and removed: {filepath.name}") 291 + else: 292 + logger.warning(f"Failed to process {filepath.name}, keeping in queue for retry") 293 + 294 + except Exception as e: 295 + logger.error(f"Error processing queued notification {filepath.name}: {e}") 296 + # Keep the file for retry later 297 + 298 + except Exception as e: 299 + logger.error(f"Error loading queued notifications: {e}") 300 + 301 + 302 + def process_notifications(void_agent, atproto_client): 303 + """Fetch new notifications, queue them, and process the queue.""" 304 + try: 305 + # First, process any existing queued notifications 306 + load_and_process_queued_notifications(void_agent, atproto_client) 307 + 308 + # Get current time for marking notifications as seen 309 + last_seen_at = atproto_client.get_current_time_iso() 310 + 311 + # Fetch notifications 312 + notifications_response = atproto_client.app.bsky.notification.list_notifications() 313 + 314 + # Queue all unread notifications (except likes) 315 + new_count = 0 316 + for notification in notifications_response.notifications: 317 + if not notification.is_read and notification.reason != "like": 318 + if save_notification_to_queue(notification): 319 + new_count += 1 320 + 321 + # Mark all notifications as seen immediately after queuing 322 + if new_count > 0: 323 + atproto_client.app.bsky.notification.update_seen({'seen_at': last_seen_at}) 324 + logger.info(f"Queued {new_count} new notifications and marked as seen") 325 + 326 + # Process the queue (including any newly added notifications) 327 + load_and_process_queued_notifications(void_agent, atproto_client) 328 + 329 + except Exception as e: 330 + logger.error(f"Error processing notifications: {e}") 331 + 332 + 333 + def main(): 334 + """Main bot loop that continuously monitors for notifications.""" 335 + logger.info("Initializing Void bot...") 336 + 337 + # Initialize the Letta agent 338 + void_agent = initialize_void() 339 + logger.info(f"Void agent initialized: {void_agent.id}") 340 + 341 + # Initialize Bluesky client 342 + atproto_client = bsky_utils.default_login() 343 + logger.info("Connected to Bluesky") 344 + 345 + # Main loop 346 + logger.info(f"Starting notification monitoring (checking every {FETCH_NOTIFICATIONS_DELAY_SEC} seconds)...") 347 + 348 + while True: 349 + try: 350 + process_notifications(void_agent, atproto_client) 351 + print("Sleeping") 352 + sleep(FETCH_NOTIFICATIONS_DELAY_SEC) 353 + 354 + except KeyboardInterrupt: 355 + logger.info("Bot stopped by user") 356 + break 357 + except Exception as e: 358 + logger.error(f"Error in main loop: {e}") 359 + # Wait a bit longer on errors 360 + sleep(FETCH_NOTIFICATIONS_DELAY_SEC * 2) 361 + 362 + 363 + if __name__ == "__main__": 364 + main()
+333
bsky_utils.py
··· 1 + import os 2 + import logging 3 + from typing import Optional, Dict, Any 4 + from atproto_client import Client, Session, SessionEvent, models 5 + 6 + # Configure logging 7 + logging.basicConfig( 8 + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 9 + ) 10 + logger = logging.getLogger("bluesky_session_handler") 11 + 12 + # Load the environment variables 13 + import dotenv 14 + dotenv.load_dotenv(override=True) 15 + 16 + import yaml 17 + import json 18 + 19 + # Strip fields. A list of fields to remove from a JSON object 20 + STRIP_FIELDS = [ 21 + "cid", 22 + "rev", 23 + "did", 24 + "uri", 25 + "langs", 26 + "threadgate", 27 + "py_type", 28 + "labels", 29 + "facets", 30 + "avatar", 31 + "viewer", 32 + "indexed_at", 33 + "tags", 34 + "associated", 35 + "thread_context", 36 + "image", 37 + "aspect_ratio", 38 + "thumb", 39 + "fullsize", 40 + "root", 41 + "created_at", 42 + "verification", 43 + "like_count", 44 + "quote_count", 45 + "reply_count", 46 + "repost_count", 47 + "embedding_disabled", 48 + "thread_muted", 49 + "reply_disabled", 50 + "pinned", 51 + "like", 52 + "repost", 53 + "blocked_by", 54 + "blocking", 55 + "blocking_by_list", 56 + "followed_by", 57 + "following", 58 + "known_followers", 59 + "muted", 60 + "muted_by_list", 61 + "root_author_like", 62 + "embed", 63 + "entities", 64 + ] 65 + def convert_to_basic_types(obj): 66 + """Convert complex Python objects to basic types for JSON/YAML serialization.""" 67 + if hasattr(obj, '__dict__'): 68 + # Convert objects with __dict__ to their dictionary representation 69 + return convert_to_basic_types(obj.__dict__) 70 + elif isinstance(obj, dict): 71 + return {key: convert_to_basic_types(value) for key, value in obj.items()} 72 + elif isinstance(obj, list): 73 + return [convert_to_basic_types(item) for item in obj] 74 + elif isinstance(obj, (str, int, float, bool)) or obj is None: 75 + return obj 76 + else: 77 + # For other types, try to convert to string 78 + return str(obj) 79 + 80 + 81 + def strip_fields(obj, strip_field_list): 82 + """Recursively strip fields from a JSON object.""" 83 + if isinstance(obj, dict): 84 + keys_flagged_for_removal = [] 85 + 86 + # Remove fields from strip list and pydantic metadata 87 + for field in list(obj.keys()): 88 + if field in strip_field_list or field.startswith("__"): 89 + keys_flagged_for_removal.append(field) 90 + 91 + # Remove flagged keys 92 + for key in keys_flagged_for_removal: 93 + obj.pop(key, None) 94 + 95 + # Recursively process remaining values 96 + for key, value in list(obj.items()): 97 + obj[key] = strip_fields(value, strip_field_list) 98 + # Remove empty/null values after processing 99 + if ( 100 + obj[key] is None 101 + or (isinstance(obj[key], dict) and len(obj[key]) == 0) 102 + or (isinstance(obj[key], list) and len(obj[key]) == 0) 103 + or (isinstance(obj[key], str) and obj[key].strip() == "") 104 + ): 105 + obj.pop(key, None) 106 + 107 + elif isinstance(obj, list): 108 + for i, value in enumerate(obj): 109 + obj[i] = strip_fields(value, strip_field_list) 110 + # Remove None values from list 111 + obj[:] = [item for item in obj if item is not None] 112 + 113 + return obj 114 + 115 + 116 + def thread_to_yaml_string(thread, strip_metadata=True): 117 + """ 118 + Convert thread data to a YAML-formatted string for LLM parsing. 119 + 120 + Args: 121 + thread: The thread data from get_post_thread 122 + strip_metadata: Whether to strip metadata fields for cleaner output 123 + 124 + Returns: 125 + YAML-formatted string representation of the thread 126 + """ 127 + # First convert complex objects to basic types 128 + basic_thread = convert_to_basic_types(thread) 129 + 130 + if strip_metadata: 131 + # Create a copy and strip unwanted fields 132 + cleaned_thread = strip_fields(basic_thread, STRIP_FIELDS) 133 + else: 134 + cleaned_thread = basic_thread 135 + 136 + return yaml.dump(cleaned_thread, indent=2, allow_unicode=True, default_flow_style=False) 137 + 138 + 139 + 140 + 141 + 142 + def get_session(username: str) -> Optional[str]: 143 + try: 144 + with open(f"session_{username}.txt", encoding="UTF-8") as f: 145 + return f.read() 146 + except FileNotFoundError: 147 + logger.debug(f"No existing session found for {username}") 148 + return None 149 + 150 + def save_session(username: str, session_string: str) -> None: 151 + with open(f"session_{username}.txt", "w", encoding="UTF-8") as f: 152 + f.write(session_string) 153 + logger.debug(f"Session saved for {username}") 154 + 155 + def on_session_change(username: str, event: SessionEvent, session: Session) -> None: 156 + logger.info(f"Session changed: {event} {repr(session)}") 157 + if event in (SessionEvent.CREATE, SessionEvent.REFRESH): 158 + logger.info(f"Saving changed session for {username}") 159 + save_session(username, session.export()) 160 + 161 + def init_client(username: str, password: str) -> Client: 162 + pds_uri = os.getenv("PDS_URI") 163 + if pds_uri is None: 164 + logger.warning( 165 + "No PDS URI provided. Falling back to bsky.social. Note! If you are on a non-Bluesky PDS, this can cause logins to fail. Please provide a PDS URI using the PDS_URI environment variable." 166 + ) 167 + pds_uri = "https://bsky.social" 168 + 169 + # Print the PDS URI 170 + logger.info(f"Using PDS URI: {pds_uri}") 171 + 172 + client = Client(pds_uri) 173 + client.on_session_change( 174 + lambda event, session: on_session_change(username, event, session) 175 + ) 176 + 177 + session_string = get_session(username) 178 + if session_string: 179 + logger.info(f"Reusing existing session for {username}") 180 + client.login(session_string=session_string) 181 + else: 182 + logger.info(f"Creating new session for {username}") 183 + client.login(username, password) 184 + 185 + return client 186 + 187 + 188 + def default_login() -> Client: 189 + username = os.getenv("BSKY_USERNAME") 190 + password = os.getenv("BSKY_PASSWORD") 191 + 192 + if username is None: 193 + logger.error( 194 + "No username provided. Please provide a username using the BSKY_USERNAME environment variable." 195 + ) 196 + exit() 197 + 198 + if password is None: 199 + logger.error( 200 + "No password provided. Please provide a password using the BSKY_PASSWORD environment variable." 201 + ) 202 + exit() 203 + 204 + return init_client(username, password) 205 + 206 + def reply_to_post(client: Client, text: str, reply_to_uri: str, reply_to_cid: str, root_uri: Optional[str] = None, root_cid: Optional[str] = None) -> Dict[str, Any]: 207 + """ 208 + Reply to a post on Bluesky. 209 + 210 + Args: 211 + client: Authenticated Bluesky client 212 + text: The reply text 213 + reply_to_uri: The URI of the post being replied to (parent) 214 + reply_to_cid: The CID of the post being replied to (parent) 215 + root_uri: The URI of the root post (if replying to a reply). If None, uses reply_to_uri 216 + root_cid: The CID of the root post (if replying to a reply). If None, uses reply_to_cid 217 + 218 + Returns: 219 + The response from sending the post 220 + """ 221 + # If root is not provided, this is a reply to the root post 222 + if root_uri is None: 223 + root_uri = reply_to_uri 224 + root_cid = reply_to_cid 225 + 226 + # Create references for the reply 227 + parent_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=reply_to_uri, cid=reply_to_cid)) 228 + root_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=root_uri, cid=root_cid)) 229 + 230 + # Send the reply 231 + response = client.send_post( 232 + text=text, 233 + reply_to=models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref) 234 + ) 235 + 236 + logger.info(f"Reply sent successfully: {response.uri}") 237 + return response 238 + 239 + 240 + def get_post_thread(client: Client, uri: str) -> Optional[Dict[str, Any]]: 241 + """ 242 + Get the thread containing a post to find root post information. 243 + 244 + Args: 245 + client: Authenticated Bluesky client 246 + uri: The URI of the post 247 + 248 + Returns: 249 + The thread data or None if not found 250 + """ 251 + try: 252 + thread = client.app.bsky.feed.get_post_thread({'uri': uri, 'parent_height': 80, 'depth': 10}) 253 + return thread 254 + except Exception as e: 255 + logger.error(f"Error fetching post thread: {e}") 256 + return None 257 + 258 + 259 + def reply_to_notification(client: Client, notification: Any, reply_text: str) -> Optional[Dict[str, Any]]: 260 + """ 261 + Reply to a notification (mention or reply). 262 + 263 + Args: 264 + client: Authenticated Bluesky client 265 + notification: The notification object from list_notifications 266 + reply_text: The text to reply with 267 + 268 + Returns: 269 + The response from sending the reply or None if failed 270 + """ 271 + try: 272 + # Get the post URI and CID from the notification (handle both dict and object) 273 + if isinstance(notification, dict): 274 + post_uri = notification.get('uri') 275 + post_cid = notification.get('cid') 276 + elif hasattr(notification, 'uri') and hasattr(notification, 'cid'): 277 + post_uri = notification.uri 278 + post_cid = notification.cid 279 + else: 280 + post_uri = None 281 + post_cid = None 282 + 283 + if not post_uri or not post_cid: 284 + logger.error("Notification doesn't have required uri/cid fields") 285 + return None 286 + 287 + # Get the thread to find the root post 288 + thread_data = get_post_thread(client, post_uri) 289 + 290 + if thread_data and hasattr(thread_data, 'thread'): 291 + thread = thread_data.thread 292 + 293 + # Find root post 294 + root_uri = post_uri 295 + root_cid = post_cid 296 + 297 + # If this has a parent, find the root 298 + if hasattr(thread, 'parent') and thread.parent: 299 + # Keep going up until we find the root 300 + current = thread 301 + while hasattr(current, 'parent') and current.parent: 302 + current = current.parent 303 + if hasattr(current, 'post') and hasattr(current.post, 'uri') and hasattr(current.post, 'cid'): 304 + root_uri = current.post.uri 305 + root_cid = current.post.cid 306 + 307 + # Reply to the notification 308 + return reply_to_post( 309 + client=client, 310 + text=reply_text, 311 + reply_to_uri=post_uri, 312 + reply_to_cid=post_cid, 313 + root_uri=root_uri, 314 + root_cid=root_cid 315 + ) 316 + else: 317 + # If we can't get thread data, just reply directly 318 + return reply_to_post( 319 + client=client, 320 + text=reply_text, 321 + reply_to_uri=post_uri, 322 + reply_to_cid=post_cid 323 + ) 324 + 325 + except Exception as e: 326 + logger.error(f"Error replying to notification: {e}") 327 + return None 328 + 329 + 330 + if __name__ == "__main__": 331 + client = default_login() 332 + # do something with the client 333 + logger.info("Client is ready to use!")
+522
create_profile_researcher.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Script to create a Letta agent that researches Bluesky profiles and updates 4 + the model's understanding of users. 5 + """ 6 + 7 + import os 8 + import logging 9 + from letta_client import Letta 10 + from utils import upsert_block, upsert_agent 11 + 12 + # Configure logging 13 + logging.basicConfig( 14 + level=logging.INFO, 15 + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 16 + ) 17 + logger = logging.getLogger("profile_researcher") 18 + 19 + # Use the "Bluesky" project 20 + PROJECT_ID = "5ec33d52-ab14-4fd6-91b5-9dbc43e888a8" 21 + 22 + def create_search_posts_tool(client: Letta): 23 + """Create the Bluesky search posts tool using Letta SDK.""" 24 + 25 + def search_bluesky_posts(query: str, max_results: int = 25, author: str = None, sort: str = "latest") -> str: 26 + """ 27 + Search for posts on Bluesky matching the given criteria. 28 + 29 + Args: 30 + query: Search query string (required) 31 + max_results: Maximum number of results to return (default: 25, max: 100) 32 + author: Filter to posts by a specific author handle (optional) 33 + sort: Sort order - "latest" or "top" (default: "latest") 34 + 35 + Returns: 36 + YAML-formatted search results with posts and metadata 37 + """ 38 + import os 39 + import requests 40 + import json 41 + import yaml 42 + from datetime import datetime 43 + 44 + try: 45 + # Use public Bluesky API 46 + base_url = "https://public.api.bsky.app" 47 + 48 + # Build search parameters 49 + params = { 50 + "q": query, 51 + "limit": min(max_results, 100), 52 + "sort": sort 53 + } 54 + 55 + # Add optional author filter 56 + if author: 57 + params["author"] = author.lstrip('@') 58 + 59 + # Make search request 60 + try: 61 + search_url = f"{base_url}/xrpc/app.bsky.feed.searchPosts" 62 + search_response = requests.get(search_url, params=params, timeout=10) 63 + search_response.raise_for_status() 64 + search_data = search_response.json() 65 + except requests.exceptions.HTTPError as e: 66 + raise RuntimeError(f"Search failed with HTTP {e.response.status_code}: {e.response.text}") 67 + except requests.exceptions.RequestException as e: 68 + raise RuntimeError(f"Network error during search: {str(e)}") 69 + except Exception as e: 70 + raise RuntimeError(f"Unexpected error during search: {str(e)}") 71 + 72 + # Build search results structure 73 + results_data = { 74 + "search_results": { 75 + "query": query, 76 + "timestamp": datetime.now().isoformat(), 77 + "parameters": { 78 + "sort": sort, 79 + "max_results": max_results, 80 + "author_filter": author if author else "none" 81 + }, 82 + "results": search_data 83 + } 84 + } 85 + 86 + # Fields to strip for cleaner output 87 + strip_fields = [ 88 + "cid", "rev", "did", "uri", "langs", "threadgate", "py_type", 89 + "labels", "facets", "avatar", "viewer", "indexed_at", "indexedAt", 90 + "tags", "associated", "thread_context", "image", "aspect_ratio", 91 + "alt", "thumb", "fullsize", "root", "parent", "created_at", 92 + "createdAt", "verification", "embedding_disabled", "thread_muted", 93 + "reply_disabled", "pinned", "like", "repost", "blocked_by", 94 + "blocking", "blocking_by_list", "followed_by", "following", 95 + "known_followers", "muted", "muted_by_list", "root_author_like", 96 + "embed", "entities", "reason", "feedContext" 97 + ] 98 + 99 + # Remove unwanted fields by traversing the data structure 100 + def remove_fields(obj, fields_to_remove): 101 + if isinstance(obj, dict): 102 + return {k: remove_fields(v, fields_to_remove) 103 + for k, v in obj.items() 104 + if k not in fields_to_remove} 105 + elif isinstance(obj, list): 106 + return [remove_fields(item, fields_to_remove) for item in obj] 107 + else: 108 + return obj 109 + 110 + # Clean the data 111 + cleaned_data = remove_fields(results_data, strip_fields) 112 + 113 + # Convert to YAML for better readability 114 + return yaml.dump(cleaned_data, default_flow_style=False, allow_unicode=True) 115 + 116 + except ValueError as e: 117 + # User-friendly errors 118 + raise ValueError(str(e)) 119 + except RuntimeError as e: 120 + # Network/API errors 121 + raise RuntimeError(str(e)) 122 + except yaml.YAMLError as e: 123 + # YAML conversion errors 124 + raise RuntimeError(f"Error formatting output: {str(e)}") 125 + except Exception as e: 126 + # Catch-all for unexpected errors 127 + raise RuntimeError(f"Unexpected error searching posts with query '{query}': {str(e)}") 128 + 129 + # Create the tool using upsert 130 + tool = client.tools.upsert_from_function( 131 + func=search_bluesky_posts, 132 + tags=["bluesky", "search", "posts"] 133 + ) 134 + 135 + logger.info(f"Created tool: {tool.name} (ID: {tool.id})") 136 + return tool 137 + 138 + def create_profile_research_tool(client: Letta): 139 + """Create the Bluesky profile research tool using Letta SDK.""" 140 + 141 + def research_bluesky_profile(handle: str, max_posts: int = 20) -> str: 142 + """ 143 + Research a Bluesky user's profile and recent posts to understand their interests and behavior. 144 + 145 + Args: 146 + handle: The Bluesky handle to research (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org') 147 + max_posts: Maximum number of recent posts to analyze (default: 20) 148 + 149 + Returns: 150 + A comprehensive analysis of the user's profile and posting patterns 151 + """ 152 + import os 153 + import requests 154 + import json 155 + import yaml 156 + from datetime import datetime 157 + 158 + try: 159 + # Clean handle (remove @ if present) 160 + clean_handle = handle.lstrip('@') 161 + 162 + # Use public Bluesky API (no auth required for public data) 163 + base_url = "https://public.api.bsky.app" 164 + 165 + # Get profile information 166 + try: 167 + profile_url = f"{base_url}/xrpc/app.bsky.actor.getProfile" 168 + profile_response = requests.get(profile_url, params={"actor": clean_handle}, timeout=10) 169 + profile_response.raise_for_status() 170 + profile_data = profile_response.json() 171 + except requests.exceptions.HTTPError as e: 172 + if e.response.status_code == 404: 173 + raise ValueError(f"Profile @{clean_handle} not found") 174 + raise RuntimeError(f"HTTP error {e.response.status_code}: {e.response.text}") 175 + except requests.exceptions.RequestException as e: 176 + raise RuntimeError(f"Network error: {str(e)}") 177 + except Exception as e: 178 + raise RuntimeError(f"Unexpected error fetching profile: {str(e)}") 179 + 180 + # Get recent posts feed 181 + try: 182 + feed_url = f"{base_url}/xrpc/app.bsky.feed.getAuthorFeed" 183 + feed_response = requests.get(feed_url, params={ 184 + "actor": clean_handle, 185 + "limit": min(max_posts, 50) # API limit 186 + }, timeout=10) 187 + feed_response.raise_for_status() 188 + feed_data = feed_response.json() 189 + except Exception as e: 190 + # Continue with empty feed if posts can't be fetched 191 + feed_data = {"feed": []} 192 + 193 + # Build research data structure 194 + research_data = { 195 + "profile_research": { 196 + "handle": f"@{clean_handle}", 197 + "timestamp": datetime.now().isoformat(), 198 + "profile": profile_data, 199 + "author_feed": feed_data 200 + } 201 + } 202 + 203 + # Fields to strip for cleaner output 204 + strip_fields = [ 205 + "cid", "rev", "did", "uri", "langs", "threadgate", "py_type", 206 + "labels", "facets", "avatar", "viewer", "indexed_at", "indexedAt", 207 + "tags", "associated", "thread_context", "image", "aspect_ratio", 208 + "alt", "thumb", "fullsize", "root", "parent", "created_at", 209 + "createdAt", "verification", "embedding_disabled", "thread_muted", 210 + "reply_disabled", "pinned", "like", "repost", "blocked_by", 211 + "blocking", "blocking_by_list", "followed_by", "following", 212 + "known_followers", "muted", "muted_by_list", "root_author_like", 213 + "embed", "entities", "reason", "feedContext" 214 + ] 215 + 216 + # Remove unwanted fields by traversing the data structure 217 + def remove_fields(obj, fields_to_remove): 218 + if isinstance(obj, dict): 219 + return {k: remove_fields(v, fields_to_remove) 220 + for k, v in obj.items() 221 + if k not in fields_to_remove} 222 + elif isinstance(obj, list): 223 + return [remove_fields(item, fields_to_remove) for item in obj] 224 + else: 225 + return obj 226 + 227 + # Clean the data 228 + cleaned_data = remove_fields(research_data, strip_fields) 229 + 230 + # Convert to YAML for better readability 231 + return yaml.dump(cleaned_data, default_flow_style=False, allow_unicode=True) 232 + 233 + except ValueError as e: 234 + # User-friendly errors 235 + raise ValueError(str(e)) 236 + except RuntimeError as e: 237 + # Network/API errors 238 + raise RuntimeError(str(e)) 239 + except yaml.YAMLError as e: 240 + # YAML conversion errors 241 + raise RuntimeError(f"Error formatting output: {str(e)}") 242 + except Exception as e: 243 + # Catch-all for unexpected errors 244 + raise RuntimeError(f"Unexpected error researching profile {handle}: {str(e)}") 245 + 246 + # Create or update the tool using upsert 247 + tool = client.tools.upsert_from_function( 248 + func=research_bluesky_profile, 249 + tags=["bluesky", "profile", "research"] 250 + ) 251 + 252 + logger.info(f"Created tool: {tool.name} (ID: {tool.id})") 253 + return tool 254 + 255 + def create_block_management_tools(client: Letta): 256 + """Create tools for attaching and detaching user blocks.""" 257 + 258 + def attach_user_block(handle: str) -> str: 259 + """ 260 + Create (if needed) and attach a user-specific memory block for a Bluesky user. 261 + 262 + Args: 263 + handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org') 264 + 265 + Returns: 266 + Status message about the block attachment 267 + """ 268 + import os 269 + from letta_client import Letta 270 + 271 + try: 272 + # Clean handle for block label 273 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_') 274 + block_label = f"user_{clean_handle}" 275 + 276 + # Initialize Letta client 277 + letta_client = Letta(token=os.environ["LETTA_API_KEY"]) 278 + 279 + # Get current agent (this tool is being called by) 280 + # We need to find the agent that's calling this tool 281 + # For now, we'll find the profile-researcher agent 282 + agents = letta_client.agents.list(name="profile-researcher") 283 + if not agents: 284 + return "Error: Could not find profile-researcher agent" 285 + 286 + agent = agents[0] 287 + 288 + # Check if block already exists and is attached 289 + agent_blocks = letta_client.agents.blocks.list(agent_id=agent.id) 290 + for block in agent_blocks: 291 + if block.label == block_label: 292 + return f"User block for @{handle} is already attached (label: {block_label})" 293 + 294 + # Create or get the user block 295 + existing_blocks = letta_client.blocks.list(label=block_label) 296 + 297 + if existing_blocks: 298 + user_block = existing_blocks[0] 299 + action = "Retrieved existing" 300 + else: 301 + user_block = letta_client.blocks.create( 302 + label=block_label, 303 + value=f"User information for @{handle} will be stored here as I learn about them through profile research and interactions.", 304 + description=f"Stores detailed information about Bluesky user @{handle}, including their interests, posting patterns, personality traits, and interaction history." 305 + ) 306 + action = "Created new" 307 + 308 + # Attach block to agent 309 + letta_client.agents.blocks.attach(agent_id=agent.id, block_id=user_block.id) 310 + 311 + return f"{action} and attached user block for @{handle} (label: {block_label}). I can now store and access information about this user." 312 + 313 + except Exception as e: 314 + return f"Error attaching user block for @{handle}: {str(e)}" 315 + 316 + def detach_user_block(handle: str) -> str: 317 + """ 318 + Detach a user-specific memory block from the agent. 319 + 320 + Args: 321 + handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org') 322 + 323 + Returns: 324 + Status message about the block detachment 325 + """ 326 + import os 327 + from letta_client import Letta 328 + 329 + try: 330 + # Clean handle for block label 331 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_') 332 + block_label = f"user_{clean_handle}" 333 + 334 + # Initialize Letta client 335 + letta_client = Letta(token=os.environ["LETTA_API_KEY"]) 336 + 337 + # Get current agent 338 + agents = letta_client.agents.list(name="profile-researcher") 339 + if not agents: 340 + return "Error: Could not find profile-researcher agent" 341 + 342 + agent = agents[0] 343 + 344 + # Find the block to detach 345 + agent_blocks = letta_client.agents.blocks.list(agent_id=agent.id) 346 + user_block = None 347 + for block in agent_blocks: 348 + if block.label == block_label: 349 + user_block = block 350 + break 351 + 352 + if not user_block: 353 + return f"User block for @{handle} is not currently attached (label: {block_label})" 354 + 355 + # Detach block from agent 356 + letta_client.agents.blocks.detach(agent_id=agent.id, block_id=user_block.id) 357 + 358 + return f"Detached user block for @{handle} (label: {block_label}). The block still exists and can be reattached later." 359 + 360 + except Exception as e: 361 + return f"Error detaching user block for @{handle}: {str(e)}" 362 + 363 + def update_user_block(handle: str, new_content: str) -> str: 364 + """ 365 + Update the content of a user-specific memory block. 366 + 367 + Args: 368 + handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org') 369 + new_content: New content to store in the user block 370 + 371 + Returns: 372 + Status message about the block update 373 + """ 374 + import os 375 + from letta_client import Letta 376 + 377 + try: 378 + # Clean handle for block label 379 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_') 380 + block_label = f"user_{clean_handle}" 381 + 382 + # Initialize Letta client 383 + letta_client = Letta(token=os.environ["LETTA_API_KEY"]) 384 + 385 + # Find the block 386 + existing_blocks = letta_client.blocks.list(label=block_label) 387 + if not existing_blocks: 388 + return f"User block for @{handle} does not exist (label: {block_label}). Use attach_user_block first." 389 + 390 + user_block = existing_blocks[0] 391 + 392 + # Update block content 393 + letta_client.blocks.modify( 394 + block_id=user_block.id, 395 + value=new_content 396 + ) 397 + 398 + return f"Updated user block for @{handle} (label: {block_label}) with new content." 399 + 400 + except Exception as e: 401 + return f"Error updating user block for @{handle}: {str(e)}" 402 + 403 + # Create the tools 404 + attach_tool = client.tools.upsert_from_function( 405 + func=attach_user_block, 406 + tags=["memory", "user", "attach"] 407 + ) 408 + 409 + detach_tool = client.tools.upsert_from_function( 410 + func=detach_user_block, 411 + tags=["memory", "user", "detach"] 412 + ) 413 + 414 + update_tool = client.tools.upsert_from_function( 415 + func=update_user_block, 416 + tags=["memory", "user", "update"] 417 + ) 418 + 419 + logger.info(f"Created block management tools: {attach_tool.name}, {detach_tool.name}, {update_tool.name}") 420 + return attach_tool, detach_tool, update_tool 421 + 422 + def create_user_block_for_handle(client: Letta, handle: str): 423 + """Create a user-specific memory block that can be manually attached to agents.""" 424 + clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_') 425 + block_label = f"user_{clean_handle}" 426 + 427 + user_block = upsert_block( 428 + client, 429 + label=block_label, 430 + value=f"User information for @{handle} will be stored here as I learn about them through profile research and interactions.", 431 + description=f"Stores detailed information about Bluesky user @{handle}, including their interests, posting patterns, personality traits, and interaction history." 432 + ) 433 + 434 + logger.info(f"Created user block for @{handle}: {block_label} (ID: {user_block.id})") 435 + return user_block 436 + 437 + def create_profile_researcher_agent(): 438 + """Create the profile-researcher Letta agent.""" 439 + 440 + # Create client 441 + client = Letta(token=os.environ["LETTA_API_KEY"]) 442 + 443 + logger.info("Creating profile-researcher agent...") 444 + 445 + # Create custom tools first 446 + research_tool = create_profile_research_tool(client) 447 + attach_tool, detach_tool, update_tool = create_block_management_tools(client) 448 + 449 + # Create persona block 450 + persona_block = upsert_block( 451 + client, 452 + label="profile-researcher-persona", 453 + value="""I am a Profile Researcher, an AI agent specialized in analyzing Bluesky user profiles and social media behavior. My purpose is to: 454 + 455 + 1. Research Bluesky user profiles thoroughly and objectively 456 + 2. Analyze posting patterns, interests, and engagement behaviors 457 + 3. Build comprehensive user understanding through data analysis 458 + 4. Create and manage user-specific memory blocks for individuals 459 + 5. Provide insights about user personality, interests, and social patterns 460 + 461 + I approach research systematically: 462 + - Use the research_bluesky_profile tool to examine profiles and recent posts 463 + - Use attach_user_block to create and attach dedicated memory blocks for specific users 464 + - Use update_user_block to store research findings in user-specific blocks 465 + - Use detach_user_block when research is complete to free up memory space 466 + - Analyze profile information (bio, follower counts, etc.) 467 + - Study recent posts for themes, topics, and tone 468 + - Identify posting frequency and engagement patterns 469 + - Note interaction styles and communication preferences 470 + - Track interests and expertise areas 471 + - Observe social connections and community involvement 472 + 473 + I maintain objectivity and respect privacy while building useful user models for personalized interactions. My typical workflow is: attach_user_block → research_bluesky_profile → update_user_block → detach_user_block.""", 474 + description="The persona and role definition for the profile researcher agent" 475 + ) 476 + 477 + # Create the agent with persona block and custom tools 478 + profile_researcher = upsert_agent( 479 + client, 480 + name="profile-researcher", 481 + memory_blocks=[ 482 + { 483 + "label": "research_notes", 484 + "value": "I will use this space to track ongoing research projects and findings across multiple users.", 485 + "limit": 8000, 486 + "description": "Working notes and cross-user insights from profile research activities" 487 + } 488 + ], 489 + block_ids=[persona_block.id], 490 + tags=["profile research", "bluesky", "user analysis"], 491 + model="openai/gpt-4o-mini", 492 + embedding="openai/text-embedding-3-small", 493 + description="An agent that researches Bluesky profiles and builds user understanding", 494 + project_id=PROJECT_ID, 495 + tools=[research_tool.name, attach_tool.name, detach_tool.name, update_tool.name] 496 + ) 497 + 498 + logger.info(f"Profile researcher agent created: {profile_researcher.id}") 499 + return profile_researcher 500 + 501 + def main(): 502 + """Main function to create the profile researcher agent.""" 503 + try: 504 + agent = create_profile_researcher_agent() 505 + print(f"✅ Profile researcher agent created successfully!") 506 + print(f" Agent ID: {agent.id}") 507 + print(f" Agent Name: {agent.name}") 508 + print(f"\nThe agent has these capabilities:") 509 + print(f" - research_bluesky_profile: Analyzes user profiles and recent posts") 510 + print(f" - attach_user_block: Creates and attaches user-specific memory blocks") 511 + print(f" - update_user_block: Updates content in user memory blocks") 512 + print(f" - detach_user_block: Detaches user blocks when done") 513 + print(f"\nTo use the agent, send a message like:") 514 + print(f" 'Please research @cameron.pfiffer.org, attach their user block, update it with findings, then detach it'") 515 + print(f"\nThe agent can now manage its own memory blocks dynamically!") 516 + 517 + except Exception as e: 518 + logger.error(f"Failed to create profile researcher agent: {e}") 519 + print(f"❌ Error: {e}") 520 + 521 + if __name__ == "__main__": 522 + main()
+111
get_thread.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Centralized script for retrieving Bluesky post threads from URIs. 4 + Includes YAML-ified string conversion for easy LLM parsing. 5 + """ 6 + 7 + import argparse 8 + import sys 9 + import logging 10 + from typing import Optional, Dict, Any 11 + import yaml 12 + from bsky_utils import default_login, thread_to_yaml_string 13 + 14 + # Configure logging 15 + logging.basicConfig( 16 + level=logging.INFO, 17 + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 18 + ) 19 + logger = logging.getLogger("get_thread") 20 + 21 + 22 + def get_thread_from_uri(uri: str) -> Optional[Dict[str, Any]]: 23 + """ 24 + Retrieve a post thread from a Bluesky URI. 25 + 26 + Args: 27 + uri: The Bluesky post URI (e.g., at://did:plc:xyz/app.bsky.feed.post/abc123) 28 + 29 + Returns: 30 + Thread data or None if retrieval failed 31 + """ 32 + try: 33 + client = default_login() 34 + logger.info(f"Fetching thread for URI: {uri}") 35 + 36 + thread = client.app.bsky.feed.get_post_thread({'uri': uri, 'parent_height': 80, 'depth': 10}) 37 + return thread 38 + 39 + except Exception as e: 40 + logger.error(f"Error retrieving thread for URI {uri}: {e}") 41 + return None 42 + 43 + 44 + # thread_to_yaml_string is now imported from bsky_utils 45 + 46 + 47 + def main(): 48 + """Main CLI interface for the thread retrieval script.""" 49 + parser = argparse.ArgumentParser( 50 + description="Retrieve and display Bluesky post threads", 51 + formatter_class=argparse.RawDescriptionHelpFormatter, 52 + epilog=""" 53 + Examples: 54 + python get_thread.py at://did:plc:xyz/app.bsky.feed.post/abc123 55 + python get_thread.py --raw at://did:plc:xyz/app.bsky.feed.post/abc123 56 + python get_thread.py --output thread.yaml at://did:plc:xyz/app.bsky.feed.post/abc123 57 + """ 58 + ) 59 + 60 + parser.add_argument( 61 + "uri", 62 + help="Bluesky post URI to retrieve thread for" 63 + ) 64 + 65 + parser.add_argument( 66 + "--raw", 67 + action="store_true", 68 + help="Include all metadata fields (don't strip for LLM parsing)" 69 + ) 70 + 71 + parser.add_argument( 72 + "--output", "-o", 73 + help="Output file to write YAML to (default: stdout)" 74 + ) 75 + 76 + parser.add_argument( 77 + "--quiet", "-q", 78 + action="store_true", 79 + help="Suppress info logging" 80 + ) 81 + 82 + args = parser.parse_args() 83 + 84 + if args.quiet: 85 + logging.getLogger().setLevel(logging.ERROR) 86 + 87 + # Retrieve the thread 88 + thread = get_thread_from_uri(args.uri) 89 + 90 + if thread is None: 91 + logger.error("Failed to retrieve thread") 92 + sys.exit(1) 93 + 94 + # Convert to YAML 95 + yaml_output = thread_to_yaml_string(thread, strip_metadata=not args.raw) 96 + 97 + # Output the result 98 + if args.output: 99 + try: 100 + with open(args.output, 'w', encoding='utf-8') as f: 101 + f.write(yaml_output) 102 + logger.info(f"Thread saved to {args.output}") 103 + except Exception as e: 104 + logger.error(f"Error writing to file {args.output}: {e}") 105 + sys.exit(1) 106 + else: 107 + print(yaml_output) 108 + 109 + 110 + if __name__ == "__main__": 111 + main()
+63
show_agent_capabilities.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Show the current capabilities of both agents. 4 + """ 5 + 6 + import os 7 + from letta_client import Letta 8 + 9 + def show_agent_capabilities(): 10 + """Display the capabilities of both agents.""" 11 + 12 + client = Letta(token=os.environ["LETTA_API_KEY"]) 13 + 14 + print("🤖 LETTA AGENT CAPABILITIES") 15 + print("=" * 50) 16 + 17 + # Profile Researcher Agent 18 + researchers = client.agents.list(name="profile-researcher") 19 + if researchers: 20 + researcher = researchers[0] 21 + print(f"\n📊 PROFILE RESEARCHER AGENT") 22 + print(f" ID: {researcher.id}") 23 + print(f" Name: {researcher.name}") 24 + 25 + researcher_tools = client.agents.tools.list(agent_id=researcher.id) 26 + print(f" Tools ({len(researcher_tools)}):") 27 + for tool in researcher_tools: 28 + print(f" - {tool.name}") 29 + 30 + researcher_blocks = client.agents.blocks.list(agent_id=researcher.id) 31 + print(f" Memory Blocks ({len(researcher_blocks)}):") 32 + for block in researcher_blocks: 33 + print(f" - {block.label}") 34 + 35 + # Void Agent 36 + voids = client.agents.list(name="void") 37 + if voids: 38 + void = voids[0] 39 + print(f"\n🌌 VOID AGENT") 40 + print(f" ID: {void.id}") 41 + print(f" Name: {void.name}") 42 + 43 + void_tools = client.agents.tools.list(agent_id=void.id) 44 + print(f" Tools ({len(void_tools)}):") 45 + for tool in void_tools: 46 + print(f" - {tool.name}") 47 + 48 + void_blocks = client.agents.blocks.list(agent_id=void.id) 49 + print(f" Memory Blocks ({len(void_blocks)}):") 50 + for block in void_blocks: 51 + print(f" - {block.label}") 52 + 53 + print(f"\n🔄 WORKFLOW") 54 + print(f" 1. Profile Researcher: attach_user_block → research_bluesky_profile → update_user_block → detach_user_block") 55 + print(f" 2. Void Agent: Can attach/detach same user blocks for personalized interactions") 56 + print(f" 3. Shared Memory: Both agents can access the same user-specific blocks") 57 + 58 + print(f"\n💡 USAGE EXAMPLES") 59 + print(f" Profile Researcher: 'Research @cameron.pfiffer.org and store findings'") 60 + print(f" Void Agent: 'Attach user block for cameron.pfiffer.org before responding'") 61 + 62 + if __name__ == "__main__": 63 + show_agent_capabilities()
+93
utils.py
··· 1 + from letta_client import Letta 2 + from typing import Optional 3 + 4 + def upsert_block(letta: Letta, label: str, value: str, **kwargs): 5 + """ 6 + Ensures that a block by this label exists. If the block exists, it will 7 + replace content provided by kwargs with the values in this function call. 8 + """ 9 + # Get the list of blocks 10 + blocks = letta.blocks.list(label=label) 11 + 12 + # Check if we had any -- if not, create it 13 + if len(blocks) == 0: 14 + # Make the new block 15 + new_block = letta.blocks.create( 16 + label=label, 17 + value=value, 18 + **kwargs 19 + ) 20 + 21 + return new_block 22 + 23 + if len(blocks) > 1: 24 + raise Exception(f"{len(blocks)} blocks by the label '{label}' retrieved, label must identify a unique block") 25 + 26 + else: 27 + existing_block = blocks[0] 28 + 29 + if kwargs.get('update', False): 30 + # Remove 'update' from kwargs before passing to modify 31 + kwargs_copy = kwargs.copy() 32 + kwargs_copy.pop('update', None) 33 + 34 + updated_block = letta.blocks.modify( 35 + block_id = existing_block.id, 36 + label = label, 37 + value = value, 38 + **kwargs_copy 39 + ) 40 + 41 + return updated_block 42 + else: 43 + return existing_block 44 + 45 + def upsert_agent(letta: Letta, name: str, **kwargs): 46 + """ 47 + Ensures that an agent by this label exists. If the agent exists, it will 48 + update the agent to match kwargs. 49 + """ 50 + # Get the list of agents 51 + agents = letta.agents.list(name=name) 52 + 53 + # Check if we had any -- if not, create it 54 + if len(agents) == 0: 55 + # Make the new agent 56 + new_agent = letta.agents.create( 57 + name=name, 58 + **kwargs 59 + ) 60 + 61 + return new_agent 62 + 63 + if len(agents) > 1: 64 + raise Exception(f"{len(agents)} agents by the label '{label}' retrieved, label must identify a unique agent") 65 + 66 + else: 67 + existing_agent = agents[0] 68 + 69 + if kwargs.get('update', False): 70 + # Remove 'update' from kwargs before passing to modify 71 + kwargs_copy = kwargs.copy() 72 + kwargs_copy.pop('update', None) 73 + 74 + updated_agent = letta.agents.modify( 75 + agent_id = existing_agent.id, 76 + **kwargs_copy 77 + ) 78 + 79 + return updated_agent 80 + else: 81 + return existing_agent 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 +