a letta agent python tool for running a skill check against an atproto user using skill data in their PDS - https://stats.voyager.studio
perform_skill_check.py edited
441 lines 18 kB view raw
1"""Bluesky skill check tool for Letta agents using geometric polygon overlap.""" 2 3import math 4import random 5import requests 6from dataclasses import dataclass 7from typing import Dict, List, Optional 8 9 10@dataclass 11class Point2D: 12 """2D point in Cartesian coordinates.""" 13 x: float 14 y: float 15 16 17def perform_skill_check(actor: str, challenge: Dict[str, int]) -> str: 18 """ 19 Perform a skill check between a Bluesky user's skills and a challenge requirement. 20 21 This tool fetches a user's skill data from their Bluesky PDS (stored as studio.voyager.skill 22 record) and performs a geometric skill check by converting both the user's skills and the 23 challenge requirements into radar chart polygons. It calculates the overlap percentage and 24 uses it as the probability for a random success/failure roll. 25 26 The skill check uses 6 RPG-style stats: logic, empathy, tenacity, ingenuity, finesse, and 27 insight. Each skill has a value between 1-25. The geometric algorithm converts these to 28 polygon vertices, finds the intersection area, and calculates what percentage of the 29 challenge requirements the user's skills cover. 30 31 Args: 32 actor (str): The identifier for the Bluesky user whose skills will be checked. This can be: 33 - A handle (username): Must be the full handle including domain suffix. 34 Examples: 'user.bsky.social', 'example.com', 'alice.bsky.social' 35 - A DID (decentralized identifier): Must start with 'did:plc:' followed 36 by the unique identifier. Example: 'did:plc:z72i7hdynmk6r22z27h6tvur' 37 38 The actor parameter is required and cannot be empty. 39 40 challenge (Dict[str, int]): The skill requirements for the challenge. Must contain 41 all 6 skills as keys with integer values between 1-25: 42 - logic: Reasoning and analytical thinking (1-25) 43 - empathy: Connection and emotional intelligence (1-25) 44 - tenacity: Endurance and persistence (1-25) 45 - ingenuity: Adaptability and creative problem-solving (1-25) 46 - finesse: Technique and precision (1-25) 47 - insight: Perception and awareness (1-25) 48 49 Example: {'logic': 15, 'empathy': 8, 'tenacity': 12, 'ingenuity': 10, 50 'finesse': 14, 'insight': 9} 51 52 Returns: 53 str: A formatted string containing the skill check results with the following sections: 54 - Success or failure indicator with visual emoji 55 - Success probability percentage (makes it clear what chance the player had) 56 - Skill-by-skill comparison showing player vs challenge values 57 - Geometric overlap percentage (how much of challenge requirements were met) 58 59 If the user doesn't have skill data yet, returns a message indicating they need 60 to set up their skills first. 61 62 Returns detailed error messages with resolution guidance if there are issues 63 with invalid parameters or API failures. 64 65 Examples: 66 # Check if user can succeed at a high-logic challenge 67 perform_skill_check( 68 "alice.bsky.social", 69 {"logic": 20, "empathy": 5, "tenacity": 10, "ingenuity": 12, 70 "finesse": 8, "insight": 15} 71 ) 72 73 # Check using a DID instead of handle 74 perform_skill_check( 75 "did:plc:z72i7hdynmk6r22z27h6tvur", 76 {"logic": 10, "empathy": 15, "tenacity": 8, "ingenuity": 14, 77 "finesse": 12, "insight": 11} 78 ) 79 80 # Balanced challenge across all skills 81 perform_skill_check( 82 "user.bsky.social", 83 {"logic": 12, "empathy": 13, "tenacity": 12, "ingenuity": 13, 84 "finesse": 12, "insight": 13} 85 ) 86 """ 87 try: 88 from atproto import Client 89 90 # Validate inputs 91 if not actor or len(actor.strip()) == 0: 92 raise Exception( 93 "Error: The actor parameter is empty. To resolve this, provide a valid " 94 "Bluesky handle (like 'user.bsky.social') or DID (like 'did:plc:...'). " 95 "This is a common mistake and can be fixed by calling the tool again with " 96 "a specific user identifier." 97 ) 98 99 # Validate challenge parameter 100 required_skills = {'logic', 'empathy', 'tenacity', 'ingenuity', 'finesse', 'insight'} 101 if not isinstance(challenge, dict): 102 raise Exception( 103 "Error: The challenge parameter must be a dictionary with skill names as keys. " 104 f"Expected keys: {', '.join(sorted(required_skills))}. " 105 "To resolve this, pass a dictionary like {'logic': 15, 'empathy': 10, ...}. " 106 "Each skill must have an integer value between 1 and 25." 107 ) 108 109 missing_skills = required_skills - set(challenge.keys()) 110 if missing_skills: 111 raise Exception( 112 f"Error: The challenge is missing required skills: {', '.join(sorted(missing_skills))}. " 113 f"All 6 skills must be provided: {', '.join(sorted(required_skills))}. " 114 "To resolve this, add the missing skills to the challenge dictionary with values " 115 "between 1 and 25." 116 ) 117 118 # Validate skill values 119 invalid_skills = [] 120 for skill, value in challenge.items(): 121 if skill not in required_skills: 122 raise Exception( 123 f"Error: Unknown skill '{skill}' in challenge. Valid skills are: " 124 f"{', '.join(sorted(required_skills))}. To resolve this, remove the unknown " 125 "skill or check for typos in the skill name." 126 ) 127 if not isinstance(value, int) or value < 1 or value > 25: 128 invalid_skills.append(f"{skill}={value}") 129 130 if invalid_skills: 131 raise Exception( 132 f"Error: Invalid skill values in challenge: {', '.join(invalid_skills)}. " 133 "All skill values must be integers between 1 and 25 (inclusive). " 134 "To resolve this, update the challenge dictionary with valid values in this range." 135 ) 136 137 # Initialize client (no authentication needed for public data) 138 client = Client() 139 140 # Resolve handle to DID if needed (works for all handles including custom domains) 141 did = actor 142 if not actor.startswith('did:'): 143 try: 144 # Use public identity resolution - no auth required, works with any handle 145 resolve_response = client.com.atproto.identity.resolve_handle(params={'handle': actor}) 146 did = resolve_response.did 147 except Exception as e: 148 error_msg = str(e).lower() 149 if 'not found' in error_msg or '404' in error_msg or 'unable to resolve' in error_msg: 150 raise Exception( 151 f"Error: The handle '{actor}' could not be resolved to a DID. To resolve this, verify " 152 f"the handle is correct. Common issues include typos, an incomplete handle " 153 f"(missing the domain like '.bsky.social'), or the handle may have changed. " 154 f"Try searching for the user on bsky.app to confirm the correct handle." 155 ) 156 raise Exception( 157 f"Error: Failed to resolve handle '{actor}' to a DID. The API returned: {str(e)}. " 158 f"To resolve this, verify the handle is valid and try again." 159 ) 160 161 # Resolve PDS endpoint from DID document 162 try: 163 did_doc_url = f"https://plc.directory/{did}" 164 did_doc_response = requests.get(did_doc_url, timeout=10) 165 did_doc_response.raise_for_status() 166 did_doc = did_doc_response.json() 167 168 # Extract PDS service endpoint 169 pds_endpoint = None 170 for service in did_doc.get('service', []): 171 if service.get('type') == 'AtprotoPersonalDataServer': 172 pds_endpoint = service.get('serviceEndpoint') 173 break 174 175 if not pds_endpoint: 176 raise Exception( 177 f"Error: Could not find PDS endpoint for DID {did}. The DID document may be malformed. " 178 f"To resolve this, verify the user's account is properly configured." 179 ) 180 181 except requests.RequestException as e: 182 raise Exception( 183 f"Error: Failed to resolve DID document for {did}. The PLC directory may be unavailable. " 184 f"To resolve this, try again in a moment. Technical details: {str(e)}" 185 ) 186 187 # Fetch skill data from the user's PDS 188 try: 189 record_url = f"{pds_endpoint}/xrpc/com.atproto.repo.getRecord" 190 params = { 191 'repo': did, 192 'collection': 'studio.voyager.skill', 193 'rkey': 'self' 194 } 195 record_response = requests.get(record_url, params=params, timeout=10) 196 197 if record_response.status_code == 404: 198 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." 199 200 record_response.raise_for_status() 201 record_data = record_response.json() 202 203 # Extract skills from the nested stats object 204 skill_data = record_data.get('value', {}) 205 stats = skill_data.get('stats', {}) 206 207 player_skills = { 208 'logic': stats.get('logic'), 209 'empathy': stats.get('empathy'), 210 'tenacity': stats.get('tenacity'), 211 'ingenuity': stats.get('ingenuity'), 212 'finesse': stats.get('finesse'), 213 'insight': stats.get('insight') 214 } 215 216 # Validate that all skills have valid values 217 missing_or_invalid = [] 218 for skill, value in player_skills.items(): 219 if value is None: 220 missing_or_invalid.append(f"{skill}=None") 221 elif not isinstance(value, int) or value < 1 or value > 25: 222 missing_or_invalid.append(f"{skill}={value}") 223 224 if missing_or_invalid: 225 raise Exception( 226 f"Error: The user's skill data is incomplete or invalid. Skills with issues: {', '.join(missing_or_invalid)}. " 227 f"All skills must have integer values between 1 and 25. The user may need to re-complete their " 228 f"personality quiz at studio.voyager to fix their Soul Shape data." 229 ) 230 231 except requests.RequestException as e: 232 if 'Error:' in str(e): 233 raise 234 raise Exception( 235 f"Error: Failed to fetch skill data for '{actor}' from their PDS ({pds_endpoint}). " 236 f"The PDS may be unavailable or the record may not exist. To resolve this, verify the user " 237 f"has completed their skill setup and try again. Technical details: {str(e)}" 238 ) 239 240 # Perform the geometric skill check 241 # Create polygons for player and challenge 242 max_values = {skill: 25 for skill in required_skills} # All skills max at 25 243 244 player_polygon = _skills_to_polygon(player_skills, max_values) 245 challenge_polygon = _skills_to_polygon(challenge, max_values) 246 247 # Calculate overlap 248 overlap_percentage = _calculate_overlap_percentage(player_polygon, challenge_polygon) 249 250 # Perform probabilistic roll 251 roll = random.random() * 100 252 success = roll <= overlap_percentage 253 254 # Format the response 255 status_emoji = "" if success else "" 256 status_text = "SUCCESS" if success else "FAILURE" 257 258 response = f"""{status_emoji} Skill check {status_text} (had {overlap_percentage:.1f}% chance of succeeding) 259━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 260 261📊 Challenge Requirements vs Player Skills 262""" 263 264 # Add skill-by-skill comparison 265 for skill in sorted(required_skills): 266 player_val = player_skills[skill] 267 challenge_val = challenge[skill] 268 269 # Visual indicator 270 if player_val >= challenge_val: 271 indicator = "" 272 else: 273 indicator = "" 274 275 # Create simple bar visualization 276 player_bar = "" * player_val 277 challenge_bar = "" * challenge_val 278 279 response += f"\n {indicator} {skill.capitalize():12s}: Player {player_val:2d}/25 Challenge {challenge_val:2d}/25" 280 281 response += f"\n\n🔷 Geometric Overlap: {overlap_percentage:.1f}% of challenge requirements met" 282 283 return response 284 285 except ImportError: 286 raise Exception( 287 "Error: The atproto Python package is not installed in the execution environment. " 288 "To resolve this, the system administrator needs to install it using 'pip install atproto'. " 289 "This is a dependency issue that prevents the tool from connecting to Bluesky. Once the " 290 "package is installed, this tool will work normally." 291 ) 292 except Exception as e: 293 # Re-raise if it's already one of our formatted error messages 294 if str(e).startswith("Error:"): 295 raise 296 # Otherwise wrap it with helpful context 297 raise Exception( 298 f"Error: An unexpected issue occurred during the skill check: {str(e)}. " 299 f"To resolve this, verify all parameters are correct and try again. This type of error " 300 f"is uncommon but can usually be resolved by retrying or checking the input parameters." 301 ) 302 303 304# ============================================================================ 305# Internal Helper Functions - Geometric Algorithm Implementation 306# ============================================================================ 307 308def _skills_to_polygon(skills: Dict[str, float], max_values: Dict[str, float], radius: float = 100.0) -> List[Point2D]: 309 # Sort skills alphabetically for consistent vertex ordering 310 sorted_skills = sorted(skills.keys()) 311 n = len(sorted_skills) 312 313 vertices = [] 314 for i, skill in enumerate(sorted_skills): 315 # Calculate angle for this skill (distributed evenly) 316 angle = (i * 2 * math.pi) / n 317 318 # Normalize skill value (0 to 1 based on max) 319 normalized_value = skills[skill] / max_values[skill] 320 321 # Calculate distance from center 322 distance = normalized_value * radius 323 324 # Convert polar to Cartesian coordinates 325 x = distance * math.cos(angle) 326 y = distance * math.sin(angle) 327 328 vertices.append(Point2D(x, y)) 329 330 return vertices 331 332 333def _calculate_overlap_percentage(player_polygon: List[Point2D], challenge_polygon: List[Point2D]) -> float: 334 # Find intersection: clip challenge against player to see how much of challenge is covered 335 intersection = _polygon_intersection(challenge_polygon, player_polygon) 336 337 # If no intersection, 0% overlap 338 if len(intersection) == 0: 339 return 0.0 340 341 # Calculate areas 342 intersection_area = _polygon_area(intersection) 343 challenge_area = _polygon_area(challenge_polygon) 344 345 # Edge case: zero-area challenge means 100% success 346 if challenge_area == 0: 347 return 100.0 348 349 # Calculate percentage 350 overlap_percentage = (intersection_area / challenge_area) * 100 351 352 # Clamp to [0, 100] range 353 return max(0.0, min(100.0, overlap_percentage)) 354 355 356def _polygon_intersection(subject: List[Point2D], clip: List[Point2D]) -> List[Point2D]: 357 if len(subject) < 3 or len(clip) < 3: 358 return [] 359 360 output = list(subject) 361 362 # Clip against each edge of the clip polygon 363 for i in range(len(clip)): 364 if len(output) == 0: 365 break 366 367 edge_start = clip[i] 368 edge_end = clip[(i + 1) % len(clip)] 369 370 input_list = output 371 output = [] 372 373 if len(input_list) == 0: 374 continue 375 376 # Process each edge of the current polygon 377 for j in range(len(input_list)): 378 current = input_list[j] 379 next_vertex = input_list[(j + 1) % len(input_list)] 380 381 current_inside = _is_point_inside_edge(current, edge_start, edge_end) 382 next_inside = _is_point_inside_edge(next_vertex, edge_start, edge_end) 383 384 if next_inside: 385 if not current_inside: 386 # Entering: add intersection point 387 intersection = _line_intersection(current, next_vertex, edge_start, edge_end) 388 if intersection: 389 output.append(intersection) 390 # Add the next vertex 391 output.append(next_vertex) 392 elif current_inside: 393 # Exiting: add intersection point only 394 intersection = _line_intersection(current, next_vertex, edge_start, edge_end) 395 if intersection: 396 output.append(intersection) 397 398 return output 399 400 401def _is_point_inside_edge(point: Point2D, edge_start: Point2D, edge_end: Point2D) -> bool: 402 dx = edge_end.x - edge_start.x 403 dy = edge_end.y - edge_start.y 404 dxp = point.x - edge_start.x 405 dyp = point.y - edge_start.y 406 407 # Cross product: positive means point is on the left (inside) 408 cross_product = dx * dyp - dy * dxp 409 410 return cross_product >= 0 411 412 413def _line_intersection(p1: Point2D, p2: Point2D, p3: Point2D, p4: Point2D) -> Optional[Point2D]: 414 denom = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x) 415 416 # Lines are parallel 417 if abs(denom) < 1e-10: 418 return None 419 420 t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / denom 421 422 # Calculate intersection point 423 x = p1.x + t * (p2.x - p1.x) 424 y = p1.y + t * (p2.y - p1.y) 425 426 return Point2D(x, y) 427 428 429def _polygon_area(vertices: List[Point2D]) -> float: 430 if len(vertices) < 3: 431 return 0.0 432 433 area = 0.0 434 n = len(vertices) 435 436 for i in range(n): 437 j = (i + 1) % n 438 area += vertices[i].x * vertices[j].y 439 area -= vertices[j].x * vertices[i].y 440 441 return abs(area) / 2.0