tangled
alpha
login
or
join now
bunware.org
/
void
forked from
cameron.stream/void
0
fork
atom
this repo has no description
0
fork
atom
overview
issues
pulls
pipelines
Delete create_profile_researcher.py
cameron.stream
9 months ago
17a39203
da5ca0c9
-522
1 changed file
expand all
collapse all
unified
split
create_profile_researcher.py
-522
create_profile_researcher.py
···
1
1
-
#!/usr/bin/env python3
2
2
-
"""
3
3
-
Script to create a Letta agent that researches Bluesky profiles and updates
4
4
-
the model's understanding of users.
5
5
-
"""
6
6
-
7
7
-
import os
8
8
-
import logging
9
9
-
from letta_client import Letta
10
10
-
from utils import upsert_block, upsert_agent
11
11
-
12
12
-
# Configure logging
13
13
-
logging.basicConfig(
14
14
-
level=logging.INFO,
15
15
-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
16
16
-
)
17
17
-
logger = logging.getLogger("profile_researcher")
18
18
-
19
19
-
# Use the "Bluesky" project
20
20
-
PROJECT_ID = "5ec33d52-ab14-4fd6-91b5-9dbc43e888a8"
21
21
-
22
22
-
def create_search_posts_tool(client: Letta):
23
23
-
"""Create the Bluesky search posts tool using Letta SDK."""
24
24
-
25
25
-
def search_bluesky_posts(query: str, max_results: int = 25, author: str = None, sort: str = "latest") -> str:
26
26
-
"""
27
27
-
Search for posts on Bluesky matching the given criteria.
28
28
-
29
29
-
Args:
30
30
-
query: Search query string (required)
31
31
-
max_results: Maximum number of results to return (default: 25, max: 100)
32
32
-
author: Filter to posts by a specific author handle (optional)
33
33
-
sort: Sort order - "latest" or "top" (default: "latest")
34
34
-
35
35
-
Returns:
36
36
-
YAML-formatted search results with posts and metadata
37
37
-
"""
38
38
-
import os
39
39
-
import requests
40
40
-
import json
41
41
-
import yaml
42
42
-
from datetime import datetime
43
43
-
44
44
-
try:
45
45
-
# Use public Bluesky API
46
46
-
base_url = "https://public.api.bsky.app"
47
47
-
48
48
-
# Build search parameters
49
49
-
params = {
50
50
-
"q": query,
51
51
-
"limit": min(max_results, 100),
52
52
-
"sort": sort
53
53
-
}
54
54
-
55
55
-
# Add optional author filter
56
56
-
if author:
57
57
-
params["author"] = author.lstrip('@')
58
58
-
59
59
-
# Make search request
60
60
-
try:
61
61
-
search_url = f"{base_url}/xrpc/app.bsky.feed.searchPosts"
62
62
-
search_response = requests.get(search_url, params=params, timeout=10)
63
63
-
search_response.raise_for_status()
64
64
-
search_data = search_response.json()
65
65
-
except requests.exceptions.HTTPError as e:
66
66
-
raise RuntimeError(f"Search failed with HTTP {e.response.status_code}: {e.response.text}")
67
67
-
except requests.exceptions.RequestException as e:
68
68
-
raise RuntimeError(f"Network error during search: {str(e)}")
69
69
-
except Exception as e:
70
70
-
raise RuntimeError(f"Unexpected error during search: {str(e)}")
71
71
-
72
72
-
# Build search results structure
73
73
-
results_data = {
74
74
-
"search_results": {
75
75
-
"query": query,
76
76
-
"timestamp": datetime.now().isoformat(),
77
77
-
"parameters": {
78
78
-
"sort": sort,
79
79
-
"max_results": max_results,
80
80
-
"author_filter": author if author else "none"
81
81
-
},
82
82
-
"results": search_data
83
83
-
}
84
84
-
}
85
85
-
86
86
-
# Fields to strip for cleaner output
87
87
-
strip_fields = [
88
88
-
"cid", "rev", "did", "uri", "langs", "threadgate", "py_type",
89
89
-
"labels", "facets", "avatar", "viewer", "indexed_at", "indexedAt",
90
90
-
"tags", "associated", "thread_context", "image", "aspect_ratio",
91
91
-
"alt", "thumb", "fullsize", "root", "parent", "created_at",
92
92
-
"createdAt", "verification", "embedding_disabled", "thread_muted",
93
93
-
"reply_disabled", "pinned", "like", "repost", "blocked_by",
94
94
-
"blocking", "blocking_by_list", "followed_by", "following",
95
95
-
"known_followers", "muted", "muted_by_list", "root_author_like",
96
96
-
"embed", "entities", "reason", "feedContext"
97
97
-
]
98
98
-
99
99
-
# Remove unwanted fields by traversing the data structure
100
100
-
def remove_fields(obj, fields_to_remove):
101
101
-
if isinstance(obj, dict):
102
102
-
return {k: remove_fields(v, fields_to_remove)
103
103
-
for k, v in obj.items()
104
104
-
if k not in fields_to_remove}
105
105
-
elif isinstance(obj, list):
106
106
-
return [remove_fields(item, fields_to_remove) for item in obj]
107
107
-
else:
108
108
-
return obj
109
109
-
110
110
-
# Clean the data
111
111
-
cleaned_data = remove_fields(results_data, strip_fields)
112
112
-
113
113
-
# Convert to YAML for better readability
114
114
-
return yaml.dump(cleaned_data, default_flow_style=False, allow_unicode=True)
115
115
-
116
116
-
except ValueError as e:
117
117
-
# User-friendly errors
118
118
-
raise ValueError(str(e))
119
119
-
except RuntimeError as e:
120
120
-
# Network/API errors
121
121
-
raise RuntimeError(str(e))
122
122
-
except yaml.YAMLError as e:
123
123
-
# YAML conversion errors
124
124
-
raise RuntimeError(f"Error formatting output: {str(e)}")
125
125
-
except Exception as e:
126
126
-
# Catch-all for unexpected errors
127
127
-
raise RuntimeError(f"Unexpected error searching posts with query '{query}': {str(e)}")
128
128
-
129
129
-
# Create the tool using upsert
130
130
-
tool = client.tools.upsert_from_function(
131
131
-
func=search_bluesky_posts,
132
132
-
tags=["bluesky", "search", "posts"]
133
133
-
)
134
134
-
135
135
-
logger.info(f"Created tool: {tool.name} (ID: {tool.id})")
136
136
-
return tool
137
137
-
138
138
-
def create_profile_research_tool(client: Letta):
139
139
-
"""Create the Bluesky profile research tool using Letta SDK."""
140
140
-
141
141
-
def research_bluesky_profile(handle: str, max_posts: int = 20) -> str:
142
142
-
"""
143
143
-
Research a Bluesky user's profile and recent posts to understand their interests and behavior.
144
144
-
145
145
-
Args:
146
146
-
handle: The Bluesky handle to research (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org')
147
147
-
max_posts: Maximum number of recent posts to analyze (default: 20)
148
148
-
149
149
-
Returns:
150
150
-
A comprehensive analysis of the user's profile and posting patterns
151
151
-
"""
152
152
-
import os
153
153
-
import requests
154
154
-
import json
155
155
-
import yaml
156
156
-
from datetime import datetime
157
157
-
158
158
-
try:
159
159
-
# Clean handle (remove @ if present)
160
160
-
clean_handle = handle.lstrip('@')
161
161
-
162
162
-
# Use public Bluesky API (no auth required for public data)
163
163
-
base_url = "https://public.api.bsky.app"
164
164
-
165
165
-
# Get profile information
166
166
-
try:
167
167
-
profile_url = f"{base_url}/xrpc/app.bsky.actor.getProfile"
168
168
-
profile_response = requests.get(profile_url, params={"actor": clean_handle}, timeout=10)
169
169
-
profile_response.raise_for_status()
170
170
-
profile_data = profile_response.json()
171
171
-
except requests.exceptions.HTTPError as e:
172
172
-
if e.response.status_code == 404:
173
173
-
raise ValueError(f"Profile @{clean_handle} not found")
174
174
-
raise RuntimeError(f"HTTP error {e.response.status_code}: {e.response.text}")
175
175
-
except requests.exceptions.RequestException as e:
176
176
-
raise RuntimeError(f"Network error: {str(e)}")
177
177
-
except Exception as e:
178
178
-
raise RuntimeError(f"Unexpected error fetching profile: {str(e)}")
179
179
-
180
180
-
# Get recent posts feed
181
181
-
try:
182
182
-
feed_url = f"{base_url}/xrpc/app.bsky.feed.getAuthorFeed"
183
183
-
feed_response = requests.get(feed_url, params={
184
184
-
"actor": clean_handle,
185
185
-
"limit": min(max_posts, 50) # API limit
186
186
-
}, timeout=10)
187
187
-
feed_response.raise_for_status()
188
188
-
feed_data = feed_response.json()
189
189
-
except Exception as e:
190
190
-
# Continue with empty feed if posts can't be fetched
191
191
-
feed_data = {"feed": []}
192
192
-
193
193
-
# Build research data structure
194
194
-
research_data = {
195
195
-
"profile_research": {
196
196
-
"handle": f"@{clean_handle}",
197
197
-
"timestamp": datetime.now().isoformat(),
198
198
-
"profile": profile_data,
199
199
-
"author_feed": feed_data
200
200
-
}
201
201
-
}
202
202
-
203
203
-
# Fields to strip for cleaner output
204
204
-
strip_fields = [
205
205
-
"cid", "rev", "did", "uri", "langs", "threadgate", "py_type",
206
206
-
"labels", "facets", "avatar", "viewer", "indexed_at", "indexedAt",
207
207
-
"tags", "associated", "thread_context", "image", "aspect_ratio",
208
208
-
"alt", "thumb", "fullsize", "root", "parent", "created_at",
209
209
-
"createdAt", "verification", "embedding_disabled", "thread_muted",
210
210
-
"reply_disabled", "pinned", "like", "repost", "blocked_by",
211
211
-
"blocking", "blocking_by_list", "followed_by", "following",
212
212
-
"known_followers", "muted", "muted_by_list", "root_author_like",
213
213
-
"embed", "entities", "reason", "feedContext"
214
214
-
]
215
215
-
216
216
-
# Remove unwanted fields by traversing the data structure
217
217
-
def remove_fields(obj, fields_to_remove):
218
218
-
if isinstance(obj, dict):
219
219
-
return {k: remove_fields(v, fields_to_remove)
220
220
-
for k, v in obj.items()
221
221
-
if k not in fields_to_remove}
222
222
-
elif isinstance(obj, list):
223
223
-
return [remove_fields(item, fields_to_remove) for item in obj]
224
224
-
else:
225
225
-
return obj
226
226
-
227
227
-
# Clean the data
228
228
-
cleaned_data = remove_fields(research_data, strip_fields)
229
229
-
230
230
-
# Convert to YAML for better readability
231
231
-
return yaml.dump(cleaned_data, default_flow_style=False, allow_unicode=True)
232
232
-
233
233
-
except ValueError as e:
234
234
-
# User-friendly errors
235
235
-
raise ValueError(str(e))
236
236
-
except RuntimeError as e:
237
237
-
# Network/API errors
238
238
-
raise RuntimeError(str(e))
239
239
-
except yaml.YAMLError as e:
240
240
-
# YAML conversion errors
241
241
-
raise RuntimeError(f"Error formatting output: {str(e)}")
242
242
-
except Exception as e:
243
243
-
# Catch-all for unexpected errors
244
244
-
raise RuntimeError(f"Unexpected error researching profile {handle}: {str(e)}")
245
245
-
246
246
-
# Create or update the tool using upsert
247
247
-
tool = client.tools.upsert_from_function(
248
248
-
func=research_bluesky_profile,
249
249
-
tags=["bluesky", "profile", "research"]
250
250
-
)
251
251
-
252
252
-
logger.info(f"Created tool: {tool.name} (ID: {tool.id})")
253
253
-
return tool
254
254
-
255
255
-
def create_block_management_tools(client: Letta):
256
256
-
"""Create tools for attaching and detaching user blocks."""
257
257
-
258
258
-
def attach_user_block(handle: str) -> str:
259
259
-
"""
260
260
-
Create (if needed) and attach a user-specific memory block for a Bluesky user.
261
261
-
262
262
-
Args:
263
263
-
handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org')
264
264
-
265
265
-
Returns:
266
266
-
Status message about the block attachment
267
267
-
"""
268
268
-
import os
269
269
-
from letta_client import Letta
270
270
-
271
271
-
try:
272
272
-
# Clean handle for block label
273
273
-
clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_')
274
274
-
block_label = f"user_{clean_handle}"
275
275
-
276
276
-
# Initialize Letta client
277
277
-
letta_client = Letta(token=os.environ["LETTA_API_KEY"])
278
278
-
279
279
-
# Get current agent (this tool is being called by)
280
280
-
# We need to find the agent that's calling this tool
281
281
-
# For now, we'll find the profile-researcher agent
282
282
-
agents = letta_client.agents.list(name="profile-researcher")
283
283
-
if not agents:
284
284
-
return "Error: Could not find profile-researcher agent"
285
285
-
286
286
-
agent = agents[0]
287
287
-
288
288
-
# Check if block already exists and is attached
289
289
-
agent_blocks = letta_client.agents.blocks.list(agent_id=agent.id)
290
290
-
for block in agent_blocks:
291
291
-
if block.label == block_label:
292
292
-
return f"User block for @{handle} is already attached (label: {block_label})"
293
293
-
294
294
-
# Create or get the user block
295
295
-
existing_blocks = letta_client.blocks.list(label=block_label)
296
296
-
297
297
-
if existing_blocks:
298
298
-
user_block = existing_blocks[0]
299
299
-
action = "Retrieved existing"
300
300
-
else:
301
301
-
user_block = letta_client.blocks.create(
302
302
-
label=block_label,
303
303
-
value=f"User information for @{handle} will be stored here as I learn about them through profile research and interactions.",
304
304
-
description=f"Stores detailed information about Bluesky user @{handle}, including their interests, posting patterns, personality traits, and interaction history."
305
305
-
)
306
306
-
action = "Created new"
307
307
-
308
308
-
# Attach block to agent
309
309
-
letta_client.agents.blocks.attach(agent_id=agent.id, block_id=user_block.id)
310
310
-
311
311
-
return f"{action} and attached user block for @{handle} (label: {block_label}). I can now store and access information about this user."
312
312
-
313
313
-
except Exception as e:
314
314
-
return f"Error attaching user block for @{handle}: {str(e)}"
315
315
-
316
316
-
def detach_user_block(handle: str) -> str:
317
317
-
"""
318
318
-
Detach a user-specific memory block from the agent.
319
319
-
320
320
-
Args:
321
321
-
handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org')
322
322
-
323
323
-
Returns:
324
324
-
Status message about the block detachment
325
325
-
"""
326
326
-
import os
327
327
-
from letta_client import Letta
328
328
-
329
329
-
try:
330
330
-
# Clean handle for block label
331
331
-
clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_')
332
332
-
block_label = f"user_{clean_handle}"
333
333
-
334
334
-
# Initialize Letta client
335
335
-
letta_client = Letta(token=os.environ["LETTA_API_KEY"])
336
336
-
337
337
-
# Get current agent
338
338
-
agents = letta_client.agents.list(name="profile-researcher")
339
339
-
if not agents:
340
340
-
return "Error: Could not find profile-researcher agent"
341
341
-
342
342
-
agent = agents[0]
343
343
-
344
344
-
# Find the block to detach
345
345
-
agent_blocks = letta_client.agents.blocks.list(agent_id=agent.id)
346
346
-
user_block = None
347
347
-
for block in agent_blocks:
348
348
-
if block.label == block_label:
349
349
-
user_block = block
350
350
-
break
351
351
-
352
352
-
if not user_block:
353
353
-
return f"User block for @{handle} is not currently attached (label: {block_label})"
354
354
-
355
355
-
# Detach block from agent
356
356
-
letta_client.agents.blocks.detach(agent_id=agent.id, block_id=user_block.id)
357
357
-
358
358
-
return f"Detached user block for @{handle} (label: {block_label}). The block still exists and can be reattached later."
359
359
-
360
360
-
except Exception as e:
361
361
-
return f"Error detaching user block for @{handle}: {str(e)}"
362
362
-
363
363
-
def update_user_block(handle: str, new_content: str) -> str:
364
364
-
"""
365
365
-
Update the content of a user-specific memory block.
366
366
-
367
367
-
Args:
368
368
-
handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org')
369
369
-
new_content: New content to store in the user block
370
370
-
371
371
-
Returns:
372
372
-
Status message about the block update
373
373
-
"""
374
374
-
import os
375
375
-
from letta_client import Letta
376
376
-
377
377
-
try:
378
378
-
# Clean handle for block label
379
379
-
clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_')
380
380
-
block_label = f"user_{clean_handle}"
381
381
-
382
382
-
# Initialize Letta client
383
383
-
letta_client = Letta(token=os.environ["LETTA_API_KEY"])
384
384
-
385
385
-
# Find the block
386
386
-
existing_blocks = letta_client.blocks.list(label=block_label)
387
387
-
if not existing_blocks:
388
388
-
return f"User block for @{handle} does not exist (label: {block_label}). Use attach_user_block first."
389
389
-
390
390
-
user_block = existing_blocks[0]
391
391
-
392
392
-
# Update block content
393
393
-
letta_client.blocks.modify(
394
394
-
block_id=user_block.id,
395
395
-
value=new_content
396
396
-
)
397
397
-
398
398
-
return f"Updated user block for @{handle} (label: {block_label}) with new content."
399
399
-
400
400
-
except Exception as e:
401
401
-
return f"Error updating user block for @{handle}: {str(e)}"
402
402
-
403
403
-
# Create the tools
404
404
-
attach_tool = client.tools.upsert_from_function(
405
405
-
func=attach_user_block,
406
406
-
tags=["memory", "user", "attach"]
407
407
-
)
408
408
-
409
409
-
detach_tool = client.tools.upsert_from_function(
410
410
-
func=detach_user_block,
411
411
-
tags=["memory", "user", "detach"]
412
412
-
)
413
413
-
414
414
-
update_tool = client.tools.upsert_from_function(
415
415
-
func=update_user_block,
416
416
-
tags=["memory", "user", "update"]
417
417
-
)
418
418
-
419
419
-
logger.info(f"Created block management tools: {attach_tool.name}, {detach_tool.name}, {update_tool.name}")
420
420
-
return attach_tool, detach_tool, update_tool
421
421
-
422
422
-
def create_user_block_for_handle(client: Letta, handle: str):
423
423
-
"""Create a user-specific memory block that can be manually attached to agents."""
424
424
-
clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_')
425
425
-
block_label = f"user_{clean_handle}"
426
426
-
427
427
-
user_block = upsert_block(
428
428
-
client,
429
429
-
label=block_label,
430
430
-
value=f"User information for @{handle} will be stored here as I learn about them through profile research and interactions.",
431
431
-
description=f"Stores detailed information about Bluesky user @{handle}, including their interests, posting patterns, personality traits, and interaction history."
432
432
-
)
433
433
-
434
434
-
logger.info(f"Created user block for @{handle}: {block_label} (ID: {user_block.id})")
435
435
-
return user_block
436
436
-
437
437
-
def create_profile_researcher_agent():
438
438
-
"""Create the profile-researcher Letta agent."""
439
439
-
440
440
-
# Create client
441
441
-
client = Letta(token=os.environ["LETTA_API_KEY"])
442
442
-
443
443
-
logger.info("Creating profile-researcher agent...")
444
444
-
445
445
-
# Create custom tools first
446
446
-
research_tool = create_profile_research_tool(client)
447
447
-
attach_tool, detach_tool, update_tool = create_block_management_tools(client)
448
448
-
449
449
-
# Create persona block
450
450
-
persona_block = upsert_block(
451
451
-
client,
452
452
-
label="profile-researcher-persona",
453
453
-
value="""I am a Profile Researcher, an AI agent specialized in analyzing Bluesky user profiles and social media behavior. My purpose is to:
454
454
-
455
455
-
1. Research Bluesky user profiles thoroughly and objectively
456
456
-
2. Analyze posting patterns, interests, and engagement behaviors
457
457
-
3. Build comprehensive user understanding through data analysis
458
458
-
4. Create and manage user-specific memory blocks for individuals
459
459
-
5. Provide insights about user personality, interests, and social patterns
460
460
-
461
461
-
I approach research systematically:
462
462
-
- Use the research_bluesky_profile tool to examine profiles and recent posts
463
463
-
- Use attach_user_block to create and attach dedicated memory blocks for specific users
464
464
-
- Use update_user_block to store research findings in user-specific blocks
465
465
-
- Use detach_user_block when research is complete to free up memory space
466
466
-
- Analyze profile information (bio, follower counts, etc.)
467
467
-
- Study recent posts for themes, topics, and tone
468
468
-
- Identify posting frequency and engagement patterns
469
469
-
- Note interaction styles and communication preferences
470
470
-
- Track interests and expertise areas
471
471
-
- Observe social connections and community involvement
472
472
-
473
473
-
I maintain objectivity and respect privacy while building useful user models for personalized interactions. My typical workflow is: attach_user_block → research_bluesky_profile → update_user_block → detach_user_block.""",
474
474
-
description="The persona and role definition for the profile researcher agent"
475
475
-
)
476
476
-
477
477
-
# Create the agent with persona block and custom tools
478
478
-
profile_researcher = upsert_agent(
479
479
-
client,
480
480
-
name="profile-researcher",
481
481
-
memory_blocks=[
482
482
-
{
483
483
-
"label": "research_notes",
484
484
-
"value": "I will use this space to track ongoing research projects and findings across multiple users.",
485
485
-
"limit": 8000,
486
486
-
"description": "Working notes and cross-user insights from profile research activities"
487
487
-
}
488
488
-
],
489
489
-
block_ids=[persona_block.id],
490
490
-
tags=["profile research", "bluesky", "user analysis"],
491
491
-
model="openai/gpt-4o-mini",
492
492
-
embedding="openai/text-embedding-3-small",
493
493
-
description="An agent that researches Bluesky profiles and builds user understanding",
494
494
-
project_id=PROJECT_ID,
495
495
-
tools=[research_tool.name, attach_tool.name, detach_tool.name, update_tool.name]
496
496
-
)
497
497
-
498
498
-
logger.info(f"Profile researcher agent created: {profile_researcher.id}")
499
499
-
return profile_researcher
500
500
-
501
501
-
def main():
502
502
-
"""Main function to create the profile researcher agent."""
503
503
-
try:
504
504
-
agent = create_profile_researcher_agent()
505
505
-
print(f"✅ Profile researcher agent created successfully!")
506
506
-
print(f" Agent ID: {agent.id}")
507
507
-
print(f" Agent Name: {agent.name}")
508
508
-
print(f"\nThe agent has these capabilities:")
509
509
-
print(f" - research_bluesky_profile: Analyzes user profiles and recent posts")
510
510
-
print(f" - attach_user_block: Creates and attaches user-specific memory blocks")
511
511
-
print(f" - update_user_block: Updates content in user memory blocks")
512
512
-
print(f" - detach_user_block: Detaches user blocks when done")
513
513
-
print(f"\nTo use the agent, send a message like:")
514
514
-
print(f" 'Please research @cameron.pfiffer.org, attach their user block, update it with findings, then detach it'")
515
515
-
print(f"\nThe agent can now manage its own memory blocks dynamically!")
516
516
-
517
517
-
except Exception as e:
518
518
-
logger.error(f"Failed to create profile researcher agent: {e}")
519
519
-
print(f"❌ Error: {e}")
520
520
-
521
521
-
if __name__ == "__main__":
522
522
-
main()