"""Bluesky skill check tool for Letta agents using geometric polygon overlap.""" import math import random import requests from dataclasses import dataclass from typing import Dict, List, Optional @dataclass class Point2D: """2D point in Cartesian coordinates.""" x: float y: float def perform_skill_check(actor: str, challenge: Dict[str, int]) -> str: """ Perform a skill check between a Bluesky user's skills and a challenge requirement. This tool fetches a user's skill data from their Bluesky PDS (stored as studio.voyager.skill record) and performs a geometric skill check by converting both the user's skills and the challenge requirements into radar chart polygons. It calculates the overlap percentage and uses it as the probability for a random success/failure roll. The skill check uses 6 RPG-style stats: logic, empathy, tenacity, ingenuity, finesse, and insight. Each skill has a value between 1-25. The geometric algorithm converts these to polygon vertices, finds the intersection area, and calculates what percentage of the challenge requirements the user's skills cover. Args: actor (str): The identifier for the Bluesky user whose skills will be checked. This can be: - A handle (username): Must be the full handle including domain suffix. Examples: 'user.bsky.social', 'example.com', 'alice.bsky.social' - A DID (decentralized identifier): Must start with 'did:plc:' followed by the unique identifier. Example: 'did:plc:z72i7hdynmk6r22z27h6tvur' The actor parameter is required and cannot be empty. challenge (Dict[str, int]): The skill requirements for the challenge. Must contain all 6 skills as keys with integer values between 1-25: - logic: Reasoning and analytical thinking (1-25) - empathy: Connection and emotional intelligence (1-25) - tenacity: Endurance and persistence (1-25) - ingenuity: Adaptability and creative problem-solving (1-25) - finesse: Technique and precision (1-25) - insight: Perception and awareness (1-25) Example: {'logic': 15, 'empathy': 8, 'tenacity': 12, 'ingenuity': 10, 'finesse': 14, 'insight': 9} Returns: str: A formatted string containing the skill check results with the following sections: - Success or failure indicator with visual emoji - Success probability percentage (makes it clear what chance the player had) - Skill-by-skill comparison showing player vs challenge values - Geometric overlap percentage (how much of challenge requirements were met) If the user doesn't have skill data yet, returns a message indicating they need to set up their skills first. Returns detailed error messages with resolution guidance if there are issues with invalid parameters or API failures. Examples: # Check if user can succeed at a high-logic challenge perform_skill_check( "alice.bsky.social", {"logic": 20, "empathy": 5, "tenacity": 10, "ingenuity": 12, "finesse": 8, "insight": 15} ) # Check using a DID instead of handle perform_skill_check( "did:plc:z72i7hdynmk6r22z27h6tvur", {"logic": 10, "empathy": 15, "tenacity": 8, "ingenuity": 14, "finesse": 12, "insight": 11} ) # Balanced challenge across all skills perform_skill_check( "user.bsky.social", {"logic": 12, "empathy": 13, "tenacity": 12, "ingenuity": 13, "finesse": 12, "insight": 13} ) """ try: from atproto import Client # Validate inputs if not actor or len(actor.strip()) == 0: raise Exception( "Error: The actor parameter is empty. To resolve this, provide a valid " "Bluesky handle (like 'user.bsky.social') or DID (like 'did:plc:...'). " "This is a common mistake and can be fixed by calling the tool again with " "a specific user identifier." ) # Validate challenge parameter required_skills = {'logic', 'empathy', 'tenacity', 'ingenuity', 'finesse', 'insight'} if not isinstance(challenge, dict): raise Exception( "Error: The challenge parameter must be a dictionary with skill names as keys. " f"Expected keys: {', '.join(sorted(required_skills))}. " "To resolve this, pass a dictionary like {'logic': 15, 'empathy': 10, ...}. " "Each skill must have an integer value between 1 and 25." ) missing_skills = required_skills - set(challenge.keys()) if missing_skills: raise Exception( f"Error: The challenge is missing required skills: {', '.join(sorted(missing_skills))}. " f"All 6 skills must be provided: {', '.join(sorted(required_skills))}. " "To resolve this, add the missing skills to the challenge dictionary with values " "between 1 and 25." ) # Validate skill values invalid_skills = [] for skill, value in challenge.items(): if skill not in required_skills: raise Exception( f"Error: Unknown skill '{skill}' in challenge. Valid skills are: " f"{', '.join(sorted(required_skills))}. To resolve this, remove the unknown " "skill or check for typos in the skill name." ) if not isinstance(value, int) or value < 1 or value > 25: invalid_skills.append(f"{skill}={value}") if invalid_skills: raise Exception( f"Error: Invalid skill values in challenge: {', '.join(invalid_skills)}. " "All skill values must be integers between 1 and 25 (inclusive). " "To resolve this, update the challenge dictionary with valid values in this range." ) # Initialize client (no authentication needed for public data) client = Client() # Resolve handle to DID if needed (works for all handles including custom domains) did = actor if not actor.startswith('did:'): try: # Use public identity resolution - no auth required, works with any handle resolve_response = client.com.atproto.identity.resolve_handle(params={'handle': actor}) did = resolve_response.did except Exception as e: error_msg = str(e).lower() if 'not found' in error_msg or '404' in error_msg or 'unable to resolve' in error_msg: raise Exception( f"Error: The handle '{actor}' could not be resolved to a DID. To resolve this, verify " f"the handle is correct. Common issues include typos, an incomplete handle " f"(missing the domain like '.bsky.social'), or the handle may have changed. " f"Try searching for the user on bsky.app to confirm the correct handle." ) raise Exception( f"Error: Failed to resolve handle '{actor}' to a DID. The API returned: {str(e)}. " f"To resolve this, verify the handle is valid and try again." ) # Resolve PDS endpoint from DID document try: did_doc_url = f"https://plc.directory/{did}" did_doc_response = requests.get(did_doc_url, timeout=10) did_doc_response.raise_for_status() did_doc = did_doc_response.json() # Extract PDS service endpoint pds_endpoint = None for service in did_doc.get('service', []): if service.get('type') == 'AtprotoPersonalDataServer': pds_endpoint = service.get('serviceEndpoint') break if not pds_endpoint: raise Exception( f"Error: Could not find PDS endpoint for DID {did}. The DID document may be malformed. " f"To resolve this, verify the user's account is properly configured." ) except requests.RequestException as e: raise Exception( f"Error: Failed to resolve DID document for {did}. The PLC directory may be unavailable. " f"To resolve this, try again in a moment. Technical details: {str(e)}" ) # Fetch skill data from the user's PDS try: record_url = f"{pds_endpoint}/xrpc/com.atproto.repo.getRecord" params = { 'repo': did, 'collection': 'studio.voyager.skill', 'rkey': 'self' } record_response = requests.get(record_url, params=params, timeout=10) if record_response.status_code == 404: return f"User {actor} does not have skill data yet. They need to complete the personality quiz to engrave their Soul Shape stats at studio.voyager before attempting skill checks." record_response.raise_for_status() record_data = record_response.json() # Extract skills from the nested stats object skill_data = record_data.get('value', {}) stats = skill_data.get('stats', {}) player_skills = { 'logic': stats.get('logic'), 'empathy': stats.get('empathy'), 'tenacity': stats.get('tenacity'), 'ingenuity': stats.get('ingenuity'), 'finesse': stats.get('finesse'), 'insight': stats.get('insight') } # Validate that all skills have valid values missing_or_invalid = [] for skill, value in player_skills.items(): if value is None: missing_or_invalid.append(f"{skill}=None") elif not isinstance(value, int) or value < 1 or value > 25: missing_or_invalid.append(f"{skill}={value}") if missing_or_invalid: raise Exception( f"Error: The user's skill data is incomplete or invalid. Skills with issues: {', '.join(missing_or_invalid)}. " f"All skills must have integer values between 1 and 25. The user may need to re-complete their " f"personality quiz at studio.voyager to fix their Soul Shape data." ) except requests.RequestException as e: if 'Error:' in str(e): raise raise Exception( f"Error: Failed to fetch skill data for '{actor}' from their PDS ({pds_endpoint}). " f"The PDS may be unavailable or the record may not exist. To resolve this, verify the user " f"has completed their skill setup and try again. Technical details: {str(e)}" ) # Perform the geometric skill check # Create polygons for player and challenge max_values = {skill: 25 for skill in required_skills} # All skills max at 25 player_polygon = _skills_to_polygon(player_skills, max_values) challenge_polygon = _skills_to_polygon(challenge, max_values) # Calculate overlap overlap_percentage = _calculate_overlap_percentage(player_polygon, challenge_polygon) # Perform probabilistic roll roll = random.random() * 100 success = roll <= overlap_percentage # Format the response status_emoji = "✅" if success else "❌" status_text = "SUCCESS" if success else "FAILURE" response = f"""{status_emoji} Skill check {status_text} (had {overlap_percentage:.1f}% chance of succeeding) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📊 Challenge Requirements vs Player Skills """ # Add skill-by-skill comparison for skill in sorted(required_skills): player_val = player_skills[skill] challenge_val = challenge[skill] # Visual indicator if player_val >= challenge_val: indicator = "✓" else: indicator = "✗" # Create simple bar visualization player_bar = "█" * player_val challenge_bar = "░" * challenge_val response += f"\n {indicator} {skill.capitalize():12s}: Player {player_val:2d}/25 Challenge {challenge_val:2d}/25" response += f"\n\n🔷 Geometric Overlap: {overlap_percentage:.1f}% of challenge requirements met" return response except ImportError: raise Exception( "Error: The atproto Python package is not installed in the execution environment. " "To resolve this, the system administrator needs to install it using 'pip install atproto'. " "This is a dependency issue that prevents the tool from connecting to Bluesky. Once the " "package is installed, this tool will work normally." ) except Exception as e: # Re-raise if it's already one of our formatted error messages if str(e).startswith("Error:"): raise # Otherwise wrap it with helpful context raise Exception( f"Error: An unexpected issue occurred during the skill check: {str(e)}. " f"To resolve this, verify all parameters are correct and try again. This type of error " f"is uncommon but can usually be resolved by retrying or checking the input parameters." ) # ============================================================================ # Internal Helper Functions - Geometric Algorithm Implementation # ============================================================================ def _skills_to_polygon(skills: Dict[str, float], max_values: Dict[str, float], radius: float = 100.0) -> List[Point2D]: # Sort skills alphabetically for consistent vertex ordering sorted_skills = sorted(skills.keys()) n = len(sorted_skills) vertices = [] for i, skill in enumerate(sorted_skills): # Calculate angle for this skill (distributed evenly) angle = (i * 2 * math.pi) / n # Normalize skill value (0 to 1 based on max) normalized_value = skills[skill] / max_values[skill] # Calculate distance from center distance = normalized_value * radius # Convert polar to Cartesian coordinates x = distance * math.cos(angle) y = distance * math.sin(angle) vertices.append(Point2D(x, y)) return vertices def _calculate_overlap_percentage(player_polygon: List[Point2D], challenge_polygon: List[Point2D]) -> float: # Find intersection: clip challenge against player to see how much of challenge is covered intersection = _polygon_intersection(challenge_polygon, player_polygon) # If no intersection, 0% overlap if len(intersection) == 0: return 0.0 # Calculate areas intersection_area = _polygon_area(intersection) challenge_area = _polygon_area(challenge_polygon) # Edge case: zero-area challenge means 100% success if challenge_area == 0: return 100.0 # Calculate percentage overlap_percentage = (intersection_area / challenge_area) * 100 # Clamp to [0, 100] range return max(0.0, min(100.0, overlap_percentage)) def _polygon_intersection(subject: List[Point2D], clip: List[Point2D]) -> List[Point2D]: if len(subject) < 3 or len(clip) < 3: return [] output = list(subject) # Clip against each edge of the clip polygon for i in range(len(clip)): if len(output) == 0: break edge_start = clip[i] edge_end = clip[(i + 1) % len(clip)] input_list = output output = [] if len(input_list) == 0: continue # Process each edge of the current polygon for j in range(len(input_list)): current = input_list[j] next_vertex = input_list[(j + 1) % len(input_list)] current_inside = _is_point_inside_edge(current, edge_start, edge_end) next_inside = _is_point_inside_edge(next_vertex, edge_start, edge_end) if next_inside: if not current_inside: # Entering: add intersection point intersection = _line_intersection(current, next_vertex, edge_start, edge_end) if intersection: output.append(intersection) # Add the next vertex output.append(next_vertex) elif current_inside: # Exiting: add intersection point only intersection = _line_intersection(current, next_vertex, edge_start, edge_end) if intersection: output.append(intersection) return output def _is_point_inside_edge(point: Point2D, edge_start: Point2D, edge_end: Point2D) -> bool: dx = edge_end.x - edge_start.x dy = edge_end.y - edge_start.y dxp = point.x - edge_start.x dyp = point.y - edge_start.y # Cross product: positive means point is on the left (inside) cross_product = dx * dyp - dy * dxp return cross_product >= 0 def _line_intersection(p1: Point2D, p2: Point2D, p3: Point2D, p4: Point2D) -> Optional[Point2D]: denom = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x) # Lines are parallel if abs(denom) < 1e-10: return None t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / denom # Calculate intersection point x = p1.x + t * (p2.x - p1.x) y = p1.y + t * (p2.y - p1.y) return Point2D(x, y) def _polygon_area(vertices: List[Point2D]) -> float: if len(vertices) < 3: return 0.0 area = 0.0 n = len(vertices) for i in range(n): j = (i + 1) % n area += vertices[i].x * vertices[j].y area -= vertices[j].x * vertices[i].y return abs(area) / 2.0