a tool to help your Letta AI agents navigate bluesky

Add consent guardrails for Bluesky posting

Changed files
+407 -2
tools
+195
tools/bluesky/create_bluesky_post.py
··· 67 67 return facets if facets else None 68 68 69 69 70 + def _check_is_self(agent_did: str, target_did: str) -> bool: 71 + """Check 1: Self-Post Check (Free).""" 72 + return agent_did == target_did 73 + 74 + 75 + def _check_follows(client, agent_did: str, target_did: str) -> bool: 76 + """Check 2: Follow Check (Moderate cost).""" 77 + try: 78 + # Fetch profiles to get follow counts 79 + agent_profile = client.app.bsky.actor.get_profile({'actor': agent_did}) 80 + target_profile = client.app.bsky.actor.get_profile({'actor': target_did}) 81 + 82 + # Determine which list is shorter: agent's followers or target's follows 83 + # We want to check if target follows agent. 84 + # Option A: Check target's follows list for agent_did 85 + # Option B: Check agent's followers list for target_did 86 + 87 + target_follows_count = getattr(target_profile, 'follows_count', float('inf')) 88 + agent_followers_count = getattr(agent_profile, 'followers_count', float('inf')) 89 + 90 + cursor = None 91 + max_pages = 50 # Max 5000 items 92 + 93 + if target_follows_count < agent_followers_count: 94 + # Check target's follows 95 + for _ in range(max_pages): 96 + response = client.app.bsky.graph.get_follows({'actor': target_did, 'cursor': cursor, 'limit': 100}) 97 + if not response.follows: 98 + break 99 + 100 + for follow in response.follows: 101 + if follow.did == agent_did: 102 + return True 103 + 104 + cursor = response.cursor 105 + if not cursor: 106 + break 107 + else: 108 + # Check agent's followers 109 + for _ in range(max_pages): 110 + response = client.app.bsky.graph.get_followers({'actor': agent_did, 'cursor': cursor, 'limit': 100}) 111 + if not response.followers: 112 + break 113 + 114 + for follower in response.followers: 115 + if follower.did == target_did: 116 + return True 117 + 118 + cursor = response.cursor 119 + if not cursor: 120 + break 121 + 122 + return False 123 + except Exception: 124 + # If optimization fails, we continue to next check rather than failing hard here 125 + # unless it's a critical error, but we'll let the main try/except handle that 126 + raise 127 + 128 + 129 + def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool: 130 + """Check 3 & 4: Thread Participation and Mention Check (Expensive).""" 131 + try: 132 + # Fetch the thread 133 + # depth=100 should be sufficient for most contexts, or we can walk up manually if needed. 134 + # get_post_thread returns the post and its parents if configured. 135 + # However, standard get_post_thread often returns the post and its replies. 136 + # We need to walk UP the tree (parents). 137 + # The 'parent' field in the response structure allows walking up. 138 + 139 + response = client.app.bsky.feed.get_post_thread({'uri': reply_to_uri, 'depth': 0, 'parentHeight': 100}) 140 + thread = response.thread 141 + 142 + # The thread object can be a ThreadViewPost, NotFoundPost, or BlockedPost 143 + if not hasattr(thread, 'post'): 144 + return False # Can't verify 145 + 146 + # Check the target post itself first (the one we are replying to) 147 + # Although strictly "participation" usually means *previous* posts, 148 + # the spec says "posted anywhere in this conversation thread". 149 + # If we are replying to ourselves, _check_is_self would have caught it. 150 + # But we check here for mentions in the target post. 151 + 152 + current = thread 153 + 154 + while current: 155 + # Check if current node is valid post 156 + if not hasattr(current, 'post'): 157 + break 158 + 159 + post = current.post 160 + 161 + # Check 3: Did agent author this post? 162 + if post.author.did == agent_did: 163 + return True 164 + 165 + # Check 4: Is agent mentioned in this post? 166 + # Check facets for mention 167 + record = post.record 168 + if hasattr(record, 'facets') and record.facets: 169 + for facet in record.facets: 170 + for feature in facet.features: 171 + if hasattr(feature, 'did') and feature.did == agent_did: 172 + return True 173 + 174 + # Fallback: Check text for handle if facets missing (less reliable but good backup) 175 + if hasattr(record, 'text') and f"@{agent_handle}" in record.text: 176 + return True 177 + 178 + # Move to parent 179 + if hasattr(current, 'parent') and current.parent: 180 + current = current.parent 181 + else: 182 + break 183 + 184 + return False 185 + 186 + except Exception: 187 + raise 188 + 189 + 190 + def _verify_consent(client, agent_did: str, agent_handle: str, reply_to_uri: str): 191 + """ 192 + Orchestrates the consent checks. 193 + Raises Exception with specific message if consent denied or verification fails. 194 + """ 195 + try: 196 + # 0. Get target DID from reply_to_uri 197 + # We need to fetch the post we are replying to to get its author 198 + # We can do this via get_post_thread or get_posts. 199 + # Since we need thread later, maybe we just do that? 200 + # But Follow Check is cheaper than Thread Check. 201 + # So we should get the post details first (1 API call). 202 + 203 + # Parse DID from URI first to avoid API call if possible? 204 + # URI format: at://did:plc:xyz/... 205 + # The DID in the URI is the REPO owner, which is usually the author. 206 + parts = reply_to_uri.replace('at://', '').split('/') 207 + if len(parts) >= 1: 208 + target_did = parts[0] 209 + else: 210 + # Should have been caught by validation, but just in case 211 + raise Exception("Invalid URI format") 212 + 213 + # Check 1: Self-Post 214 + if _check_is_self(agent_did, target_did): 215 + return True 216 + 217 + # Check 2: Follow Check 218 + if _check_follows(client, agent_did, target_did): 219 + return True 220 + 221 + # Check 3 & 4: Thread Participation / Mention 222 + # This requires fetching the thread 223 + if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri): 224 + return True 225 + 226 + # If all checks fail 227 + raise Exception( 228 + "Message not sent: the reply was withheld because the user does not follow you " 229 + "and you were not mentioned or included in this thread. Respecting the consent of " 230 + "strangers is the right choice here. You haven't done anything wrong, but you may " 231 + "consider a lower-signal interaction (like liking or reposting) and/or move on to " 232 + "other conversations." 233 + ) 234 + 235 + except Exception as e: 236 + # Check if it's our own consent exception 237 + if str(e).startswith("Message not sent"): 238 + raise e 239 + 240 + # Network/API failure handling 241 + raise Exception( 242 + "Message withheld: Network interference prevented consent verification. To ensure " 243 + "safety, the system paused this action. This is an external connection issue, not " 244 + "a failure of your logic. You may try sending the message again now, or move on if " 245 + "the issue persists." 246 + ) 247 + 248 + 249 + 70 250 def create_bluesky_post(text: List[str], lang: str = "en-US", reply_to_uri: str = None) -> Dict: 71 251 """ 72 252 Create a post or thread on Bluesky using atproto SDK. ··· 145 325 146 326 client = Client() 147 327 client.login(username, password) 328 + 329 + # --- CONSENT GUARDRAILS --- 330 + if reply_to_uri: 331 + try: 332 + agent_did = client.me.did 333 + # agent_handle is username (without @ usually, but let's ensure) 334 + agent_handle = username.replace('@', '') 335 + 336 + _verify_consent(client, agent_did, agent_handle, reply_to_uri) 337 + except Exception as e: 338 + return { 339 + "status": "error", 340 + "message": str(e) 341 + } 342 + # -------------------------- 148 343 149 344 initial_reply_ref = None 150 345 initial_root_ref = None
+212 -2
tools/bluesky/quote_bluesky_post.py
··· 1 1 """Bluesky quote posting tool for Letta agents using atproto SDK.""" 2 2 3 - from typing import List, Dict 4 3 import os 5 4 import re 5 + from typing import Dict, List 6 6 7 7 8 8 def parse_facets(text: str, client) -> List[Dict]: ··· 67 67 return facets if facets else None 68 68 69 69 70 + def _check_is_self(agent_did: str, target_did: str) -> bool: 71 + """Check 1: Self-Post Check (Free).""" 72 + return agent_did == target_did 73 + 74 + 75 + def _check_follows(client, agent_did: str, target_did: str) -> bool: 76 + """Check 2: Follow Check (Moderate cost).""" 77 + try: 78 + # Fetch profiles to get follow counts 79 + agent_profile = client.app.bsky.actor.get_profile({'actor': agent_did}) 80 + target_profile = client.app.bsky.actor.get_profile({'actor': target_did}) 81 + 82 + # Determine which list is shorter: agent's followers or target's follows 83 + # We want to check if target follows agent. 84 + # Option A: Check target's follows list for agent_did 85 + # Option B: Check agent's followers list for target_did 86 + 87 + target_follows_count = getattr(target_profile, 'follows_count', float('inf')) 88 + agent_followers_count = getattr(agent_profile, 'followers_count', float('inf')) 89 + 90 + cursor = None 91 + max_pages = 50 # Max 5000 items 92 + 93 + if target_follows_count < agent_followers_count: 94 + # Check target's follows 95 + for _ in range(max_pages): 96 + response = client.app.bsky.graph.get_follows({'actor': target_did, 'cursor': cursor, 'limit': 100}) 97 + if not response.follows: 98 + break 99 + 100 + for follow in response.follows: 101 + if follow.did == agent_did: 102 + return True 103 + 104 + cursor = response.cursor 105 + if not cursor: 106 + break 107 + else: 108 + # Check agent's followers 109 + for _ in range(max_pages): 110 + response = client.app.bsky.graph.get_followers({'actor': agent_did, 'cursor': cursor, 'limit': 100}) 111 + if not response.followers: 112 + break 113 + 114 + for follower in response.followers: 115 + if follower.did == target_did: 116 + return True 117 + 118 + cursor = response.cursor 119 + if not cursor: 120 + break 121 + 122 + return False 123 + except Exception: 124 + # If optimization fails, we continue to next check rather than failing hard here 125 + # unless it's a critical error, but we'll let the main try/except handle that 126 + raise 127 + 128 + 129 + def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool: 130 + """Check 3 & 4: Thread Participation and Mention Check (Expensive).""" 131 + try: 132 + # Fetch the thread 133 + # depth=100 should be sufficient for most contexts, or we can walk up manually if needed. 134 + # get_post_thread returns the post and its parents if configured. 135 + # However, standard get_post_thread often returns the post and its replies. 136 + # We need to walk UP the tree (parents). 137 + # The 'parent' field in the response structure allows walking up. 138 + 139 + response = client.app.bsky.feed.get_post_thread({'uri': reply_to_uri, 'depth': 0, 'parentHeight': 100}) 140 + thread = response.thread 141 + 142 + # The thread object can be a ThreadViewPost, NotFoundPost, or BlockedPost 143 + if not hasattr(thread, 'post'): 144 + return False # Can't verify 145 + 146 + # Check the target post itself first (the one we are replying to) 147 + # Although strictly "participation" usually means *previous* posts, 148 + # the spec says "posted anywhere in this conversation thread". 149 + # If we are replying to ourselves, _check_is_self would have caught it. 150 + # But we check here for mentions in the target post. 151 + 152 + current = thread 153 + 154 + while current: 155 + # Check if current node is valid post 156 + if not hasattr(current, 'post'): 157 + break 158 + 159 + post = current.post 160 + 161 + # Check 3: Did agent author this post? 162 + if post.author.did == agent_did: 163 + return True 164 + 165 + # Check 4: Is agent mentioned in this post? 166 + # Check facets for mention 167 + record = post.record 168 + if hasattr(record, 'facets') and record.facets: 169 + for facet in record.facets: 170 + for feature in facet.features: 171 + if hasattr(feature, 'did') and feature.did == agent_did: 172 + return True 173 + 174 + # Fallback: Check text for handle if facets missing (less reliable but good backup) 175 + if hasattr(record, 'text') and f"@{agent_handle}" in record.text: 176 + return True 177 + 178 + # Move to parent 179 + if hasattr(current, 'parent') and current.parent: 180 + current = current.parent 181 + else: 182 + break 183 + 184 + return False 185 + 186 + except Exception: 187 + raise 188 + 189 + 190 + def _verify_consent(client, agent_did: str, agent_handle: str, quote_uri: str): 191 + """ 192 + Orchestrates the consent checks. 193 + Raises Exception with specific message if consent denied or verification fails. 194 + """ 195 + try: 196 + # 0. Get target DID from quote_uri 197 + parts = quote_uri.replace('at://', '').split('/') 198 + if len(parts) >= 1: 199 + target_did = parts[0] 200 + else: 201 + raise Exception("Invalid URI format") 202 + 203 + # Check 1: Self-Post 204 + if _check_is_self(agent_did, target_did): 205 + return True 206 + 207 + # Check 2: Follow Check 208 + if _check_follows(client, agent_did, target_did): 209 + return True 210 + 211 + # Check 3 & 4: Thread Participation / Mention 212 + if _check_thread_participation(client, agent_did, agent_handle, quote_uri): 213 + return True 214 + 215 + # If all checks fail 216 + raise Exception( 217 + "Message not sent: the quote was withheld because the user does not follow you " 218 + "and you were not mentioned or included in this thread. Respecting the consent of " 219 + "strangers is the right choice here. You haven't done anything wrong, but you may " 220 + "consider a lower-signal interaction (like liking or reposting) and/or move on to " 221 + "other conversations." 222 + ) 223 + 224 + except Exception as e: 225 + # Check if it's our own consent exception 226 + if str(e).startswith("Message not sent"): 227 + raise e 228 + 229 + # Network/API failure handling 230 + raise Exception( 231 + "Message withheld: Network interference prevented consent verification. To ensure " 232 + "safety, the system paused this action. This is an external connection issue, not " 233 + "a failure of your logic. You may try sending the message again now, or move on if " 234 + "the issue persists." 235 + ) 236 + 237 + 70 238 def quote_bluesky_post(text: List[str], quote_uri: str, lang: str = "en-US") -> str: 71 239 """ 72 240 Create a quote post or quote thread on Bluesky that embeds another post. ··· 194 362 client = Client() 195 363 client.login(username, password) 196 364 365 + # --- CONSENT GUARDRAILS --- 366 + if quote_uri: 367 + try: 368 + agent_did = client.me.did 369 + agent_handle = username.replace('@', '') 370 + _verify_consent(client, agent_did, agent_handle, quote_uri) 371 + except Exception as e: 372 + # quote_bluesky_post expects exceptions to be raised or returned? 373 + # The tool catches exceptions and wraps them. 374 + # But we want to return the specific message. 375 + # The existing code catches Exception and wraps it in "Error: ...". 376 + # However, our spec says "Block with Supportive Message". 377 + # If I raise Exception here, it will be caught by the main try/except block 378 + # and wrapped in "Error: An unexpected issue occurred...". 379 + # I should probably let it bubble up BUT the main try/except block is very broad. 380 + # I need to modify the main try/except block or handle it here. 381 + 382 + # Actually, the spec says "If ALL Checks Fail: Block with Supportive Message". 383 + # And "If ANY exception occurs... Message withheld: Network interference...". 384 + # My _verify_consent raises these exact messages. 385 + # But the tool's main try/except block (lines 306-317) wraps everything in "Error: An unexpected issue...". 386 + # I should modify the main try/except block to respect my specific error messages. 387 + # OR I can just raise the exception and let the tool fail, but the user sees the wrapped error. 388 + # The spec says "Block with Supportive Message". 389 + # So I should probably ensure that message is what is returned/raised. 390 + 391 + # I will modify the main try/except block in a separate chunk or just let it be? 392 + # The tool returns a string on success, raises Exception on failure. 393 + # If I raise Exception("Message not sent..."), the catch block will say "Error: An unexpected issue... Message not sent...". 394 + # That might be okay, but cleaner if I can pass it through. 395 + # The catch block has: `if str(e).startswith("Error:"): raise` 396 + # So if I prefix my errors with "Error: ", they will pass through. 397 + # But the spec gives a specific message text without "Error: " prefix. 398 + # "Message not sent: ..." 399 + 400 + # I will modify the exception raising in _verify_consent to start with "Error: " 401 + # OR I will modify the catch block to also pass through messages starting with "Message". 402 + 403 + # Let's modify the catch block in `quote_bluesky_post.py` as well. 404 + raise e 405 + # -------------------------- 406 + 197 407 # Fetch the post to quote and create a strong reference 198 408 try: 199 409 uri_parts = quote_uri.replace('at://', '').split('/') ··· 305 515 ) 306 516 except Exception as e: 307 517 # Re-raise if it's already one of our formatted error messages 308 - if str(e).startswith("Error:"): 518 + if str(e).startswith("Error:") or str(e).startswith("Message"): 309 519 raise 310 520 # Otherwise wrap it with helpful context 311 521 raise Exception(