+1
-1
.env.example
+1
-1
.env.example
+5
-1
tasks/checkNotifications.ts
+5
-1
tasks/checkNotifications.ts
···
78
78
// marks all notifications that were processed as seen
79
79
// based on time from when retrieved instead of finished
80
80
await bsky.updateSeenNotifications(startedProcessingTime);
81
-
81
+
console.log(
82
+
`🔹 done processing ${unreadNotifications.length} notification${
83
+
unreadNotifications.length > 1 ? "s" : ""
84
+
}…`,
85
+
);
82
86
// increases counter for notification processing session
83
87
agentContext.processingCount++;
84
88
} else {
+111
-8
tools/bluesky/create_bluesky_post.py
+111
-8
tools/bluesky/create_bluesky_post.py
···
68
68
69
69
70
70
def _check_is_self(agent_did: str, target_did: str) -> bool:
71
-
"""Check 1: Self-Post Check (Free)."""
71
+
"""Check 2: Self-Post Check (Free)."""
72
72
return agent_did == target_did
73
73
74
74
···
126
126
raise
127
127
128
128
129
+
def _check_already_replied(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> None:
130
+
"""
131
+
Check 1: Duplicate Reply Prevention (Cheap - 1 API call).
132
+
133
+
Prevents agents from replying multiple times to the same message.
134
+
This check runs FIRST to block duplicates in ALL scenarios, including:
135
+
- Replies to the agent's own posts
136
+
- Replies to posts that mention the agent
137
+
- Any other reply scenario
138
+
139
+
Only checks direct replies, not deeper thread responses.
140
+
141
+
When duplicates are found, provides detailed information about existing
142
+
replies including URIs and content to help agents continue the conversation
143
+
appropriately.
144
+
145
+
Args:
146
+
client: Authenticated Bluesky client
147
+
agent_did: The agent's DID
148
+
agent_handle: The agent's handle (username)
149
+
reply_to_uri: URI of the post being replied to
150
+
151
+
Raises:
152
+
Exception: If agent has already replied directly to this message,
153
+
with details about the existing reply(ies)
154
+
"""
155
+
try:
156
+
# Fetch post with only direct replies (depth=1)
157
+
response = client.app.bsky.feed.get_post_thread({
158
+
'uri': reply_to_uri,
159
+
'depth': 1, # Only direct replies
160
+
'parentHeight': 0 # Don't fetch parents (not needed)
161
+
})
162
+
163
+
# Validate response structure
164
+
if not hasattr(response, 'thread'):
165
+
return # Can't verify, proceed
166
+
167
+
thread = response.thread
168
+
if not hasattr(thread, 'replies') or not thread.replies:
169
+
return # No replies yet, proceed
170
+
171
+
# Collect all replies by this agent
172
+
agent_replies = []
173
+
for reply in thread.replies:
174
+
# Validate reply structure
175
+
if not hasattr(reply, 'post'):
176
+
continue
177
+
if not hasattr(reply.post, 'author'):
178
+
continue
179
+
180
+
# Found agent's reply
181
+
if reply.post.author.did == agent_did:
182
+
agent_replies.append(reply)
183
+
184
+
# If no duplicates found, proceed
185
+
if not agent_replies:
186
+
return
187
+
188
+
# Get the most recent reply (last in list)
189
+
# Note: Agents may have multiple replies if this issue happened before
190
+
most_recent = agent_replies[-1]
191
+
reply_post = most_recent.post
192
+
reply_text = reply_post.record.text if hasattr(reply_post.record, 'text') else "[text unavailable]"
193
+
reply_uri = reply_post.uri
194
+
195
+
# Extract rkey from URI for web URL
196
+
# URI format: at://did:plc:xyz/app.bsky.feed.post/rkey
197
+
rkey = reply_uri.split('/')[-1]
198
+
reply_url = f"https://bsky.app/profile/{agent_handle}/post/{rkey}"
199
+
200
+
# Handle multiple replies case
201
+
count_msg = ""
202
+
if len(agent_replies) > 1:
203
+
count_msg = f"\n\nNote: You have {len(agent_replies)} direct replies to this message. The most recent one is shown above."
204
+
205
+
# Construct detailed error message
206
+
error_msg = (
207
+
f"Message not sent: You have already replied directly to this message.{count_msg}\n\n"
208
+
f"Your previous reply:\n\"{reply_text}\"\n\n"
209
+
f"Reply URI: {reply_uri}\n"
210
+
f"Web link: {reply_url}\n\n"
211
+
f"Suggestions:\n"
212
+
f"1. If you want to add more to your existing reply, use the URI above to continue that thread.\n"
213
+
f"2. Make sure you're not repeating yourself - check what you already said before adding more.\n"
214
+
f"3. Consider replying to one of the responses to your reply instead.\n"
215
+
f"4. If you have something new to say, start a new top-level message with additional context."
216
+
)
217
+
218
+
raise Exception(error_msg)
219
+
220
+
except Exception as e:
221
+
# If it's our duplicate reply exception, raise it
222
+
if "already replied" in str(e):
223
+
raise e
224
+
# For other errors, re-raise to be caught by main error handler
225
+
raise
226
+
227
+
129
228
def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool:
130
-
"""Check 3 & 4: Thread Participation and Mention Check (Expensive)."""
229
+
"""Check 5: Thread Participation and Mention Check (Expensive)."""
131
230
try:
132
231
# Fetch the thread
133
232
# depth=100 should be sufficient for most contexts, or we can walk up manually if needed.
···
193
292
Raises Exception with specific message if consent denied or verification fails.
194
293
"""
195
294
try:
196
-
# Check 1: Self-Post
295
+
# Check 1: Duplicate Reply Prevention
296
+
# This check must run BEFORE any early returns to prevent duplicates in all scenarios
297
+
_check_already_replied(client, agent_did, agent_handle, reply_to_uri)
298
+
299
+
# Check 2: Self-Post
197
300
if _check_is_self(agent_did, target_did):
198
301
return True
199
302
200
-
# Check 2: Mention Check (Free/Cheap)
303
+
# Check 3: Mention Check (Free/Cheap)
201
304
# If the post we are replying to mentions us, we can reply.
202
305
if parent_post_record:
203
306
# Check facets for mention
···
206
309
for feature in facet.features:
207
310
if hasattr(feature, 'did') and feature.did == agent_did:
208
311
return True
209
-
312
+
210
313
# Fallback: Check text for handle
211
314
if hasattr(parent_post_record, 'text') and f"@{agent_handle}" in parent_post_record.text:
212
315
return True
213
316
214
-
# Check 3: Follow Check
317
+
# Check 4: Follow Check
215
318
# Rule: Target must follow agent.
216
-
# Rule 3B: If root author is different from target, Root must ALSO follow agent.
319
+
# Rule 4B: If root author is different from target, Root must ALSO follow agent.
217
320
218
321
target_follows = _check_follows(client, agent_did, target_did)
219
322
···
229
332
)
230
333
return True
231
334
232
-
# Check 4: Thread Participation
335
+
# Check 5: Thread Participation
233
336
# This requires fetching the thread (Expensive)
234
337
if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri):
235
338
return True
+85
-14
utils/messageAgent.ts
+85
-14
utils/messageAgent.ts
···
1
1
import Letta from "@letta-ai/letta-client";
2
2
import { agentContext } from "./agentContext.ts";
3
3
// Helper function to format tool arguments as inline key-value pairs
4
-
const formatArgsInline = (args: unknown): string => {
4
+
const formatArgsInline = (args: unknown, maxValueLength = 30): string => {
5
5
try {
6
6
const parsed = typeof args === "string" ? JSON.parse(args) : args;
7
7
if (typeof parsed !== "object" || parsed === null) {
···
9
9
}
10
10
return Object.entries(parsed)
11
11
.map(([key, value]) => {
12
-
const valueStr = typeof value === "object"
12
+
let valueStr = typeof value === "object"
13
13
? JSON.stringify(value)
14
14
: String(value);
15
+
// Truncate long values
16
+
if (valueStr.length > maxValueLength) {
17
+
valueStr = valueStr.slice(0, maxValueLength) + "...";
18
+
}
15
19
return `${key}=${valueStr}`;
16
20
})
17
21
.join(", ");
···
28
32
return `${str.slice(0, maxLength)}... (truncated, ${str.length} total chars)`;
29
33
};
30
34
35
+
// Helper function to extract tool return value from wrapper structure
36
+
const extractToolReturn = (toolReturns: unknown): string => {
37
+
try {
38
+
// If it's already a string, return it
39
+
if (typeof toolReturns === "string") {
40
+
return toolReturns;
41
+
}
42
+
43
+
// If it's an array, extract the tool_return from first element
44
+
if (Array.isArray(toolReturns) && toolReturns.length > 0) {
45
+
const firstReturn = toolReturns[0];
46
+
if (
47
+
typeof firstReturn === "object" &&
48
+
firstReturn !== null &&
49
+
"tool_return" in firstReturn
50
+
) {
51
+
const toolReturn = firstReturn.tool_return;
52
+
// If tool_return is already a string, return it
53
+
if (typeof toolReturn === "string") {
54
+
return toolReturn;
55
+
}
56
+
// Otherwise stringify it
57
+
return JSON.stringify(toolReturn);
58
+
}
59
+
}
60
+
61
+
// Fallback: return stringified version of the whole thing
62
+
return JSON.stringify(toolReturns);
63
+
} catch {
64
+
return String(toolReturns);
65
+
}
66
+
};
67
+
68
+
// Helper function to format tool response for logging
69
+
const formatToolResponse = (returnValue: string): string => {
70
+
try {
71
+
// Try to parse as JSON - handle both JSON and Python dict syntax
72
+
let parsed;
73
+
try {
74
+
// First try standard JSON
75
+
parsed = JSON.parse(returnValue);
76
+
} catch {
77
+
// Try to parse Python-style dict (with single quotes and None/True/False)
78
+
const pythonToJson = returnValue
79
+
.replace(/'/g, '"')
80
+
.replace(/\bNone\b/g, "null")
81
+
.replace(/\bTrue\b/g, "true")
82
+
.replace(/\bFalse\b/g, "false");
83
+
parsed = JSON.parse(pythonToJson);
84
+
}
85
+
86
+
// If parsed successfully and it's an object, format as key=value pairs
87
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
88
+
return `(${formatArgsInline(parsed, 30)})`;
89
+
}
90
+
91
+
// If it's an array or primitive, return the original string
92
+
return returnValue;
93
+
} catch {
94
+
// If parsing fails, return as-is (it's a simple string)
95
+
return returnValue;
96
+
}
97
+
};
98
+
31
99
export const client = new Letta({
32
100
apiKey: Deno.env.get("LETTA_API_KEY"),
33
101
// @ts-ignore: Letta SDK type definition might be slightly off or expecting different casing
···
48
116
stream_tokens: true,
49
117
});
50
118
119
+
let lastToolName = "";
120
+
51
121
for await (const response of reachAgent) {
52
122
if (response.message_type === "reasoning_message") {
53
123
// console.log(`💭 reasoning…`);
54
124
} else if (response.message_type === "assistant_message") {
55
125
console.log(`💬 ${agentContext.agentBskyName}: ${response.content}`);
56
126
} else if (response.message_type === "tool_call_message") {
57
-
if (
58
-
Array.isArray(response.tool_calls) && response.tool_calls.length > 0
59
-
) {
60
-
const toolCall = response.tool_calls[0];
127
+
// Use tool_call (singular) or tool_calls (both are objects, not arrays)
128
+
const toolCall = response.tool_call || response.tool_calls;
129
+
if (toolCall && toolCall.name) {
130
+
lastToolName = toolCall.name;
61
131
const formattedArgs = formatArgsInline(toolCall.arguments);
62
-
console.log(
63
-
`🗜️ tool called: ${toolCall.name} with args: ${formattedArgs}`,
64
-
);
132
+
console.log(`🔧 tool called: ${toolCall.name} (${formattedArgs})`);
65
133
}
66
134
} else if (response.message_type === "tool_return_message") {
67
-
const toolReturn = response.tool_returns;
68
-
const returnStr = typeof toolReturn === "string"
69
-
? toolReturn
70
-
: JSON.stringify(toolReturn);
71
-
console.log(`🔧 tool response: ${truncateString(returnStr)}`);
135
+
const extractedReturn = extractToolReturn(response.tool_returns);
136
+
const formattedResponse = formatToolResponse(extractedReturn);
137
+
138
+
// Determine separator based on format
139
+
const separator = formattedResponse.startsWith("(") ? " " : ": ";
140
+
const logMessage = `↩️ tool response: ${lastToolName}${separator}${formattedResponse}`;
141
+
142
+
console.log(truncateString(logMessage, 80));
72
143
} else if (response.message_type === "usage_statistics") {
73
144
console.log(`🔢 total steps: ${response.step_count}`);
74
145
} else if (response.message_type === "hidden_reasoning_message") {
+2
-2
utils/processNotification.ts
+2
-2
utils/processNotification.ts
···
37
37
} as const;
38
38
39
39
export const processNotification = async (notification: Notification) => {
40
-
const agentProject = Deno.env.get("LETTA_PROJECT_NAME");
40
+
const agentName = agentContext.agentBskyName;
41
41
const kind = notification.reason;
42
42
const author = `@${notification.author.handle}`;
43
43
const handler = notificationHandlers[kind];
···
54
54
const prompt = await handler.promptFn(notification);
55
55
await messageAgent(prompt);
56
56
console.log(
57
-
`🔹 sent ${kind} notification from ${author} to ${agentProject}. moving on…`,
57
+
`🔹 sent ${kind} notification from ${author} to ${agentName}. moving on…`,
58
58
);
59
59
} catch (error) {
60
60
console.log(