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
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