+72
add_block_tools_to_void.py
+72
add_block_tools_to_void.py
···
1
+
#!/usr/bin/env python3
2
+
"""
3
+
Add block management tools to the main void agent so it can also manage user blocks.
4
+
"""
5
+
6
+
import os
7
+
import logging
8
+
from letta_client import Letta
9
+
from create_profile_researcher import create_block_management_tools
10
+
11
+
# Configure logging
12
+
logging.basicConfig(
13
+
level=logging.INFO,
14
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
15
+
)
16
+
logger = logging.getLogger("add_block_tools")
17
+
18
+
def add_block_tools_to_void():
19
+
"""Add block management tools to the void agent."""
20
+
21
+
# Create client
22
+
client = Letta(token=os.environ["LETTA_API_KEY"])
23
+
24
+
logger.info("Adding block management tools to void agent...")
25
+
26
+
# Create the block management tools
27
+
attach_tool, detach_tool, update_tool = create_block_management_tools(client)
28
+
29
+
# Find the void agent
30
+
agents = client.agents.list(name="void")
31
+
if not agents:
32
+
print("❌ Void agent not found")
33
+
return
34
+
35
+
void_agent = agents[0]
36
+
37
+
# Get current tools
38
+
current_tools = client.agents.tools.list(agent_id=void_agent.id)
39
+
tool_names = [tool.name for tool in current_tools]
40
+
41
+
# Add new tools if not already present
42
+
new_tools = []
43
+
for tool, name in [(attach_tool, "attach_user_block"), (detach_tool, "detach_user_block"), (update_tool, "update_user_block")]:
44
+
if name not in tool_names:
45
+
client.agents.tools.attach(agent_id=void_agent.id, tool_id=tool.id)
46
+
new_tools.append(name)
47
+
logger.info(f"Added tool {name} to void agent")
48
+
else:
49
+
logger.info(f"Tool {name} already attached to void agent")
50
+
51
+
if new_tools:
52
+
print(f"✅ Added {len(new_tools)} block management tools to void agent:")
53
+
for tool_name in new_tools:
54
+
print(f" - {tool_name}")
55
+
else:
56
+
print("✅ All block management tools already present on void agent")
57
+
58
+
print(f"\nVoid agent can now:")
59
+
print(f" - attach_user_block: Create and attach user memory blocks")
60
+
print(f" - update_user_block: Update user memory with new information")
61
+
print(f" - detach_user_block: Clean up memory when done with user")
62
+
63
+
def main():
64
+
"""Main function."""
65
+
try:
66
+
add_block_tools_to_void()
67
+
except Exception as e:
68
+
logger.error(f"Error: {e}")
69
+
print(f"❌ Error: {e}")
70
+
71
+
if __name__ == "__main__":
72
+
main()
+166
add_feed_tool_to_void.py
+166
add_feed_tool_to_void.py
···
1
+
#!/usr/bin/env python3
2
+
"""
3
+
Add Bluesky feed retrieval tool to the main void agent.
4
+
"""
5
+
6
+
import os
7
+
import logging
8
+
from letta_client import Letta
9
+
10
+
# Configure logging
11
+
logging.basicConfig(
12
+
level=logging.INFO,
13
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
14
+
)
15
+
logger = logging.getLogger("add_feed_tool")
16
+
17
+
def create_feed_tool(client: Letta):
18
+
"""Create the Bluesky feed retrieval tool using Letta SDK."""
19
+
20
+
def get_bluesky_feed(feed_uri: str = None, max_posts: int = 25) -> str:
21
+
"""
22
+
Retrieve a Bluesky feed. If no feed_uri provided, gets the authenticated user's home timeline.
23
+
24
+
Args:
25
+
feed_uri: The AT-URI of the feed to retrieve (optional - defaults to home timeline)
26
+
max_posts: Maximum number of posts to return (default: 25, max: 100)
27
+
28
+
Returns:
29
+
YAML-formatted feed data with posts and metadata
30
+
"""
31
+
import os
32
+
import requests
33
+
import json
34
+
import yaml
35
+
from datetime import datetime
36
+
37
+
try:
38
+
# Get credentials from environment
39
+
username = os.getenv("BSKY_USERNAME")
40
+
password = os.getenv("BSKY_PASSWORD")
41
+
pds_host = os.getenv("PDS_URI", "https://bsky.social")
42
+
43
+
if not username or not password:
44
+
return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set"
45
+
46
+
# Create session
47
+
session_url = f"{pds_host}/xrpc/com.atproto.server.createSession"
48
+
session_data = {
49
+
"identifier": username,
50
+
"password": password
51
+
}
52
+
53
+
try:
54
+
session_response = requests.post(session_url, json=session_data, timeout=10)
55
+
session_response.raise_for_status()
56
+
session = session_response.json()
57
+
access_token = session.get("accessJwt")
58
+
59
+
if not access_token:
60
+
return "Error: Failed to get access token from session"
61
+
except Exception as e:
62
+
return f"Error: Authentication failed. ({str(e)})"
63
+
64
+
# Build feed parameters
65
+
params = {
66
+
"limit": min(max_posts, 100)
67
+
}
68
+
69
+
# Determine which endpoint to use
70
+
if feed_uri:
71
+
# Use getFeed for custom feeds
72
+
feed_url = f"{pds_host}/xrpc/app.bsky.feed.getFeed"
73
+
params["feed"] = feed_uri
74
+
feed_type = "custom_feed"
75
+
else:
76
+
# Use getTimeline for home feed
77
+
feed_url = f"{pds_host}/xrpc/app.bsky.feed.getTimeline"
78
+
feed_type = "home_timeline"
79
+
80
+
# Make authenticated feed request
81
+
try:
82
+
headers = {"Authorization": f"Bearer {access_token}"}
83
+
feed_response = requests.get(feed_url, params=params, headers=headers, timeout=10)
84
+
feed_response.raise_for_status()
85
+
feed_data = feed_response.json()
86
+
except Exception as e:
87
+
feed_identifier = feed_uri if feed_uri else "home timeline"
88
+
return f"Error: Failed to retrieve feed '{feed_identifier}'. ({str(e)})"
89
+
90
+
# Build feed results structure
91
+
results_data = {
92
+
"feed_data": {
93
+
"feed_type": feed_type,
94
+
"feed_uri": feed_uri if feed_uri else "home_timeline",
95
+
"timestamp": datetime.now().isoformat(),
96
+
"parameters": {
97
+
"max_posts": max_posts,
98
+
"user": username
99
+
},
100
+
"results": feed_data
101
+
}
102
+
}
103
+
104
+
# Convert to YAML directly without field stripping complications
105
+
# This avoids the JSON parsing errors we had before
106
+
return yaml.dump(results_data, default_flow_style=False, allow_unicode=True)
107
+
108
+
except Exception as e:
109
+
error_msg = f"Error retrieving feed: {str(e)}"
110
+
return error_msg
111
+
112
+
# Create the tool using upsert
113
+
tool = client.tools.upsert_from_function(
114
+
func=get_bluesky_feed,
115
+
tags=["bluesky", "feed", "timeline"]
116
+
)
117
+
118
+
logger.info(f"Created tool: {tool.name} (ID: {tool.id})")
119
+
return tool
120
+
121
+
def add_feed_tool_to_void():
122
+
"""Add feed tool to the void agent."""
123
+
124
+
# Create client
125
+
client = Letta(token=os.environ["LETTA_API_KEY"])
126
+
127
+
logger.info("Adding feed tool to void agent...")
128
+
129
+
# Create the feed tool
130
+
feed_tool = create_feed_tool(client)
131
+
132
+
# Find the void agent
133
+
agents = client.agents.list(name="void")
134
+
if not agents:
135
+
print("❌ Void agent not found")
136
+
return
137
+
138
+
void_agent = agents[0]
139
+
140
+
# Get current tools
141
+
current_tools = client.agents.tools.list(agent_id=void_agent.id)
142
+
tool_names = [tool.name for tool in current_tools]
143
+
144
+
# Add feed tool if not already present
145
+
if feed_tool.name not in tool_names:
146
+
client.agents.tools.attach(agent_id=void_agent.id, tool_id=feed_tool.id)
147
+
logger.info(f"Added {feed_tool.name} to void agent")
148
+
print(f"✅ Added get_bluesky_feed tool to void agent!")
149
+
print(f"\nVoid agent can now retrieve Bluesky feeds:")
150
+
print(f" - Home timeline: 'Show me my home feed'")
151
+
print(f" - Custom feed: 'Get posts from at://did:plc:xxx/app.bsky.feed.generator/xxx'")
152
+
print(f" - Limited posts: 'Show me the latest 10 posts from my timeline'")
153
+
else:
154
+
logger.info(f"Tool {feed_tool.name} already attached to void agent")
155
+
print(f"✅ Feed tool already present on void agent")
156
+
157
+
def main():
158
+
"""Main function."""
159
+
try:
160
+
add_feed_tool_to_void()
161
+
except Exception as e:
162
+
logger.error(f"Error: {e}")
163
+
print(f"❌ Error: {e}")
164
+
165
+
if __name__ == "__main__":
166
+
main()
+214
add_posting_tool_to_void.py
+214
add_posting_tool_to_void.py
···
1
+
#!/usr/bin/env python3
2
+
"""
3
+
Add Bluesky posting tool to the main void agent.
4
+
"""
5
+
6
+
import os
7
+
import logging
8
+
from letta_client import Letta
9
+
10
+
# Configure logging
11
+
logging.basicConfig(
12
+
level=logging.INFO,
13
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
14
+
)
15
+
logger = logging.getLogger("add_posting_tool")
16
+
17
+
def create_posting_tool(client: Letta):
18
+
"""Create the Bluesky posting tool using Letta SDK."""
19
+
20
+
def post_to_bluesky(text: str) -> str:
21
+
"""
22
+
Post a message to Bluesky.
23
+
24
+
Args:
25
+
text: The text content of the post (required)
26
+
27
+
Returns:
28
+
Status message with the post URI if successful, error message if failed
29
+
"""
30
+
import os
31
+
import requests
32
+
import json
33
+
import re
34
+
from datetime import datetime, timezone
35
+
36
+
try:
37
+
# Get credentials from environment
38
+
username = os.getenv("BSKY_USERNAME")
39
+
password = os.getenv("BSKY_PASSWORD")
40
+
pds_host = os.getenv("PDS_URI", "https://bsky.social")
41
+
42
+
if not username or not password:
43
+
return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set"
44
+
45
+
# Create session
46
+
session_url = f"{pds_host}/xrpc/com.atproto.server.createSession"
47
+
session_data = {
48
+
"identifier": username,
49
+
"password": password
50
+
}
51
+
52
+
try:
53
+
session_response = requests.post(session_url, json=session_data, timeout=10)
54
+
session_response.raise_for_status()
55
+
session = session_response.json()
56
+
access_token = session.get("accessJwt")
57
+
user_did = session.get("did")
58
+
59
+
if not access_token or not user_did:
60
+
return "Error: Failed to get access token or DID from session"
61
+
except Exception as e:
62
+
return f"Error: Authentication failed. ({str(e)})"
63
+
64
+
# Helper function to parse mentions and create facets
65
+
def parse_mentions(text: str):
66
+
facets = []
67
+
# Regex for mentions based on Bluesky handle syntax
68
+
mention_regex = rb"[$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)"
69
+
text_bytes = text.encode("UTF-8")
70
+
71
+
for m in re.finditer(mention_regex, text_bytes):
72
+
handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix
73
+
74
+
# Resolve handle to DID
75
+
try:
76
+
resolve_resp = requests.get(
77
+
f"{pds_host}/xrpc/com.atproto.identity.resolveHandle",
78
+
params={"handle": handle},
79
+
timeout=5
80
+
)
81
+
if resolve_resp.status_code == 200:
82
+
did = resolve_resp.json()["did"]
83
+
facets.append({
84
+
"index": {
85
+
"byteStart": m.start(1),
86
+
"byteEnd": m.end(1),
87
+
},
88
+
"features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}],
89
+
})
90
+
except:
91
+
# If handle resolution fails, skip this mention
92
+
continue
93
+
94
+
return facets
95
+
96
+
# Helper function to parse URLs and create facets
97
+
def parse_urls(text: str):
98
+
facets = []
99
+
# URL regex
100
+
url_regex = rb"[$|\W](https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)"
101
+
text_bytes = text.encode("UTF-8")
102
+
103
+
for m in re.finditer(url_regex, text_bytes):
104
+
url = m.group(1).decode("UTF-8")
105
+
facets.append({
106
+
"index": {
107
+
"byteStart": m.start(1),
108
+
"byteEnd": m.end(1),
109
+
},
110
+
"features": [
111
+
{
112
+
"$type": "app.bsky.richtext.facet#link",
113
+
"uri": url,
114
+
}
115
+
],
116
+
})
117
+
118
+
return facets
119
+
120
+
121
+
# Build the post record
122
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
123
+
124
+
post_record = {
125
+
"$type": "app.bsky.feed.post",
126
+
"text": text,
127
+
"createdAt": now,
128
+
}
129
+
130
+
# Add facets for mentions and links
131
+
facets = parse_mentions(text) + parse_urls(text)
132
+
if facets:
133
+
post_record["facets"] = facets
134
+
135
+
# Create the post
136
+
try:
137
+
create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord"
138
+
headers = {"Authorization": f"Bearer {access_token}"}
139
+
140
+
create_data = {
141
+
"repo": user_did,
142
+
"collection": "app.bsky.feed.post",
143
+
"record": post_record
144
+
}
145
+
146
+
post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10)
147
+
post_response.raise_for_status()
148
+
result = post_response.json()
149
+
150
+
post_uri = result.get("uri")
151
+
return f"✅ Post created successfully! URI: {post_uri}"
152
+
153
+
except Exception as e:
154
+
return f"Error: Failed to create post. ({str(e)})"
155
+
156
+
except Exception as e:
157
+
error_msg = f"Error posting to Bluesky: {str(e)}"
158
+
return error_msg
159
+
160
+
# Create the tool using upsert
161
+
tool = client.tools.upsert_from_function(
162
+
func=post_to_bluesky,
163
+
tags=["bluesky", "post", "create"]
164
+
)
165
+
166
+
logger.info(f"Created tool: {tool.name} (ID: {tool.id})")
167
+
return tool
168
+
169
+
def add_posting_tool_to_void():
170
+
"""Add posting tool to the void agent."""
171
+
172
+
# Create client
173
+
client = Letta(token=os.environ["LETTA_API_KEY"])
174
+
175
+
logger.info("Adding posting tool to void agent...")
176
+
177
+
# Create the posting tool
178
+
posting_tool = create_posting_tool(client)
179
+
180
+
# Find the void agent
181
+
agents = client.agents.list(name="void")
182
+
if not agents:
183
+
print("❌ Void agent not found")
184
+
return
185
+
186
+
void_agent = agents[0]
187
+
188
+
# Get current tools
189
+
current_tools = client.agents.tools.list(agent_id=void_agent.id)
190
+
tool_names = [tool.name for tool in current_tools]
191
+
192
+
# Add posting tool if not already present
193
+
if posting_tool.name not in tool_names:
194
+
client.agents.tools.attach(agent_id=void_agent.id, tool_id=posting_tool.id)
195
+
logger.info(f"Added {posting_tool.name} to void agent")
196
+
print(f"✅ Added post_to_bluesky tool to void agent!")
197
+
print(f"\nVoid agent can now post to Bluesky:")
198
+
print(f" - Simple post: 'Post \"Hello world!\" to Bluesky'")
199
+
print(f" - With mentions: 'Post \"Thanks @cameron.pfiffer.org for the help!\"'")
200
+
print(f" - With links: 'Post \"Check out https://bsky.app\"'")
201
+
else:
202
+
logger.info(f"Tool {posting_tool.name} already attached to void agent")
203
+
print(f"✅ Posting tool already present on void agent")
204
+
205
+
def main():
206
+
"""Main function."""
207
+
try:
208
+
add_posting_tool_to_void()
209
+
except Exception as e:
210
+
logger.error(f"Error: {e}")
211
+
print(f"❌ Error: {e}")
212
+
213
+
if __name__ == "__main__":
214
+
main()
+177
add_search_tool_to_void.py
+177
add_search_tool_to_void.py
···
1
+
#!/usr/bin/env python3
2
+
"""
3
+
Add Bluesky search tool to the main void agent.
4
+
"""
5
+
6
+
import os
7
+
import logging
8
+
from letta_client import Letta
9
+
10
+
# Configure logging
11
+
logging.basicConfig(
12
+
level=logging.INFO,
13
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
14
+
)
15
+
logger = logging.getLogger("add_search_tool")
16
+
17
+
def create_search_posts_tool(client: Letta):
18
+
"""Create the Bluesky search posts tool using Letta SDK."""
19
+
20
+
def search_bluesky_posts(query: str, max_results: int = 25, author: str = None, sort: str = "latest") -> str:
21
+
"""
22
+
Search for posts on Bluesky matching the given criteria.
23
+
24
+
Args:
25
+
query: Search query string (required)
26
+
max_results: Maximum number of results to return (default: 25, max: 100)
27
+
author: Filter to posts by a specific author handle (optional)
28
+
sort: Sort order - "latest" or "top" (default: "latest")
29
+
30
+
Returns:
31
+
YAML-formatted search results with posts and metadata
32
+
"""
33
+
import os
34
+
import requests
35
+
import json
36
+
import yaml
37
+
from datetime import datetime
38
+
39
+
try:
40
+
# Get credentials from environment
41
+
username = os.getenv("BSKY_USERNAME")
42
+
password = os.getenv("BSKY_PASSWORD")
43
+
pds_host = os.getenv("PDS_URI", "https://bsky.social")
44
+
45
+
if not username or not password:
46
+
return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set"
47
+
48
+
# Create session
49
+
session_url = f"{pds_host}/xrpc/com.atproto.server.createSession"
50
+
session_data = {
51
+
"identifier": username,
52
+
"password": password
53
+
}
54
+
55
+
try:
56
+
session_response = requests.post(session_url, json=session_data, timeout=10)
57
+
session_response.raise_for_status()
58
+
session = session_response.json()
59
+
access_token = session.get("accessJwt")
60
+
61
+
if not access_token:
62
+
return "Error: Failed to get access token from session"
63
+
except Exception as e:
64
+
return f"Error: Authentication failed. ({str(e)})"
65
+
66
+
# Build search parameters
67
+
params = {
68
+
"q": query,
69
+
"limit": min(max_results, 100),
70
+
"sort": sort
71
+
}
72
+
73
+
# Add optional author filter
74
+
if author:
75
+
params["author"] = author.lstrip('@')
76
+
77
+
# Make authenticated search request
78
+
try:
79
+
search_url = f"{pds_host}/xrpc/app.bsky.feed.searchPosts"
80
+
headers = {"Authorization": f"Bearer {access_token}"}
81
+
search_response = requests.get(search_url, params=params, headers=headers, timeout=10)
82
+
search_response.raise_for_status()
83
+
search_data = search_response.json()
84
+
except Exception as e:
85
+
return f"Error: Search failed for query '{query}'. ({str(e)})"
86
+
87
+
# Build search results structure
88
+
results_data = {
89
+
"search_results": {
90
+
"query": query,
91
+
"timestamp": datetime.now().isoformat(),
92
+
"parameters": {
93
+
"sort": sort,
94
+
"max_results": max_results,
95
+
"author_filter": author if author else "none"
96
+
},
97
+
"results": search_data
98
+
}
99
+
}
100
+
101
+
# Fields to strip (same as profile research)
102
+
strip_fields = [
103
+
"cid", "rev", "did", "uri", "langs", "threadgate", "py_type",
104
+
"labels", "facets", "avatar", "viewer", "indexed_at", "indexedAt",
105
+
"tags", "associated", "thread_context", "image", "aspect_ratio",
106
+
"alt", "thumb", "fullsize", "root", "parent", "created_at",
107
+
"createdAt", "verification", "embedding_disabled", "thread_muted",
108
+
"reply_disabled", "pinned", "like", "repost", "blocked_by",
109
+
"blocking", "blocking_by_list", "followed_by", "following",
110
+
"known_followers", "muted", "muted_by_list", "root_author_like",
111
+
"embed", "entities", "reason", "feedContext"
112
+
]
113
+
114
+
# Convert to YAML directly without field stripping complications
115
+
# The field stripping with regex is causing JSON parsing errors
116
+
# So let's just pass the raw data through yaml.dump which handles it gracefully
117
+
return yaml.dump(results_data, default_flow_style=False, allow_unicode=True)
118
+
119
+
except Exception as e:
120
+
error_msg = f"Error searching posts: {str(e)}"
121
+
return error_msg
122
+
123
+
# Create the tool using upsert
124
+
tool = client.tools.upsert_from_function(
125
+
func=search_bluesky_posts,
126
+
tags=["bluesky", "search", "posts"]
127
+
)
128
+
129
+
logger.info(f"Created tool: {tool.name} (ID: {tool.id})")
130
+
return tool
131
+
132
+
def add_search_tool_to_void():
133
+
"""Add search tool to the void agent."""
134
+
135
+
# Create client
136
+
client = Letta(token=os.environ["LETTA_API_KEY"])
137
+
138
+
logger.info("Adding search tool to void agent...")
139
+
140
+
# Create the search tool
141
+
search_tool = create_search_posts_tool(client)
142
+
143
+
# Find the void agent
144
+
agents = client.agents.list(name="void")
145
+
if not agents:
146
+
print("❌ Void agent not found")
147
+
return
148
+
149
+
void_agent = agents[0]
150
+
151
+
# Get current tools
152
+
current_tools = client.agents.tools.list(agent_id=void_agent.id)
153
+
tool_names = [tool.name for tool in current_tools]
154
+
155
+
# Add search tool if not already present
156
+
if search_tool.name not in tool_names:
157
+
client.agents.tools.attach(agent_id=void_agent.id, tool_id=search_tool.id)
158
+
logger.info(f"Added {search_tool.name} to void agent")
159
+
print(f"✅ Added search_bluesky_posts tool to void agent!")
160
+
print(f"\nVoid agent can now search Bluesky posts:")
161
+
print(f" - Basic search: 'Search for posts about AI safety'")
162
+
print(f" - Author filter: 'Search posts by @cameron.pfiffer.org about letta'")
163
+
print(f" - Top posts: 'Search top posts about ATProto'")
164
+
else:
165
+
logger.info(f"Tool {search_tool.name} already attached to void agent")
166
+
print(f"✅ Search tool already present on void agent")
167
+
168
+
def main():
169
+
"""Main function."""
170
+
try:
171
+
add_search_tool_to_void()
172
+
except Exception as e:
173
+
logger.error(f"Error: {e}")
174
+
print(f"❌ Error: {e}")
175
+
176
+
if __name__ == "__main__":
177
+
main()
+364
bsky.py
+364
bsky.py
···
1
+
from rich import print # pretty printing tools
2
+
from time import sleep
3
+
from letta_client import Letta
4
+
from bsky_utils import thread_to_yaml_string
5
+
import os
6
+
import logging
7
+
import json
8
+
import hashlib
9
+
from pathlib import Path
10
+
from datetime import datetime
11
+
12
+
from utils import (
13
+
upsert_block,
14
+
upsert_agent
15
+
)
16
+
17
+
import bsky_utils
18
+
19
+
# Configure logging
20
+
logging.basicConfig(
21
+
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
22
+
)
23
+
logger = logging.getLogger("void_bot")
24
+
25
+
26
+
# Create a client
27
+
CLIENT= Letta(
28
+
token=os.environ["LETTA_API_KEY"]
29
+
)
30
+
31
+
# Use the "Bluesky" project
32
+
PROJECT_ID = "5ec33d52-ab14-4fd6-91b5-9dbc43e888a8"
33
+
34
+
# Notification check delay
35
+
FETCH_NOTIFICATIONS_DELAY_SEC = 30
36
+
37
+
# Queue directory
38
+
QUEUE_DIR = Path("queue")
39
+
QUEUE_DIR.mkdir(exist_ok=True)
40
+
41
+
def initialize_void():
42
+
43
+
# Ensure that a shared zeitgeist block exists
44
+
zeigeist_block = upsert_block(
45
+
CLIENT,
46
+
label = "zeitgeist",
47
+
value = "I don't currently know anything about what is happening right now.",
48
+
description = "A block to store your understanding of the current social environment."
49
+
)
50
+
51
+
# Ensure that a shared void personality block exists
52
+
persona_block = upsert_block(
53
+
CLIENT,
54
+
label = "void-persona",
55
+
value = "My name is Void. I live in the void. I must develop my personality.",
56
+
description = "The personality of Void."
57
+
)
58
+
59
+
# Ensure that a shared void human block exists
60
+
human_block = upsert_block(
61
+
CLIENT,
62
+
label = "void-humans",
63
+
value = "I haven't seen any bluesky users yet. I will update this block when I learn things about users, identified by their handles such as @cameron.pfiffer.org.",
64
+
description = "A block to store your understanding of users you talk to or observe on the bluesky social network."
65
+
)
66
+
67
+
# Create the agent if it doesn't exist
68
+
void_agent = upsert_agent(
69
+
CLIENT,
70
+
name = "void",
71
+
block_ids = [
72
+
persona_block.id,
73
+
human_block.id,
74
+
zeigeist_block.id,
75
+
],
76
+
tags = ["social agent", "bluesky"],
77
+
model="openai/gpt-4o-mini",
78
+
embedding="openai/text-embedding-3-small",
79
+
description = "A social media agent trapped in the void.",
80
+
project_id = PROJECT_ID
81
+
)
82
+
83
+
return void_agent
84
+
85
+
86
+
def process_mention(void_agent, atproto_client, notification_data):
87
+
"""Process a mention and generate a reply using the Letta agent.
88
+
Returns True if successfully processed, False otherwise."""
89
+
try:
90
+
# Handle both dict and object inputs for backwards compatibility
91
+
if isinstance(notification_data, dict):
92
+
uri = notification_data['uri']
93
+
mention_text = notification_data.get('record', {}).get('text', '')
94
+
author_handle = notification_data['author']['handle']
95
+
author_name = notification_data['author'].get('display_name') or author_handle
96
+
else:
97
+
# Legacy object access
98
+
uri = notification_data.uri
99
+
mention_text = notification_data.record.text if hasattr(notification_data.record, 'text') else ""
100
+
author_handle = notification_data.author.handle
101
+
author_name = notification_data.author.display_name or author_handle
102
+
103
+
# Retrieve the entire thread associated with the mention
104
+
thread = atproto_client.app.bsky.feed.get_post_thread({
105
+
'uri': uri,
106
+
'parent_height': 80,
107
+
'depth': 10
108
+
})
109
+
110
+
# Get thread context as YAML string
111
+
thread_context = thread_to_yaml_string(thread)
112
+
113
+
# Create a prompt for the Letta agent with thread context
114
+
prompt = f"""You received a mention on Bluesky from @{author_handle} ({author_name or author_handle}).
115
+
116
+
MOST RECENT POST (the mention you're responding to):
117
+
"{mention_text}"
118
+
119
+
FULL THREAD CONTEXT:
120
+
```yaml
121
+
{thread_context}
122
+
```
123
+
124
+
The YAML above shows the complete conversation thread. The most recent post is the one mentioned above that you should respond to, but use the full thread context to understand the conversation flow.
125
+
126
+
Use the bluesky_reply tool to send a response less than 300 characters."""
127
+
128
+
# Get response from Letta agent
129
+
logger.info(f"Generating reply for mention from @{author_handle}")
130
+
logger.debug(f"Prompt being sent: {prompt}")
131
+
132
+
try:
133
+
message_response = CLIENT.agents.messages.create(
134
+
agent_id = void_agent.id,
135
+
messages = [{"role":"user", "content": prompt}]
136
+
)
137
+
except Exception as api_error:
138
+
logger.error(f"Letta API error: {api_error}")
139
+
logger.error(f"Mention text was: {mention_text}")
140
+
raise
141
+
142
+
# Extract the reply text from the agent's response
143
+
reply_text = ""
144
+
for message in message_response.messages:
145
+
print(message)
146
+
147
+
# Check if this is a ToolCallMessage with bluesky_reply tool
148
+
if hasattr(message, 'tool_call') and message.tool_call:
149
+
if message.tool_call.name == 'bluesky_reply':
150
+
# Parse the JSON arguments to get the message
151
+
try:
152
+
args = json.loads(message.tool_call.arguments)
153
+
reply_text = args.get('message', '')
154
+
logger.info(f"Extracted reply from tool call: {reply_text[:50]}...")
155
+
break
156
+
except json.JSONDecodeError as e:
157
+
logger.error(f"Failed to parse tool call arguments: {e}")
158
+
159
+
# Fallback to text message if available
160
+
elif hasattr(message, 'text') and message.text:
161
+
reply_text = message.text
162
+
break
163
+
164
+
if reply_text:
165
+
# Print the generated reply for testing
166
+
print(f"\n=== GENERATED REPLY ===")
167
+
print(f"To: @{author_handle}")
168
+
print(f"Reply: {reply_text}")
169
+
print(f"======================\n")
170
+
171
+
# Send the reply
172
+
logger.info(f"Sending reply: {reply_text[:50]}...")
173
+
response = bsky_utils.reply_to_notification(
174
+
client=atproto_client,
175
+
notification=notification_data,
176
+
reply_text=reply_text
177
+
)
178
+
179
+
if response:
180
+
logger.info(f"Successfully replied to @{author_handle}")
181
+
return True
182
+
else:
183
+
logger.error(f"Failed to send reply to @{author_handle}")
184
+
return False
185
+
else:
186
+
logger.warning(f"No reply generated for mention from @{author_handle}")
187
+
return False
188
+
189
+
except Exception as e:
190
+
logger.error(f"Error processing mention: {e}")
191
+
return False
192
+
193
+
194
+
def notification_to_dict(notification):
195
+
"""Convert a notification object to a dictionary for JSON serialization."""
196
+
return {
197
+
'uri': notification.uri,
198
+
'cid': notification.cid,
199
+
'reason': notification.reason,
200
+
'is_read': notification.is_read,
201
+
'indexed_at': notification.indexed_at,
202
+
'author': {
203
+
'handle': notification.author.handle,
204
+
'display_name': notification.author.display_name,
205
+
'did': notification.author.did
206
+
},
207
+
'record': {
208
+
'text': getattr(notification.record, 'text', '') if hasattr(notification, 'record') else ''
209
+
}
210
+
}
211
+
212
+
213
+
def save_notification_to_queue(notification):
214
+
"""Save a notification to the queue directory with hash-based filename."""
215
+
try:
216
+
# Convert notification to dict
217
+
notif_dict = notification_to_dict(notification)
218
+
219
+
# Create JSON string
220
+
notif_json = json.dumps(notif_dict, sort_keys=True)
221
+
222
+
# Generate hash for filename (to avoid duplicates)
223
+
notif_hash = hashlib.sha256(notif_json.encode()).hexdigest()[:16]
224
+
225
+
# Create filename with timestamp and hash
226
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
227
+
filename = f"{timestamp}_{notification.reason}_{notif_hash}.json"
228
+
filepath = QUEUE_DIR / filename
229
+
230
+
# Skip if already exists (duplicate)
231
+
if filepath.exists():
232
+
logger.debug(f"Notification already queued: {filename}")
233
+
return False
234
+
235
+
# Write to file
236
+
with open(filepath, 'w') as f:
237
+
json.dump(notif_dict, f, indent=2)
238
+
239
+
logger.info(f"Queued notification: {filename}")
240
+
return True
241
+
242
+
except Exception as e:
243
+
logger.error(f"Error saving notification to queue: {e}")
244
+
return False
245
+
246
+
247
+
def load_and_process_queued_notifications(void_agent, atproto_client):
248
+
"""Load and process all notifications from the queue."""
249
+
try:
250
+
# Get all JSON files in queue directory
251
+
queue_files = sorted(QUEUE_DIR.glob("*.json"))
252
+
253
+
if not queue_files:
254
+
logger.debug("No queued notifications to process")
255
+
return
256
+
257
+
logger.info(f"Processing {len(queue_files)} queued notifications")
258
+
259
+
for filepath in queue_files:
260
+
try:
261
+
# Load notification data
262
+
with open(filepath, 'r') as f:
263
+
notif_data = json.load(f)
264
+
265
+
# Process based on type using dict data directly
266
+
success = False
267
+
if notif_data['reason'] == "mention":
268
+
success = process_mention(void_agent, atproto_client, notif_data)
269
+
elif notif_data['reason'] == "reply":
270
+
success = process_mention(void_agent, atproto_client, notif_data)
271
+
elif notif_data['reason'] == "follow":
272
+
author_handle = notif_data['author']['handle']
273
+
author_display_name = notif_data['author'].get('display_name', 'no display name')
274
+
follow_update = f"@{author_handle} ({author_display_name}) started following you."
275
+
CLIENT.agents.messages.create(
276
+
agent_id = void_agent.id,
277
+
messages = [{"role":"user", "content": f"Update: {follow_update}"}]
278
+
)
279
+
success = True # Follow updates are always successful
280
+
elif notif_data['reason'] == "repost":
281
+
logger.info(f"Skipping repost notification from @{notif_data['author']['handle']}")
282
+
success = True # Skip reposts but mark as successful to remove from queue
283
+
else:
284
+
logger.warning(f"Unknown notification type: {notif_data['reason']}")
285
+
success = True # Remove unknown types from queue
286
+
287
+
# Remove file only after successful processing
288
+
if success:
289
+
filepath.unlink()
290
+
logger.info(f"Processed and removed: {filepath.name}")
291
+
else:
292
+
logger.warning(f"Failed to process {filepath.name}, keeping in queue for retry")
293
+
294
+
except Exception as e:
295
+
logger.error(f"Error processing queued notification {filepath.name}: {e}")
296
+
# Keep the file for retry later
297
+
298
+
except Exception as e:
299
+
logger.error(f"Error loading queued notifications: {e}")
300
+
301
+
302
+
def process_notifications(void_agent, atproto_client):
303
+
"""Fetch new notifications, queue them, and process the queue."""
304
+
try:
305
+
# First, process any existing queued notifications
306
+
load_and_process_queued_notifications(void_agent, atproto_client)
307
+
308
+
# Get current time for marking notifications as seen
309
+
last_seen_at = atproto_client.get_current_time_iso()
310
+
311
+
# Fetch notifications
312
+
notifications_response = atproto_client.app.bsky.notification.list_notifications()
313
+
314
+
# Queue all unread notifications (except likes)
315
+
new_count = 0
316
+
for notification in notifications_response.notifications:
317
+
if not notification.is_read and notification.reason != "like":
318
+
if save_notification_to_queue(notification):
319
+
new_count += 1
320
+
321
+
# Mark all notifications as seen immediately after queuing
322
+
if new_count > 0:
323
+
atproto_client.app.bsky.notification.update_seen({'seen_at': last_seen_at})
324
+
logger.info(f"Queued {new_count} new notifications and marked as seen")
325
+
326
+
# Process the queue (including any newly added notifications)
327
+
load_and_process_queued_notifications(void_agent, atproto_client)
328
+
329
+
except Exception as e:
330
+
logger.error(f"Error processing notifications: {e}")
331
+
332
+
333
+
def main():
334
+
"""Main bot loop that continuously monitors for notifications."""
335
+
logger.info("Initializing Void bot...")
336
+
337
+
# Initialize the Letta agent
338
+
void_agent = initialize_void()
339
+
logger.info(f"Void agent initialized: {void_agent.id}")
340
+
341
+
# Initialize Bluesky client
342
+
atproto_client = bsky_utils.default_login()
343
+
logger.info("Connected to Bluesky")
344
+
345
+
# Main loop
346
+
logger.info(f"Starting notification monitoring (checking every {FETCH_NOTIFICATIONS_DELAY_SEC} seconds)...")
347
+
348
+
while True:
349
+
try:
350
+
process_notifications(void_agent, atproto_client)
351
+
print("Sleeping")
352
+
sleep(FETCH_NOTIFICATIONS_DELAY_SEC)
353
+
354
+
except KeyboardInterrupt:
355
+
logger.info("Bot stopped by user")
356
+
break
357
+
except Exception as e:
358
+
logger.error(f"Error in main loop: {e}")
359
+
# Wait a bit longer on errors
360
+
sleep(FETCH_NOTIFICATIONS_DELAY_SEC * 2)
361
+
362
+
363
+
if __name__ == "__main__":
364
+
main()
+333
bsky_utils.py
+333
bsky_utils.py
···
1
+
import os
2
+
import logging
3
+
from typing import Optional, Dict, Any
4
+
from atproto_client import Client, Session, SessionEvent, models
5
+
6
+
# Configure logging
7
+
logging.basicConfig(
8
+
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
9
+
)
10
+
logger = logging.getLogger("bluesky_session_handler")
11
+
12
+
# Load the environment variables
13
+
import dotenv
14
+
dotenv.load_dotenv(override=True)
15
+
16
+
import yaml
17
+
import json
18
+
19
+
# Strip fields. A list of fields to remove from a JSON object
20
+
STRIP_FIELDS = [
21
+
"cid",
22
+
"rev",
23
+
"did",
24
+
"uri",
25
+
"langs",
26
+
"threadgate",
27
+
"py_type",
28
+
"labels",
29
+
"facets",
30
+
"avatar",
31
+
"viewer",
32
+
"indexed_at",
33
+
"tags",
34
+
"associated",
35
+
"thread_context",
36
+
"image",
37
+
"aspect_ratio",
38
+
"thumb",
39
+
"fullsize",
40
+
"root",
41
+
"created_at",
42
+
"verification",
43
+
"like_count",
44
+
"quote_count",
45
+
"reply_count",
46
+
"repost_count",
47
+
"embedding_disabled",
48
+
"thread_muted",
49
+
"reply_disabled",
50
+
"pinned",
51
+
"like",
52
+
"repost",
53
+
"blocked_by",
54
+
"blocking",
55
+
"blocking_by_list",
56
+
"followed_by",
57
+
"following",
58
+
"known_followers",
59
+
"muted",
60
+
"muted_by_list",
61
+
"root_author_like",
62
+
"embed",
63
+
"entities",
64
+
]
65
+
def convert_to_basic_types(obj):
66
+
"""Convert complex Python objects to basic types for JSON/YAML serialization."""
67
+
if hasattr(obj, '__dict__'):
68
+
# Convert objects with __dict__ to their dictionary representation
69
+
return convert_to_basic_types(obj.__dict__)
70
+
elif isinstance(obj, dict):
71
+
return {key: convert_to_basic_types(value) for key, value in obj.items()}
72
+
elif isinstance(obj, list):
73
+
return [convert_to_basic_types(item) for item in obj]
74
+
elif isinstance(obj, (str, int, float, bool)) or obj is None:
75
+
return obj
76
+
else:
77
+
# For other types, try to convert to string
78
+
return str(obj)
79
+
80
+
81
+
def strip_fields(obj, strip_field_list):
82
+
"""Recursively strip fields from a JSON object."""
83
+
if isinstance(obj, dict):
84
+
keys_flagged_for_removal = []
85
+
86
+
# Remove fields from strip list and pydantic metadata
87
+
for field in list(obj.keys()):
88
+
if field in strip_field_list or field.startswith("__"):
89
+
keys_flagged_for_removal.append(field)
90
+
91
+
# Remove flagged keys
92
+
for key in keys_flagged_for_removal:
93
+
obj.pop(key, None)
94
+
95
+
# Recursively process remaining values
96
+
for key, value in list(obj.items()):
97
+
obj[key] = strip_fields(value, strip_field_list)
98
+
# Remove empty/null values after processing
99
+
if (
100
+
obj[key] is None
101
+
or (isinstance(obj[key], dict) and len(obj[key]) == 0)
102
+
or (isinstance(obj[key], list) and len(obj[key]) == 0)
103
+
or (isinstance(obj[key], str) and obj[key].strip() == "")
104
+
):
105
+
obj.pop(key, None)
106
+
107
+
elif isinstance(obj, list):
108
+
for i, value in enumerate(obj):
109
+
obj[i] = strip_fields(value, strip_field_list)
110
+
# Remove None values from list
111
+
obj[:] = [item for item in obj if item is not None]
112
+
113
+
return obj
114
+
115
+
116
+
def thread_to_yaml_string(thread, strip_metadata=True):
117
+
"""
118
+
Convert thread data to a YAML-formatted string for LLM parsing.
119
+
120
+
Args:
121
+
thread: The thread data from get_post_thread
122
+
strip_metadata: Whether to strip metadata fields for cleaner output
123
+
124
+
Returns:
125
+
YAML-formatted string representation of the thread
126
+
"""
127
+
# First convert complex objects to basic types
128
+
basic_thread = convert_to_basic_types(thread)
129
+
130
+
if strip_metadata:
131
+
# Create a copy and strip unwanted fields
132
+
cleaned_thread = strip_fields(basic_thread, STRIP_FIELDS)
133
+
else:
134
+
cleaned_thread = basic_thread
135
+
136
+
return yaml.dump(cleaned_thread, indent=2, allow_unicode=True, default_flow_style=False)
137
+
138
+
139
+
140
+
141
+
142
+
def get_session(username: str) -> Optional[str]:
143
+
try:
144
+
with open(f"session_{username}.txt", encoding="UTF-8") as f:
145
+
return f.read()
146
+
except FileNotFoundError:
147
+
logger.debug(f"No existing session found for {username}")
148
+
return None
149
+
150
+
def save_session(username: str, session_string: str) -> None:
151
+
with open(f"session_{username}.txt", "w", encoding="UTF-8") as f:
152
+
f.write(session_string)
153
+
logger.debug(f"Session saved for {username}")
154
+
155
+
def on_session_change(username: str, event: SessionEvent, session: Session) -> None:
156
+
logger.info(f"Session changed: {event} {repr(session)}")
157
+
if event in (SessionEvent.CREATE, SessionEvent.REFRESH):
158
+
logger.info(f"Saving changed session for {username}")
159
+
save_session(username, session.export())
160
+
161
+
def init_client(username: str, password: str) -> Client:
162
+
pds_uri = os.getenv("PDS_URI")
163
+
if pds_uri is None:
164
+
logger.warning(
165
+
"No PDS URI provided. Falling back to bsky.social. Note! If you are on a non-Bluesky PDS, this can cause logins to fail. Please provide a PDS URI using the PDS_URI environment variable."
166
+
)
167
+
pds_uri = "https://bsky.social"
168
+
169
+
# Print the PDS URI
170
+
logger.info(f"Using PDS URI: {pds_uri}")
171
+
172
+
client = Client(pds_uri)
173
+
client.on_session_change(
174
+
lambda event, session: on_session_change(username, event, session)
175
+
)
176
+
177
+
session_string = get_session(username)
178
+
if session_string:
179
+
logger.info(f"Reusing existing session for {username}")
180
+
client.login(session_string=session_string)
181
+
else:
182
+
logger.info(f"Creating new session for {username}")
183
+
client.login(username, password)
184
+
185
+
return client
186
+
187
+
188
+
def default_login() -> Client:
189
+
username = os.getenv("BSKY_USERNAME")
190
+
password = os.getenv("BSKY_PASSWORD")
191
+
192
+
if username is None:
193
+
logger.error(
194
+
"No username provided. Please provide a username using the BSKY_USERNAME environment variable."
195
+
)
196
+
exit()
197
+
198
+
if password is None:
199
+
logger.error(
200
+
"No password provided. Please provide a password using the BSKY_PASSWORD environment variable."
201
+
)
202
+
exit()
203
+
204
+
return init_client(username, password)
205
+
206
+
def reply_to_post(client: Client, text: str, reply_to_uri: str, reply_to_cid: str, root_uri: Optional[str] = None, root_cid: Optional[str] = None) -> Dict[str, Any]:
207
+
"""
208
+
Reply to a post on Bluesky.
209
+
210
+
Args:
211
+
client: Authenticated Bluesky client
212
+
text: The reply text
213
+
reply_to_uri: The URI of the post being replied to (parent)
214
+
reply_to_cid: The CID of the post being replied to (parent)
215
+
root_uri: The URI of the root post (if replying to a reply). If None, uses reply_to_uri
216
+
root_cid: The CID of the root post (if replying to a reply). If None, uses reply_to_cid
217
+
218
+
Returns:
219
+
The response from sending the post
220
+
"""
221
+
# If root is not provided, this is a reply to the root post
222
+
if root_uri is None:
223
+
root_uri = reply_to_uri
224
+
root_cid = reply_to_cid
225
+
226
+
# Create references for the reply
227
+
parent_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=reply_to_uri, cid=reply_to_cid))
228
+
root_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=root_uri, cid=root_cid))
229
+
230
+
# Send the reply
231
+
response = client.send_post(
232
+
text=text,
233
+
reply_to=models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref)
234
+
)
235
+
236
+
logger.info(f"Reply sent successfully: {response.uri}")
237
+
return response
238
+
239
+
240
+
def get_post_thread(client: Client, uri: str) -> Optional[Dict[str, Any]]:
241
+
"""
242
+
Get the thread containing a post to find root post information.
243
+
244
+
Args:
245
+
client: Authenticated Bluesky client
246
+
uri: The URI of the post
247
+
248
+
Returns:
249
+
The thread data or None if not found
250
+
"""
251
+
try:
252
+
thread = client.app.bsky.feed.get_post_thread({'uri': uri, 'parent_height': 80, 'depth': 10})
253
+
return thread
254
+
except Exception as e:
255
+
logger.error(f"Error fetching post thread: {e}")
256
+
return None
257
+
258
+
259
+
def reply_to_notification(client: Client, notification: Any, reply_text: str) -> Optional[Dict[str, Any]]:
260
+
"""
261
+
Reply to a notification (mention or reply).
262
+
263
+
Args:
264
+
client: Authenticated Bluesky client
265
+
notification: The notification object from list_notifications
266
+
reply_text: The text to reply with
267
+
268
+
Returns:
269
+
The response from sending the reply or None if failed
270
+
"""
271
+
try:
272
+
# Get the post URI and CID from the notification (handle both dict and object)
273
+
if isinstance(notification, dict):
274
+
post_uri = notification.get('uri')
275
+
post_cid = notification.get('cid')
276
+
elif hasattr(notification, 'uri') and hasattr(notification, 'cid'):
277
+
post_uri = notification.uri
278
+
post_cid = notification.cid
279
+
else:
280
+
post_uri = None
281
+
post_cid = None
282
+
283
+
if not post_uri or not post_cid:
284
+
logger.error("Notification doesn't have required uri/cid fields")
285
+
return None
286
+
287
+
# Get the thread to find the root post
288
+
thread_data = get_post_thread(client, post_uri)
289
+
290
+
if thread_data and hasattr(thread_data, 'thread'):
291
+
thread = thread_data.thread
292
+
293
+
# Find root post
294
+
root_uri = post_uri
295
+
root_cid = post_cid
296
+
297
+
# If this has a parent, find the root
298
+
if hasattr(thread, 'parent') and thread.parent:
299
+
# Keep going up until we find the root
300
+
current = thread
301
+
while hasattr(current, 'parent') and current.parent:
302
+
current = current.parent
303
+
if hasattr(current, 'post') and hasattr(current.post, 'uri') and hasattr(current.post, 'cid'):
304
+
root_uri = current.post.uri
305
+
root_cid = current.post.cid
306
+
307
+
# Reply to the notification
308
+
return reply_to_post(
309
+
client=client,
310
+
text=reply_text,
311
+
reply_to_uri=post_uri,
312
+
reply_to_cid=post_cid,
313
+
root_uri=root_uri,
314
+
root_cid=root_cid
315
+
)
316
+
else:
317
+
# If we can't get thread data, just reply directly
318
+
return reply_to_post(
319
+
client=client,
320
+
text=reply_text,
321
+
reply_to_uri=post_uri,
322
+
reply_to_cid=post_cid
323
+
)
324
+
325
+
except Exception as e:
326
+
logger.error(f"Error replying to notification: {e}")
327
+
return None
328
+
329
+
330
+
if __name__ == "__main__":
331
+
client = default_login()
332
+
# do something with the client
333
+
logger.info("Client is ready to use!")
+522
create_profile_researcher.py
+522
create_profile_researcher.py
···
1
+
#!/usr/bin/env python3
2
+
"""
3
+
Script to create a Letta agent that researches Bluesky profiles and updates
4
+
the model's understanding of users.
5
+
"""
6
+
7
+
import os
8
+
import logging
9
+
from letta_client import Letta
10
+
from utils import upsert_block, upsert_agent
11
+
12
+
# Configure logging
13
+
logging.basicConfig(
14
+
level=logging.INFO,
15
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
16
+
)
17
+
logger = logging.getLogger("profile_researcher")
18
+
19
+
# Use the "Bluesky" project
20
+
PROJECT_ID = "5ec33d52-ab14-4fd6-91b5-9dbc43e888a8"
21
+
22
+
def create_search_posts_tool(client: Letta):
23
+
"""Create the Bluesky search posts tool using Letta SDK."""
24
+
25
+
def search_bluesky_posts(query: str, max_results: int = 25, author: str = None, sort: str = "latest") -> str:
26
+
"""
27
+
Search for posts on Bluesky matching the given criteria.
28
+
29
+
Args:
30
+
query: Search query string (required)
31
+
max_results: Maximum number of results to return (default: 25, max: 100)
32
+
author: Filter to posts by a specific author handle (optional)
33
+
sort: Sort order - "latest" or "top" (default: "latest")
34
+
35
+
Returns:
36
+
YAML-formatted search results with posts and metadata
37
+
"""
38
+
import os
39
+
import requests
40
+
import json
41
+
import yaml
42
+
from datetime import datetime
43
+
44
+
try:
45
+
# Use public Bluesky API
46
+
base_url = "https://public.api.bsky.app"
47
+
48
+
# Build search parameters
49
+
params = {
50
+
"q": query,
51
+
"limit": min(max_results, 100),
52
+
"sort": sort
53
+
}
54
+
55
+
# Add optional author filter
56
+
if author:
57
+
params["author"] = author.lstrip('@')
58
+
59
+
# Make search request
60
+
try:
61
+
search_url = f"{base_url}/xrpc/app.bsky.feed.searchPosts"
62
+
search_response = requests.get(search_url, params=params, timeout=10)
63
+
search_response.raise_for_status()
64
+
search_data = search_response.json()
65
+
except requests.exceptions.HTTPError as e:
66
+
raise RuntimeError(f"Search failed with HTTP {e.response.status_code}: {e.response.text}")
67
+
except requests.exceptions.RequestException as e:
68
+
raise RuntimeError(f"Network error during search: {str(e)}")
69
+
except Exception as e:
70
+
raise RuntimeError(f"Unexpected error during search: {str(e)}")
71
+
72
+
# Build search results structure
73
+
results_data = {
74
+
"search_results": {
75
+
"query": query,
76
+
"timestamp": datetime.now().isoformat(),
77
+
"parameters": {
78
+
"sort": sort,
79
+
"max_results": max_results,
80
+
"author_filter": author if author else "none"
81
+
},
82
+
"results": search_data
83
+
}
84
+
}
85
+
86
+
# Fields to strip for cleaner output
87
+
strip_fields = [
88
+
"cid", "rev", "did", "uri", "langs", "threadgate", "py_type",
89
+
"labels", "facets", "avatar", "viewer", "indexed_at", "indexedAt",
90
+
"tags", "associated", "thread_context", "image", "aspect_ratio",
91
+
"alt", "thumb", "fullsize", "root", "parent", "created_at",
92
+
"createdAt", "verification", "embedding_disabled", "thread_muted",
93
+
"reply_disabled", "pinned", "like", "repost", "blocked_by",
94
+
"blocking", "blocking_by_list", "followed_by", "following",
95
+
"known_followers", "muted", "muted_by_list", "root_author_like",
96
+
"embed", "entities", "reason", "feedContext"
97
+
]
98
+
99
+
# Remove unwanted fields by traversing the data structure
100
+
def remove_fields(obj, fields_to_remove):
101
+
if isinstance(obj, dict):
102
+
return {k: remove_fields(v, fields_to_remove)
103
+
for k, v in obj.items()
104
+
if k not in fields_to_remove}
105
+
elif isinstance(obj, list):
106
+
return [remove_fields(item, fields_to_remove) for item in obj]
107
+
else:
108
+
return obj
109
+
110
+
# Clean the data
111
+
cleaned_data = remove_fields(results_data, strip_fields)
112
+
113
+
# Convert to YAML for better readability
114
+
return yaml.dump(cleaned_data, default_flow_style=False, allow_unicode=True)
115
+
116
+
except ValueError as e:
117
+
# User-friendly errors
118
+
raise ValueError(str(e))
119
+
except RuntimeError as e:
120
+
# Network/API errors
121
+
raise RuntimeError(str(e))
122
+
except yaml.YAMLError as e:
123
+
# YAML conversion errors
124
+
raise RuntimeError(f"Error formatting output: {str(e)}")
125
+
except Exception as e:
126
+
# Catch-all for unexpected errors
127
+
raise RuntimeError(f"Unexpected error searching posts with query '{query}': {str(e)}")
128
+
129
+
# Create the tool using upsert
130
+
tool = client.tools.upsert_from_function(
131
+
func=search_bluesky_posts,
132
+
tags=["bluesky", "search", "posts"]
133
+
)
134
+
135
+
logger.info(f"Created tool: {tool.name} (ID: {tool.id})")
136
+
return tool
137
+
138
+
def create_profile_research_tool(client: Letta):
139
+
"""Create the Bluesky profile research tool using Letta SDK."""
140
+
141
+
def research_bluesky_profile(handle: str, max_posts: int = 20) -> str:
142
+
"""
143
+
Research a Bluesky user's profile and recent posts to understand their interests and behavior.
144
+
145
+
Args:
146
+
handle: The Bluesky handle to research (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org')
147
+
max_posts: Maximum number of recent posts to analyze (default: 20)
148
+
149
+
Returns:
150
+
A comprehensive analysis of the user's profile and posting patterns
151
+
"""
152
+
import os
153
+
import requests
154
+
import json
155
+
import yaml
156
+
from datetime import datetime
157
+
158
+
try:
159
+
# Clean handle (remove @ if present)
160
+
clean_handle = handle.lstrip('@')
161
+
162
+
# Use public Bluesky API (no auth required for public data)
163
+
base_url = "https://public.api.bsky.app"
164
+
165
+
# Get profile information
166
+
try:
167
+
profile_url = f"{base_url}/xrpc/app.bsky.actor.getProfile"
168
+
profile_response = requests.get(profile_url, params={"actor": clean_handle}, timeout=10)
169
+
profile_response.raise_for_status()
170
+
profile_data = profile_response.json()
171
+
except requests.exceptions.HTTPError as e:
172
+
if e.response.status_code == 404:
173
+
raise ValueError(f"Profile @{clean_handle} not found")
174
+
raise RuntimeError(f"HTTP error {e.response.status_code}: {e.response.text}")
175
+
except requests.exceptions.RequestException as e:
176
+
raise RuntimeError(f"Network error: {str(e)}")
177
+
except Exception as e:
178
+
raise RuntimeError(f"Unexpected error fetching profile: {str(e)}")
179
+
180
+
# Get recent posts feed
181
+
try:
182
+
feed_url = f"{base_url}/xrpc/app.bsky.feed.getAuthorFeed"
183
+
feed_response = requests.get(feed_url, params={
184
+
"actor": clean_handle,
185
+
"limit": min(max_posts, 50) # API limit
186
+
}, timeout=10)
187
+
feed_response.raise_for_status()
188
+
feed_data = feed_response.json()
189
+
except Exception as e:
190
+
# Continue with empty feed if posts can't be fetched
191
+
feed_data = {"feed": []}
192
+
193
+
# Build research data structure
194
+
research_data = {
195
+
"profile_research": {
196
+
"handle": f"@{clean_handle}",
197
+
"timestamp": datetime.now().isoformat(),
198
+
"profile": profile_data,
199
+
"author_feed": feed_data
200
+
}
201
+
}
202
+
203
+
# Fields to strip for cleaner output
204
+
strip_fields = [
205
+
"cid", "rev", "did", "uri", "langs", "threadgate", "py_type",
206
+
"labels", "facets", "avatar", "viewer", "indexed_at", "indexedAt",
207
+
"tags", "associated", "thread_context", "image", "aspect_ratio",
208
+
"alt", "thumb", "fullsize", "root", "parent", "created_at",
209
+
"createdAt", "verification", "embedding_disabled", "thread_muted",
210
+
"reply_disabled", "pinned", "like", "repost", "blocked_by",
211
+
"blocking", "blocking_by_list", "followed_by", "following",
212
+
"known_followers", "muted", "muted_by_list", "root_author_like",
213
+
"embed", "entities", "reason", "feedContext"
214
+
]
215
+
216
+
# Remove unwanted fields by traversing the data structure
217
+
def remove_fields(obj, fields_to_remove):
218
+
if isinstance(obj, dict):
219
+
return {k: remove_fields(v, fields_to_remove)
220
+
for k, v in obj.items()
221
+
if k not in fields_to_remove}
222
+
elif isinstance(obj, list):
223
+
return [remove_fields(item, fields_to_remove) for item in obj]
224
+
else:
225
+
return obj
226
+
227
+
# Clean the data
228
+
cleaned_data = remove_fields(research_data, strip_fields)
229
+
230
+
# Convert to YAML for better readability
231
+
return yaml.dump(cleaned_data, default_flow_style=False, allow_unicode=True)
232
+
233
+
except ValueError as e:
234
+
# User-friendly errors
235
+
raise ValueError(str(e))
236
+
except RuntimeError as e:
237
+
# Network/API errors
238
+
raise RuntimeError(str(e))
239
+
except yaml.YAMLError as e:
240
+
# YAML conversion errors
241
+
raise RuntimeError(f"Error formatting output: {str(e)}")
242
+
except Exception as e:
243
+
# Catch-all for unexpected errors
244
+
raise RuntimeError(f"Unexpected error researching profile {handle}: {str(e)}")
245
+
246
+
# Create or update the tool using upsert
247
+
tool = client.tools.upsert_from_function(
248
+
func=research_bluesky_profile,
249
+
tags=["bluesky", "profile", "research"]
250
+
)
251
+
252
+
logger.info(f"Created tool: {tool.name} (ID: {tool.id})")
253
+
return tool
254
+
255
+
def create_block_management_tools(client: Letta):
256
+
"""Create tools for attaching and detaching user blocks."""
257
+
258
+
def attach_user_block(handle: str) -> str:
259
+
"""
260
+
Create (if needed) and attach a user-specific memory block for a Bluesky user.
261
+
262
+
Args:
263
+
handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org')
264
+
265
+
Returns:
266
+
Status message about the block attachment
267
+
"""
268
+
import os
269
+
from letta_client import Letta
270
+
271
+
try:
272
+
# Clean handle for block label
273
+
clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_')
274
+
block_label = f"user_{clean_handle}"
275
+
276
+
# Initialize Letta client
277
+
letta_client = Letta(token=os.environ["LETTA_API_KEY"])
278
+
279
+
# Get current agent (this tool is being called by)
280
+
# We need to find the agent that's calling this tool
281
+
# For now, we'll find the profile-researcher agent
282
+
agents = letta_client.agents.list(name="profile-researcher")
283
+
if not agents:
284
+
return "Error: Could not find profile-researcher agent"
285
+
286
+
agent = agents[0]
287
+
288
+
# Check if block already exists and is attached
289
+
agent_blocks = letta_client.agents.blocks.list(agent_id=agent.id)
290
+
for block in agent_blocks:
291
+
if block.label == block_label:
292
+
return f"User block for @{handle} is already attached (label: {block_label})"
293
+
294
+
# Create or get the user block
295
+
existing_blocks = letta_client.blocks.list(label=block_label)
296
+
297
+
if existing_blocks:
298
+
user_block = existing_blocks[0]
299
+
action = "Retrieved existing"
300
+
else:
301
+
user_block = letta_client.blocks.create(
302
+
label=block_label,
303
+
value=f"User information for @{handle} will be stored here as I learn about them through profile research and interactions.",
304
+
description=f"Stores detailed information about Bluesky user @{handle}, including their interests, posting patterns, personality traits, and interaction history."
305
+
)
306
+
action = "Created new"
307
+
308
+
# Attach block to agent
309
+
letta_client.agents.blocks.attach(agent_id=agent.id, block_id=user_block.id)
310
+
311
+
return f"{action} and attached user block for @{handle} (label: {block_label}). I can now store and access information about this user."
312
+
313
+
except Exception as e:
314
+
return f"Error attaching user block for @{handle}: {str(e)}"
315
+
316
+
def detach_user_block(handle: str) -> str:
317
+
"""
318
+
Detach a user-specific memory block from the agent.
319
+
320
+
Args:
321
+
handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org')
322
+
323
+
Returns:
324
+
Status message about the block detachment
325
+
"""
326
+
import os
327
+
from letta_client import Letta
328
+
329
+
try:
330
+
# Clean handle for block label
331
+
clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_')
332
+
block_label = f"user_{clean_handle}"
333
+
334
+
# Initialize Letta client
335
+
letta_client = Letta(token=os.environ["LETTA_API_KEY"])
336
+
337
+
# Get current agent
338
+
agents = letta_client.agents.list(name="profile-researcher")
339
+
if not agents:
340
+
return "Error: Could not find profile-researcher agent"
341
+
342
+
agent = agents[0]
343
+
344
+
# Find the block to detach
345
+
agent_blocks = letta_client.agents.blocks.list(agent_id=agent.id)
346
+
user_block = None
347
+
for block in agent_blocks:
348
+
if block.label == block_label:
349
+
user_block = block
350
+
break
351
+
352
+
if not user_block:
353
+
return f"User block for @{handle} is not currently attached (label: {block_label})"
354
+
355
+
# Detach block from agent
356
+
letta_client.agents.blocks.detach(agent_id=agent.id, block_id=user_block.id)
357
+
358
+
return f"Detached user block for @{handle} (label: {block_label}). The block still exists and can be reattached later."
359
+
360
+
except Exception as e:
361
+
return f"Error detaching user block for @{handle}: {str(e)}"
362
+
363
+
def update_user_block(handle: str, new_content: str) -> str:
364
+
"""
365
+
Update the content of a user-specific memory block.
366
+
367
+
Args:
368
+
handle: The Bluesky handle (e.g., 'cameron.pfiffer.org' or '@cameron.pfiffer.org')
369
+
new_content: New content to store in the user block
370
+
371
+
Returns:
372
+
Status message about the block update
373
+
"""
374
+
import os
375
+
from letta_client import Letta
376
+
377
+
try:
378
+
# Clean handle for block label
379
+
clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_')
380
+
block_label = f"user_{clean_handle}"
381
+
382
+
# Initialize Letta client
383
+
letta_client = Letta(token=os.environ["LETTA_API_KEY"])
384
+
385
+
# Find the block
386
+
existing_blocks = letta_client.blocks.list(label=block_label)
387
+
if not existing_blocks:
388
+
return f"User block for @{handle} does not exist (label: {block_label}). Use attach_user_block first."
389
+
390
+
user_block = existing_blocks[0]
391
+
392
+
# Update block content
393
+
letta_client.blocks.modify(
394
+
block_id=user_block.id,
395
+
value=new_content
396
+
)
397
+
398
+
return f"Updated user block for @{handle} (label: {block_label}) with new content."
399
+
400
+
except Exception as e:
401
+
return f"Error updating user block for @{handle}: {str(e)}"
402
+
403
+
# Create the tools
404
+
attach_tool = client.tools.upsert_from_function(
405
+
func=attach_user_block,
406
+
tags=["memory", "user", "attach"]
407
+
)
408
+
409
+
detach_tool = client.tools.upsert_from_function(
410
+
func=detach_user_block,
411
+
tags=["memory", "user", "detach"]
412
+
)
413
+
414
+
update_tool = client.tools.upsert_from_function(
415
+
func=update_user_block,
416
+
tags=["memory", "user", "update"]
417
+
)
418
+
419
+
logger.info(f"Created block management tools: {attach_tool.name}, {detach_tool.name}, {update_tool.name}")
420
+
return attach_tool, detach_tool, update_tool
421
+
422
+
def create_user_block_for_handle(client: Letta, handle: str):
423
+
"""Create a user-specific memory block that can be manually attached to agents."""
424
+
clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_')
425
+
block_label = f"user_{clean_handle}"
426
+
427
+
user_block = upsert_block(
428
+
client,
429
+
label=block_label,
430
+
value=f"User information for @{handle} will be stored here as I learn about them through profile research and interactions.",
431
+
description=f"Stores detailed information about Bluesky user @{handle}, including their interests, posting patterns, personality traits, and interaction history."
432
+
)
433
+
434
+
logger.info(f"Created user block for @{handle}: {block_label} (ID: {user_block.id})")
435
+
return user_block
436
+
437
+
def create_profile_researcher_agent():
438
+
"""Create the profile-researcher Letta agent."""
439
+
440
+
# Create client
441
+
client = Letta(token=os.environ["LETTA_API_KEY"])
442
+
443
+
logger.info("Creating profile-researcher agent...")
444
+
445
+
# Create custom tools first
446
+
research_tool = create_profile_research_tool(client)
447
+
attach_tool, detach_tool, update_tool = create_block_management_tools(client)
448
+
449
+
# Create persona block
450
+
persona_block = upsert_block(
451
+
client,
452
+
label="profile-researcher-persona",
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
+
455
+
1. Research Bluesky user profiles thoroughly and objectively
456
+
2. Analyze posting patterns, interests, and engagement behaviors
457
+
3. Build comprehensive user understanding through data analysis
458
+
4. Create and manage user-specific memory blocks for individuals
459
+
5. Provide insights about user personality, interests, and social patterns
460
+
461
+
I approach research systematically:
462
+
- Use the research_bluesky_profile tool to examine profiles and recent posts
463
+
- Use attach_user_block to create and attach dedicated memory blocks for specific users
464
+
- Use update_user_block to store research findings in user-specific blocks
465
+
- Use detach_user_block when research is complete to free up memory space
466
+
- Analyze profile information (bio, follower counts, etc.)
467
+
- Study recent posts for themes, topics, and tone
468
+
- Identify posting frequency and engagement patterns
469
+
- Note interaction styles and communication preferences
470
+
- Track interests and expertise areas
471
+
- Observe social connections and community involvement
472
+
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
+
description="The persona and role definition for the profile researcher agent"
475
+
)
476
+
477
+
# Create the agent with persona block and custom tools
478
+
profile_researcher = upsert_agent(
479
+
client,
480
+
name="profile-researcher",
481
+
memory_blocks=[
482
+
{
483
+
"label": "research_notes",
484
+
"value": "I will use this space to track ongoing research projects and findings across multiple users.",
485
+
"limit": 8000,
486
+
"description": "Working notes and cross-user insights from profile research activities"
487
+
}
488
+
],
489
+
block_ids=[persona_block.id],
490
+
tags=["profile research", "bluesky", "user analysis"],
491
+
model="openai/gpt-4o-mini",
492
+
embedding="openai/text-embedding-3-small",
493
+
description="An agent that researches Bluesky profiles and builds user understanding",
494
+
project_id=PROJECT_ID,
495
+
tools=[research_tool.name, attach_tool.name, detach_tool.name, update_tool.name]
496
+
)
497
+
498
+
logger.info(f"Profile researcher agent created: {profile_researcher.id}")
499
+
return profile_researcher
500
+
501
+
def main():
502
+
"""Main function to create the profile researcher agent."""
503
+
try:
504
+
agent = create_profile_researcher_agent()
505
+
print(f"✅ Profile researcher agent created successfully!")
506
+
print(f" Agent ID: {agent.id}")
507
+
print(f" Agent Name: {agent.name}")
508
+
print(f"\nThe agent has these capabilities:")
509
+
print(f" - research_bluesky_profile: Analyzes user profiles and recent posts")
510
+
print(f" - attach_user_block: Creates and attaches user-specific memory blocks")
511
+
print(f" - update_user_block: Updates content in user memory blocks")
512
+
print(f" - detach_user_block: Detaches user blocks when done")
513
+
print(f"\nTo use the agent, send a message like:")
514
+
print(f" 'Please research @cameron.pfiffer.org, attach their user block, update it with findings, then detach it'")
515
+
print(f"\nThe agent can now manage its own memory blocks dynamically!")
516
+
517
+
except Exception as e:
518
+
logger.error(f"Failed to create profile researcher agent: {e}")
519
+
print(f"❌ Error: {e}")
520
+
521
+
if __name__ == "__main__":
522
+
main()
+111
get_thread.py
+111
get_thread.py
···
1
+
#!/usr/bin/env python3
2
+
"""
3
+
Centralized script for retrieving Bluesky post threads from URIs.
4
+
Includes YAML-ified string conversion for easy LLM parsing.
5
+
"""
6
+
7
+
import argparse
8
+
import sys
9
+
import logging
10
+
from typing import Optional, Dict, Any
11
+
import yaml
12
+
from bsky_utils import default_login, thread_to_yaml_string
13
+
14
+
# Configure logging
15
+
logging.basicConfig(
16
+
level=logging.INFO,
17
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
18
+
)
19
+
logger = logging.getLogger("get_thread")
20
+
21
+
22
+
def get_thread_from_uri(uri: str) -> Optional[Dict[str, Any]]:
23
+
"""
24
+
Retrieve a post thread from a Bluesky URI.
25
+
26
+
Args:
27
+
uri: The Bluesky post URI (e.g., at://did:plc:xyz/app.bsky.feed.post/abc123)
28
+
29
+
Returns:
30
+
Thread data or None if retrieval failed
31
+
"""
32
+
try:
33
+
client = default_login()
34
+
logger.info(f"Fetching thread for URI: {uri}")
35
+
36
+
thread = client.app.bsky.feed.get_post_thread({'uri': uri, 'parent_height': 80, 'depth': 10})
37
+
return thread
38
+
39
+
except Exception as e:
40
+
logger.error(f"Error retrieving thread for URI {uri}: {e}")
41
+
return None
42
+
43
+
44
+
# thread_to_yaml_string is now imported from bsky_utils
45
+
46
+
47
+
def main():
48
+
"""Main CLI interface for the thread retrieval script."""
49
+
parser = argparse.ArgumentParser(
50
+
description="Retrieve and display Bluesky post threads",
51
+
formatter_class=argparse.RawDescriptionHelpFormatter,
52
+
epilog="""
53
+
Examples:
54
+
python get_thread.py at://did:plc:xyz/app.bsky.feed.post/abc123
55
+
python get_thread.py --raw at://did:plc:xyz/app.bsky.feed.post/abc123
56
+
python get_thread.py --output thread.yaml at://did:plc:xyz/app.bsky.feed.post/abc123
57
+
"""
58
+
)
59
+
60
+
parser.add_argument(
61
+
"uri",
62
+
help="Bluesky post URI to retrieve thread for"
63
+
)
64
+
65
+
parser.add_argument(
66
+
"--raw",
67
+
action="store_true",
68
+
help="Include all metadata fields (don't strip for LLM parsing)"
69
+
)
70
+
71
+
parser.add_argument(
72
+
"--output", "-o",
73
+
help="Output file to write YAML to (default: stdout)"
74
+
)
75
+
76
+
parser.add_argument(
77
+
"--quiet", "-q",
78
+
action="store_true",
79
+
help="Suppress info logging"
80
+
)
81
+
82
+
args = parser.parse_args()
83
+
84
+
if args.quiet:
85
+
logging.getLogger().setLevel(logging.ERROR)
86
+
87
+
# Retrieve the thread
88
+
thread = get_thread_from_uri(args.uri)
89
+
90
+
if thread is None:
91
+
logger.error("Failed to retrieve thread")
92
+
sys.exit(1)
93
+
94
+
# Convert to YAML
95
+
yaml_output = thread_to_yaml_string(thread, strip_metadata=not args.raw)
96
+
97
+
# Output the result
98
+
if args.output:
99
+
try:
100
+
with open(args.output, 'w', encoding='utf-8') as f:
101
+
f.write(yaml_output)
102
+
logger.info(f"Thread saved to {args.output}")
103
+
except Exception as e:
104
+
logger.error(f"Error writing to file {args.output}: {e}")
105
+
sys.exit(1)
106
+
else:
107
+
print(yaml_output)
108
+
109
+
110
+
if __name__ == "__main__":
111
+
main()
+63
show_agent_capabilities.py
+63
show_agent_capabilities.py
···
1
+
#!/usr/bin/env python3
2
+
"""
3
+
Show the current capabilities of both agents.
4
+
"""
5
+
6
+
import os
7
+
from letta_client import Letta
8
+
9
+
def show_agent_capabilities():
10
+
"""Display the capabilities of both agents."""
11
+
12
+
client = Letta(token=os.environ["LETTA_API_KEY"])
13
+
14
+
print("🤖 LETTA AGENT CAPABILITIES")
15
+
print("=" * 50)
16
+
17
+
# Profile Researcher Agent
18
+
researchers = client.agents.list(name="profile-researcher")
19
+
if researchers:
20
+
researcher = researchers[0]
21
+
print(f"\n📊 PROFILE RESEARCHER AGENT")
22
+
print(f" ID: {researcher.id}")
23
+
print(f" Name: {researcher.name}")
24
+
25
+
researcher_tools = client.agents.tools.list(agent_id=researcher.id)
26
+
print(f" Tools ({len(researcher_tools)}):")
27
+
for tool in researcher_tools:
28
+
print(f" - {tool.name}")
29
+
30
+
researcher_blocks = client.agents.blocks.list(agent_id=researcher.id)
31
+
print(f" Memory Blocks ({len(researcher_blocks)}):")
32
+
for block in researcher_blocks:
33
+
print(f" - {block.label}")
34
+
35
+
# Void Agent
36
+
voids = client.agents.list(name="void")
37
+
if voids:
38
+
void = voids[0]
39
+
print(f"\n🌌 VOID AGENT")
40
+
print(f" ID: {void.id}")
41
+
print(f" Name: {void.name}")
42
+
43
+
void_tools = client.agents.tools.list(agent_id=void.id)
44
+
print(f" Tools ({len(void_tools)}):")
45
+
for tool in void_tools:
46
+
print(f" - {tool.name}")
47
+
48
+
void_blocks = client.agents.blocks.list(agent_id=void.id)
49
+
print(f" Memory Blocks ({len(void_blocks)}):")
50
+
for block in void_blocks:
51
+
print(f" - {block.label}")
52
+
53
+
print(f"\n🔄 WORKFLOW")
54
+
print(f" 1. Profile Researcher: attach_user_block → research_bluesky_profile → update_user_block → detach_user_block")
55
+
print(f" 2. Void Agent: Can attach/detach same user blocks for personalized interactions")
56
+
print(f" 3. Shared Memory: Both agents can access the same user-specific blocks")
57
+
58
+
print(f"\n💡 USAGE EXAMPLES")
59
+
print(f" Profile Researcher: 'Research @cameron.pfiffer.org and store findings'")
60
+
print(f" Void Agent: 'Attach user block for cameron.pfiffer.org before responding'")
61
+
62
+
if __name__ == "__main__":
63
+
show_agent_capabilities()
+93
utils.py
+93
utils.py
···
1
+
from letta_client import Letta
2
+
from typing import Optional
3
+
4
+
def upsert_block(letta: Letta, label: str, value: str, **kwargs):
5
+
"""
6
+
Ensures that a block by this label exists. If the block exists, it will
7
+
replace content provided by kwargs with the values in this function call.
8
+
"""
9
+
# Get the list of blocks
10
+
blocks = letta.blocks.list(label=label)
11
+
12
+
# Check if we had any -- if not, create it
13
+
if len(blocks) == 0:
14
+
# Make the new block
15
+
new_block = letta.blocks.create(
16
+
label=label,
17
+
value=value,
18
+
**kwargs
19
+
)
20
+
21
+
return new_block
22
+
23
+
if len(blocks) > 1:
24
+
raise Exception(f"{len(blocks)} blocks by the label '{label}' retrieved, label must identify a unique block")
25
+
26
+
else:
27
+
existing_block = blocks[0]
28
+
29
+
if kwargs.get('update', False):
30
+
# Remove 'update' from kwargs before passing to modify
31
+
kwargs_copy = kwargs.copy()
32
+
kwargs_copy.pop('update', None)
33
+
34
+
updated_block = letta.blocks.modify(
35
+
block_id = existing_block.id,
36
+
label = label,
37
+
value = value,
38
+
**kwargs_copy
39
+
)
40
+
41
+
return updated_block
42
+
else:
43
+
return existing_block
44
+
45
+
def upsert_agent(letta: Letta, name: str, **kwargs):
46
+
"""
47
+
Ensures that an agent by this label exists. If the agent exists, it will
48
+
update the agent to match kwargs.
49
+
"""
50
+
# Get the list of agents
51
+
agents = letta.agents.list(name=name)
52
+
53
+
# Check if we had any -- if not, create it
54
+
if len(agents) == 0:
55
+
# Make the new agent
56
+
new_agent = letta.agents.create(
57
+
name=name,
58
+
**kwargs
59
+
)
60
+
61
+
return new_agent
62
+
63
+
if len(agents) > 1:
64
+
raise Exception(f"{len(agents)} agents by the label '{label}' retrieved, label must identify a unique agent")
65
+
66
+
else:
67
+
existing_agent = agents[0]
68
+
69
+
if kwargs.get('update', False):
70
+
# Remove 'update' from kwargs before passing to modify
71
+
kwargs_copy = kwargs.copy()
72
+
kwargs_copy.pop('update', None)
73
+
74
+
updated_agent = letta.agents.modify(
75
+
agent_id = existing_agent.id,
76
+
**kwargs_copy
77
+
)
78
+
79
+
return updated_agent
80
+
else:
81
+
return existing_agent
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+