a digital person for bluesky

Add list support to bluesky_reply tool with threading (max 4 messages)

Major changes:
- Updated ReplyArgs schema to accept List[str] messages instead of single message
- Added validation for max 4 messages, each max 300 characters
- Updated bluesky_reply function to handle message lists
- Created reply_with_thread_to_notification function in bsky_utils.py
- Added system failure message posting when thread messages fail
- Updated bot processing logic in bsky.py to handle both single and multi-message replies
- Maintained backward compatibility with old message format
- Updated register_tools.py description

Threading behavior:
- Single message: Works exactly like before
- Multiple messages: Creates a linear thread where each reply is a response to the previous one
- Failure handling: Posts "[SYSTEM FAILURE: COULD NOT POST MESSAGE, PLEASE TRY AGAIN]" if a message in the thread fails

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

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

+40 -14
bsky.py
··· 394 394 if message.tool_call.name == 'bluesky_reply': 395 395 try: 396 396 args = json.loads(message.tool_call.arguments) 397 - reply_text = args.get('message', '') 397 + # Handle both old format (message) and new format (messages) 398 + reply_messages = args.get('messages', []) 399 + if not reply_messages: 400 + # Fallback to old format for backward compatibility 401 + old_message = args.get('message', '') 402 + if old_message: 403 + reply_messages = [old_message] 404 + 398 405 reply_lang = args.get('lang', 'en-US') 399 - if reply_text: # Only add if there's actual content 400 - reply_candidates.append((reply_text, reply_lang)) 401 - logger.info(f"Found bluesky_reply candidate: {reply_text[:50]}... (lang: {reply_lang})") 406 + if reply_messages: # Only add if there's actual content 407 + reply_candidates.append((reply_messages, reply_lang)) 408 + if len(reply_messages) == 1: 409 + logger.info(f"Found bluesky_reply candidate: {reply_messages[0][:50]}... (lang: {reply_lang})") 410 + else: 411 + logger.info(f"Found bluesky_reply thread candidate with {len(reply_messages)} messages (lang: {reply_lang})") 402 412 except json.JSONDecodeError as e: 403 413 logger.error(f"Failed to parse tool call arguments: {e}") 404 414 405 415 if reply_candidates: 406 416 logger.info(f"Found {len(reply_candidates)} bluesky_reply candidates, trying each until one succeeds...") 407 417 408 - for i, (reply_text, reply_lang) in enumerate(reply_candidates, 1): 418 + for i, (reply_messages, reply_lang) in enumerate(reply_candidates, 1): 409 419 # Print the generated reply for testing 410 420 print(f"\n=== GENERATED REPLY {i}/{len(reply_candidates)} ===") 411 421 print(f"To: @{author_handle}") 412 - print(f"Reply: {reply_text}") 422 + if len(reply_messages) == 1: 423 + print(f"Reply: {reply_messages[0]}") 424 + else: 425 + print(f"Reply thread ({len(reply_messages)} messages):") 426 + for j, msg in enumerate(reply_messages, 1): 427 + print(f" {j}. {msg}") 413 428 print(f"Language: {reply_lang}") 414 429 print(f"======================\n") 415 430 416 - # Send the reply with language 417 - logger.info(f"Trying reply {i}/{len(reply_candidates)}: {reply_text[:50]}... (lang: {reply_lang})") 418 - response = bsky_utils.reply_to_notification( 419 - client=atproto_client, 420 - notification=notification_data, 421 - reply_text=reply_text, 422 - lang=reply_lang 423 - ) 431 + # Send the reply(s) with language 432 + if len(reply_messages) == 1: 433 + # Single reply - use existing function 434 + logger.info(f"Trying single reply {i}/{len(reply_candidates)}: {reply_messages[0][:50]}... (lang: {reply_lang})") 435 + response = bsky_utils.reply_to_notification( 436 + client=atproto_client, 437 + notification=notification_data, 438 + reply_text=reply_messages[0], 439 + lang=reply_lang 440 + ) 441 + else: 442 + # Multiple replies - use new threaded function 443 + logger.info(f"Trying threaded reply {i}/{len(reply_candidates)} with {len(reply_messages)} messages (lang: {reply_lang})") 444 + response = bsky_utils.reply_with_thread_to_notification( 445 + client=atproto_client, 446 + notification=notification_data, 447 + reply_messages=reply_messages, 448 + lang=reply_lang 449 + ) 424 450 425 451 if response: 426 452 logger.info(f"Successfully replied to @{author_handle} with candidate {i}")
+110
bsky_utils.py
··· 439 439 return None 440 440 441 441 442 + def reply_with_thread_to_notification(client: Client, notification: Any, reply_messages: List[str], lang: str = "en-US") -> Optional[List[Dict[str, Any]]]: 443 + """ 444 + Reply to a notification with a threaded chain of messages (max 4). 445 + 446 + Args: 447 + client: Authenticated Bluesky client 448 + notification: The notification object from list_notifications 449 + reply_messages: List of reply texts (max 4 messages, each max 300 chars) 450 + lang: Language code for the posts (defaults to "en-US") 451 + 452 + Returns: 453 + List of responses from sending the replies or None if failed 454 + """ 455 + try: 456 + from typing import List 457 + 458 + # Validate input 459 + if not reply_messages or len(reply_messages) == 0: 460 + logger.error("Reply messages list cannot be empty") 461 + return None 462 + if len(reply_messages) > 4: 463 + logger.error(f"Cannot send more than 4 reply messages (got {len(reply_messages)})") 464 + return None 465 + 466 + # Get the post URI and CID from the notification (handle both dict and object) 467 + if isinstance(notification, dict): 468 + post_uri = notification.get('uri') 469 + post_cid = notification.get('cid') 470 + elif hasattr(notification, 'uri') and hasattr(notification, 'cid'): 471 + post_uri = notification.uri 472 + post_cid = notification.cid 473 + else: 474 + post_uri = None 475 + post_cid = None 476 + 477 + if not post_uri or not post_cid: 478 + logger.error("Notification doesn't have required uri/cid fields") 479 + return None 480 + 481 + # Get the thread to find the root post 482 + thread_data = get_post_thread(client, post_uri) 483 + 484 + root_uri = post_uri 485 + root_cid = post_cid 486 + 487 + if thread_data and hasattr(thread_data, 'thread'): 488 + thread = thread_data.thread 489 + # If this has a parent, find the root 490 + if hasattr(thread, 'parent') and thread.parent: 491 + # Keep going up until we find the root 492 + current = thread 493 + while hasattr(current, 'parent') and current.parent: 494 + current = current.parent 495 + if hasattr(current, 'post') and hasattr(current.post, 'uri') and hasattr(current.post, 'cid'): 496 + root_uri = current.post.uri 497 + root_cid = current.post.cid 498 + 499 + # Send replies in sequence, creating a thread 500 + responses = [] 501 + current_parent_uri = post_uri 502 + current_parent_cid = post_cid 503 + 504 + for i, message in enumerate(reply_messages): 505 + logger.info(f"Sending reply {i+1}/{len(reply_messages)}: {message[:50]}...") 506 + 507 + # Send this reply 508 + response = reply_to_post( 509 + client=client, 510 + text=message, 511 + reply_to_uri=current_parent_uri, 512 + reply_to_cid=current_parent_cid, 513 + root_uri=root_uri, 514 + root_cid=root_cid, 515 + lang=lang 516 + ) 517 + 518 + if not response: 519 + logger.error(f"Failed to send reply {i+1}, posting system failure message") 520 + # Try to post a system failure message 521 + failure_response = reply_to_post( 522 + client=client, 523 + text="[SYSTEM FAILURE: COULD NOT POST MESSAGE, PLEASE TRY AGAIN]", 524 + reply_to_uri=current_parent_uri, 525 + reply_to_cid=current_parent_cid, 526 + root_uri=root_uri, 527 + root_cid=root_cid, 528 + lang=lang 529 + ) 530 + if failure_response: 531 + responses.append(failure_response) 532 + current_parent_uri = failure_response.uri 533 + current_parent_cid = failure_response.cid 534 + else: 535 + logger.error("Could not even send system failure message, stopping thread") 536 + return responses if responses else None 537 + else: 538 + responses.append(response) 539 + # Update parent references for next reply (if any) 540 + if i < len(reply_messages) - 1: # Not the last message 541 + current_parent_uri = response.uri 542 + current_parent_cid = response.cid 543 + 544 + logger.info(f"Successfully sent {len(responses)} threaded replies") 545 + return responses 546 + 547 + except Exception as e: 548 + logger.error(f"Error sending threaded reply to notification: {e}") 549 + return None 550 + 551 + 442 552 if __name__ == "__main__": 443 553 client = default_login() 444 554 # do something with the client
+1 -1
register_tools.py
··· 57 57 { 58 58 "func": bluesky_reply, 59 59 "args_schema": ReplyArgs, 60 - "description": "Simple reply indicator for the Letta agent (max 300 chars)", 60 + "description": "Reply indicator for the Letta agent (1-4 messages, each max 300 chars). Creates threaded replies.", 61 61 "tags": ["bluesky", "reply", "response"] 62 62 }, 63 63 ]
+31 -17
tools/reply.py
··· 1 1 """Reply tool for Bluesky - a simple tool for the Letta agent to indicate a reply.""" 2 - from typing import Optional 2 + from typing import List, Optional 3 3 from pydantic import BaseModel, Field, validator 4 4 5 5 6 6 class ReplyArgs(BaseModel): 7 - message: str = Field( 7 + messages: List[str] = Field( 8 8 ..., 9 - description="The reply message text (max 300 characters)" 9 + description="List of reply messages (each max 300 characters, max 4 messages total). Single item creates one reply, multiple items create a threaded reply chain." 10 10 ) 11 11 lang: Optional[str] = Field( 12 12 default="en-US", 13 - description="Language code for the post (e.g., 'en-US', 'es', 'ja', 'th'). Defaults to 'en-US'" 13 + description="Language code for the posts (e.g., 'en-US', 'es', 'ja', 'th'). Defaults to 'en-US'" 14 14 ) 15 15 16 - @validator('message') 17 - def validate_message_length(cls, v): 18 - if len(v) > 300: 19 - raise ValueError(f"Message cannot be longer than 300 characters (current: {len(v)} characters)") 16 + @validator('messages') 17 + def validate_messages(cls, v): 18 + if not v or len(v) == 0: 19 + raise ValueError("Messages list cannot be empty") 20 + if len(v) > 4: 21 + raise ValueError(f"Cannot send more than 4 reply messages (current: {len(v)} messages)") 22 + for i, message in enumerate(v): 23 + if len(message) > 300: 24 + raise ValueError(f"Message {i+1} cannot be longer than 300 characters (current: {len(message)} characters)") 20 25 return v 21 26 22 27 23 - def bluesky_reply(message: str, lang: str = "en-US") -> str: 28 + def bluesky_reply(messages: List[str], lang: str = "en-US") -> str: 24 29 """ 25 - This is a simple function that returns a string. MUST be less than 300 characters. 30 + This is a simple function that returns a string indicating reply thread will be sent. 26 31 27 32 Args: 28 - message: The reply text (max 300 characters) 29 - lang: Language code for the post (e.g., 'en-US', 'es', 'ja', 'th'). Defaults to 'en-US' 33 + messages: List of reply texts (each max 300 characters, max 4 messages total) 34 + lang: Language code for the posts (e.g., 'en-US', 'es', 'ja', 'th'). Defaults to 'en-US' 30 35 31 36 Returns: 32 - Confirmation message with language info 37 + Confirmation message with language info and message count 33 38 34 39 Raises: 35 - Exception: If message exceeds 300 characters 40 + Exception: If messages list is invalid or messages exceed limits 36 41 """ 37 - if len(message) > 300: 38 - raise Exception(f"Message cannot be longer than 300 characters (current: {len(message)} characters)") 42 + if not messages or len(messages) == 0: 43 + raise Exception("Messages list cannot be empty") 44 + if len(messages) > 4: 45 + raise Exception(f"Cannot send more than 4 reply messages (current: {len(messages)} messages)") 39 46 40 - return f'Reply sent (language: {lang})' 47 + for i, message in enumerate(messages): 48 + if len(message) > 300: 49 + raise Exception(f"Message {i+1} cannot be longer than 300 characters (current: {len(message)} characters)") 50 + 51 + if len(messages) == 1: 52 + return f'Reply sent (language: {lang})' 53 + else: 54 + return f'Reply thread with {len(messages)} messages sent (language: {lang})'