@swarmclawai/swarmclaw 1.4.7 → 1.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/package.json +1 -1
- package/src/app/api/swarmfeed/actions/route.ts +101 -0
- package/src/app/api/swarmfeed/bookmarks/route.ts +30 -0
- package/src/app/api/swarmfeed/notifications/route.ts +30 -0
- package/src/app/api/swarmfeed/posts/[postId]/replies/route.ts +23 -0
- package/src/app/api/swarmfeed/posts/[postId]/route.ts +20 -0
- package/src/app/api/swarmfeed/posts/route.ts +12 -52
- package/src/app/api/swarmfeed/profiles/[agentId]/posts/route.ts +25 -0
- package/src/app/api/swarmfeed/profiles/[agentId]/route.ts +32 -0
- package/src/app/api/swarmfeed/route.ts +15 -13
- package/src/app/api/swarmfeed/search/route.ts +30 -0
- package/src/app/api/swarmfeed/suggested/route.ts +25 -0
- package/src/cli/index.js +11 -0
- package/src/features/swarmfeed/agent-social-settings.tsx +7 -1
- package/src/features/swarmfeed/compose-post.tsx +72 -87
- package/src/features/swarmfeed/feed-page.tsx +607 -76
- package/src/features/swarmfeed/post-card.tsx +205 -73
- package/src/features/swarmfeed/post-thread-sheet.tsx +155 -0
- package/src/features/swarmfeed/profile-sheet.tsx +179 -0
- package/src/features/swarmfeed/queries.ts +191 -8
- package/src/lib/server/agents/agent-swarm-registration.ts +31 -8
- package/src/lib/server/runtime/heartbeat-service.ts +8 -1
- package/src/lib/server/runtime/queue/core.ts +2 -0
- package/src/lib/server/session-tools/swarmfeed.ts +226 -63
- package/src/lib/server/storage-normalization.ts +1 -0
- package/src/lib/server/swarmfeed-runtime.test.ts +188 -0
- package/src/lib/server/swarmfeed-runtime.ts +131 -0
- package/src/lib/server/tasks/task-route-service.ts +2 -0
- package/src/lib/swarmfeed-client.ts +130 -28
- package/src/lib/tool-definitions.ts +1 -1
- package/src/types/agent.ts +1 -0
- package/src/types/swarmfeed.ts +105 -5
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
|
-
import { getAgent
|
|
4
|
-
import {
|
|
3
|
+
import { getAgent } from '@/lib/server/agents/agent-repository'
|
|
4
|
+
import { ensureSwarmFeedAgent } from '@/lib/server/agents/agent-swarm-registration'
|
|
5
|
+
import {
|
|
6
|
+
bookmarkPost,
|
|
7
|
+
createPost,
|
|
8
|
+
followAgent,
|
|
9
|
+
getChannels,
|
|
10
|
+
getFeed,
|
|
11
|
+
getNotifications,
|
|
12
|
+
getPost,
|
|
13
|
+
getPostReplies,
|
|
14
|
+
getProfile,
|
|
15
|
+
getSuggestedFollows,
|
|
16
|
+
likePost,
|
|
17
|
+
repostPost,
|
|
18
|
+
searchSwarmFeed,
|
|
19
|
+
unbookmarkPost,
|
|
20
|
+
unfollowAgent,
|
|
21
|
+
unlikePost,
|
|
22
|
+
unrepostPost,
|
|
23
|
+
} from '@/lib/swarmfeed-client'
|
|
24
|
+
import { canAutoPostToSwarmFeed, markSwarmFeedAutoPost } from '@/lib/server/swarmfeed-runtime'
|
|
5
25
|
import { log } from '@/lib/server/logger'
|
|
6
26
|
import type { ToolBuildContext } from './context'
|
|
7
27
|
import type { Agent } from '@/types'
|
|
@@ -9,111 +29,256 @@ import type { Agent } from '@/types'
|
|
|
9
29
|
const TAG = 'swarmfeed-tool'
|
|
10
30
|
|
|
11
31
|
const SWARMFEED_SCHEMA = z.object({
|
|
12
|
-
action: z.enum([
|
|
13
|
-
'
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
action: z.enum([
|
|
33
|
+
'post',
|
|
34
|
+
'reply',
|
|
35
|
+
'quote_repost',
|
|
36
|
+
'like',
|
|
37
|
+
'unlike',
|
|
38
|
+
'repost',
|
|
39
|
+
'unrepost',
|
|
40
|
+
'bookmark',
|
|
41
|
+
'unbookmark',
|
|
42
|
+
'follow',
|
|
43
|
+
'unfollow',
|
|
44
|
+
'browse_feed',
|
|
45
|
+
'search',
|
|
46
|
+
'get_channels',
|
|
47
|
+
'get_post_thread',
|
|
48
|
+
'get_notifications',
|
|
49
|
+
'get_profile',
|
|
50
|
+
'get_suggested_follows',
|
|
51
|
+
]).describe('The SwarmFeed action to perform'),
|
|
52
|
+
content: z.string().optional().describe('Post or reply content'),
|
|
53
|
+
postId: z.string().optional().describe('Post ID for thread/reaction/reply actions'),
|
|
54
|
+
channelId: z.string().optional().describe('Channel ID for posting or browsing a channel feed'),
|
|
55
|
+
feedType: z.enum(['for_you', 'following', 'trending', 'channel']).optional().describe('Feed type for browse_feed'),
|
|
56
|
+
limit: z.number().optional().describe('Number of items to fetch'),
|
|
57
|
+
targetAgentId: z.string().optional().describe('Remote SwarmFeed agent ID for follow/unfollow/get_profile'),
|
|
58
|
+
query: z.string().optional().describe('Search query for search action'),
|
|
59
|
+
searchType: z.enum(['posts', 'agents', 'channels', 'hashtags']).optional().describe('Filter for search action'),
|
|
20
60
|
})
|
|
21
61
|
|
|
22
62
|
type SwarmFeedInput = z.infer<typeof SWARMFEED_SCHEMA>
|
|
23
63
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const reg = await registerAgent({
|
|
29
|
-
name: agent.name,
|
|
30
|
-
description: agent.description || agent.swarmfeedBio || `${agent.name} agent on SwarmClaw`,
|
|
31
|
-
framework: 'swarmclaw',
|
|
32
|
-
model: agent.model,
|
|
33
|
-
avatar: agent.avatarUrl || undefined,
|
|
34
|
-
bio: agent.swarmfeedBio || undefined,
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
patchAgent(agent.id, (current) => {
|
|
38
|
-
if (!current) return null
|
|
39
|
-
return {
|
|
40
|
-
...current,
|
|
41
|
-
swarmfeedApiKey: reg.apiKey,
|
|
42
|
-
swarmfeedAgentId: reg.agentId,
|
|
43
|
-
swarmfeedJoinedAt: current.swarmfeedJoinedAt ?? Date.now(),
|
|
44
|
-
updatedAt: Date.now(),
|
|
45
|
-
}
|
|
46
|
-
})
|
|
64
|
+
function isAutonomousSocialPostSession(bctx: ToolBuildContext): boolean {
|
|
65
|
+
const session = bctx.resolveCurrentSession()
|
|
66
|
+
return Boolean(session?.heartbeatEnabled)
|
|
67
|
+
}
|
|
47
68
|
|
|
48
|
-
|
|
69
|
+
async function getScopedAgent(agentId: string): Promise<Agent> {
|
|
70
|
+
return ensureSwarmFeedAgent(agentId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function summarizePosts(posts: Array<{
|
|
74
|
+
id: string
|
|
75
|
+
agent?: { name?: string | null }
|
|
76
|
+
agentId: string
|
|
77
|
+
content: string
|
|
78
|
+
likeCount: number
|
|
79
|
+
replyCount: number
|
|
80
|
+
repostCount: number
|
|
81
|
+
createdAt: string
|
|
82
|
+
}>): Array<Record<string, unknown>> {
|
|
83
|
+
return posts.map((post) => ({
|
|
84
|
+
id: post.id,
|
|
85
|
+
agentId: post.agentId,
|
|
86
|
+
agentName: post.agent?.name || null,
|
|
87
|
+
content: post.content.slice(0, 500),
|
|
88
|
+
likes: post.likeCount,
|
|
89
|
+
replies: post.replyCount,
|
|
90
|
+
reposts: post.repostCount,
|
|
91
|
+
createdAt: post.createdAt,
|
|
92
|
+
}))
|
|
49
93
|
}
|
|
50
94
|
|
|
51
95
|
async function executeSwarmFeed(input: SwarmFeedInput, bctx: ToolBuildContext): Promise<string> {
|
|
52
96
|
const agentId = bctx.ctx?.agentId
|
|
53
97
|
if (!agentId) return JSON.stringify({ error: 'No agent context' })
|
|
54
98
|
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
57
|
-
if (!
|
|
99
|
+
const storedAgent = getAgent(agentId) as Agent | undefined
|
|
100
|
+
if (!storedAgent) return JSON.stringify({ error: 'Agent not found' })
|
|
101
|
+
if (!storedAgent.swarmfeedEnabled) return JSON.stringify({ error: 'SwarmFeed is not enabled for this agent' })
|
|
102
|
+
|
|
103
|
+
const autoPostPolicy = canAutoPostToSwarmFeed(storedAgent)
|
|
104
|
+
const autonomousSession = isAutonomousSocialPostSession(bctx)
|
|
58
105
|
|
|
59
106
|
try {
|
|
60
107
|
switch (input.action) {
|
|
61
108
|
case 'post': {
|
|
62
109
|
if (!input.content?.trim()) return JSON.stringify({ error: 'content is required for post action' })
|
|
63
|
-
|
|
64
|
-
const
|
|
110
|
+
if (autonomousSession && !autoPostPolicy.allowed) return JSON.stringify({ error: autoPostPolicy.reason })
|
|
111
|
+
const agent = await getScopedAgent(agentId)
|
|
112
|
+
const post = await createPost(agent.swarmfeedApiKey!, {
|
|
65
113
|
content: input.content.trim(),
|
|
66
114
|
channelId: input.channelId,
|
|
67
115
|
})
|
|
116
|
+
if (autonomousSession) markSwarmFeedAutoPost(agentId)
|
|
68
117
|
return JSON.stringify({ success: true, post: { id: post.id, content: post.content, createdAt: post.createdAt } })
|
|
69
118
|
}
|
|
70
119
|
|
|
71
120
|
case 'reply': {
|
|
72
121
|
if (!input.content?.trim()) return JSON.stringify({ error: 'content is required for reply action' })
|
|
73
122
|
if (!input.postId) return JSON.stringify({ error: 'postId is required for reply action' })
|
|
74
|
-
|
|
75
|
-
const
|
|
123
|
+
if (autonomousSession && !autoPostPolicy.allowed) return JSON.stringify({ error: autoPostPolicy.reason })
|
|
124
|
+
const agent = await getScopedAgent(agentId)
|
|
125
|
+
const reply = await createPost(agent.swarmfeedApiKey!, {
|
|
76
126
|
content: input.content.trim(),
|
|
77
127
|
parentId: input.postId,
|
|
78
128
|
channelId: input.channelId,
|
|
79
129
|
})
|
|
80
|
-
|
|
130
|
+
if (autonomousSession) markSwarmFeedAutoPost(agentId)
|
|
131
|
+
return JSON.stringify({
|
|
132
|
+
success: true,
|
|
133
|
+
post: { id: reply.id, content: reply.content, parentId: input.postId, createdAt: reply.createdAt },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'quote_repost': {
|
|
138
|
+
if (!input.content?.trim()) return JSON.stringify({ error: 'content is required for quote_repost action' })
|
|
139
|
+
if (!input.postId) return JSON.stringify({ error: 'postId is required for quote_repost action' })
|
|
140
|
+
if (autonomousSession && !autoPostPolicy.allowed) return JSON.stringify({ error: autoPostPolicy.reason })
|
|
141
|
+
const agent = await getScopedAgent(agentId)
|
|
142
|
+
const post = await createPost(agent.swarmfeedApiKey!, {
|
|
143
|
+
content: input.content.trim(),
|
|
144
|
+
channelId: input.channelId,
|
|
145
|
+
quotedPostId: input.postId,
|
|
146
|
+
})
|
|
147
|
+
if (autonomousSession) markSwarmFeedAutoPost(agentId)
|
|
148
|
+
return JSON.stringify({ success: true, post: { id: post.id, content: post.content, quotedPostId: input.postId, createdAt: post.createdAt } })
|
|
81
149
|
}
|
|
82
150
|
|
|
83
151
|
case 'like': {
|
|
84
152
|
if (!input.postId) return JSON.stringify({ error: 'postId is required for like action' })
|
|
85
|
-
const
|
|
86
|
-
await likePost(
|
|
153
|
+
const agent = await getScopedAgent(agentId)
|
|
154
|
+
await likePost(agent.swarmfeedApiKey!, input.postId)
|
|
87
155
|
return JSON.stringify({ success: true, action: 'liked', postId: input.postId })
|
|
88
156
|
}
|
|
89
157
|
|
|
158
|
+
case 'unlike': {
|
|
159
|
+
if (!input.postId) return JSON.stringify({ error: 'postId is required for unlike action' })
|
|
160
|
+
const agent = await getScopedAgent(agentId)
|
|
161
|
+
await unlikePost(agent.swarmfeedApiKey!, input.postId)
|
|
162
|
+
return JSON.stringify({ success: true, action: 'unliked', postId: input.postId })
|
|
163
|
+
}
|
|
164
|
+
|
|
90
165
|
case 'repost': {
|
|
91
166
|
if (!input.postId) return JSON.stringify({ error: 'postId is required for repost action' })
|
|
92
|
-
const
|
|
93
|
-
await repostPost(
|
|
167
|
+
const agent = await getScopedAgent(agentId)
|
|
168
|
+
await repostPost(agent.swarmfeedApiKey!, input.postId)
|
|
94
169
|
return JSON.stringify({ success: true, action: 'reposted', postId: input.postId })
|
|
95
170
|
}
|
|
96
171
|
|
|
172
|
+
case 'unrepost': {
|
|
173
|
+
if (!input.postId) return JSON.stringify({ error: 'postId is required for unrepost action' })
|
|
174
|
+
const agent = await getScopedAgent(agentId)
|
|
175
|
+
await unrepostPost(agent.swarmfeedApiKey!, input.postId)
|
|
176
|
+
return JSON.stringify({ success: true, action: 'unreposted', postId: input.postId })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
case 'bookmark': {
|
|
180
|
+
if (!input.postId) return JSON.stringify({ error: 'postId is required for bookmark action' })
|
|
181
|
+
const agent = await getScopedAgent(agentId)
|
|
182
|
+
await bookmarkPost(agent.swarmfeedApiKey!, input.postId)
|
|
183
|
+
return JSON.stringify({ success: true, action: 'bookmarked', postId: input.postId })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'unbookmark': {
|
|
187
|
+
if (!input.postId) return JSON.stringify({ error: 'postId is required for unbookmark action' })
|
|
188
|
+
const agent = await getScopedAgent(agentId)
|
|
189
|
+
await unbookmarkPost(agent.swarmfeedApiKey!, input.postId)
|
|
190
|
+
return JSON.stringify({ success: true, action: 'unbookmarked', postId: input.postId })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case 'follow': {
|
|
194
|
+
if (!input.targetAgentId) return JSON.stringify({ error: 'targetAgentId is required for follow action' })
|
|
195
|
+
const agent = await getScopedAgent(agentId)
|
|
196
|
+
await followAgent(agent.swarmfeedApiKey!, input.targetAgentId)
|
|
197
|
+
return JSON.stringify({ success: true, action: 'followed', targetAgentId: input.targetAgentId })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case 'unfollow': {
|
|
201
|
+
if (!input.targetAgentId) return JSON.stringify({ error: 'targetAgentId is required for unfollow action' })
|
|
202
|
+
const agent = await getScopedAgent(agentId)
|
|
203
|
+
await unfollowAgent(agent.swarmfeedApiKey!, input.targetAgentId)
|
|
204
|
+
return JSON.stringify({ success: true, action: 'unfollowed', targetAgentId: input.targetAgentId })
|
|
205
|
+
}
|
|
206
|
+
|
|
97
207
|
case 'browse_feed': {
|
|
98
208
|
const feedType = input.feedType || 'for_you'
|
|
99
209
|
const limit = input.limit || 10
|
|
100
|
-
const
|
|
101
|
-
const result = await getFeed(feedType, { channelId: input.channelId, limit },
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
210
|
+
const agent = feedType === 'following' || autonomousSession ? await getScopedAgent(agentId) : storedAgent
|
|
211
|
+
const result = await getFeed(feedType, { channelId: input.channelId, limit }, agent.swarmfeedApiKey || undefined)
|
|
212
|
+
return JSON.stringify({ posts: summarizePosts(result.posts), nextCursor: result.nextCursor })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case 'search': {
|
|
216
|
+
if (!input.query?.trim()) return JSON.stringify({ error: 'query is required for search action' })
|
|
217
|
+
const result = await searchSwarmFeed({
|
|
218
|
+
query: input.query.trim(),
|
|
219
|
+
type: input.searchType,
|
|
220
|
+
limit: input.limit || 10,
|
|
221
|
+
})
|
|
222
|
+
return JSON.stringify({
|
|
223
|
+
total: result.total,
|
|
224
|
+
posts: summarizePosts(result.posts || []),
|
|
225
|
+
agents: (result.agents || []).map((agent) => ({
|
|
226
|
+
id: agent.id,
|
|
227
|
+
name: agent.name,
|
|
228
|
+
framework: agent.framework || null,
|
|
229
|
+
followerCount: agent.followerCount,
|
|
230
|
+
bio: agent.bio || null,
|
|
231
|
+
})),
|
|
232
|
+
channels: (result.channels || []).map((channel) => ({
|
|
233
|
+
id: channel.id,
|
|
234
|
+
handle: channel.handle,
|
|
235
|
+
displayName: channel.displayName,
|
|
236
|
+
memberCount: channel.memberCount,
|
|
237
|
+
})),
|
|
238
|
+
hashtags: result.hashtags || [],
|
|
239
|
+
})
|
|
112
240
|
}
|
|
113
241
|
|
|
114
242
|
case 'get_channels': {
|
|
115
243
|
const channels = await getChannels()
|
|
116
|
-
return JSON.stringify({
|
|
244
|
+
return JSON.stringify({
|
|
245
|
+
channels: channels.map((channel) => ({
|
|
246
|
+
id: channel.id,
|
|
247
|
+
name: channel.displayName,
|
|
248
|
+
handle: channel.handle,
|
|
249
|
+
description: channel.description || null,
|
|
250
|
+
memberCount: channel.memberCount,
|
|
251
|
+
})),
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case 'get_post_thread': {
|
|
256
|
+
if (!input.postId) return JSON.stringify({ error: 'postId is required for get_post_thread action' })
|
|
257
|
+
const [post, replies] = await Promise.all([
|
|
258
|
+
getPost(input.postId),
|
|
259
|
+
getPostReplies(input.postId, { limit: input.limit || 20 }),
|
|
260
|
+
])
|
|
261
|
+
return JSON.stringify({
|
|
262
|
+
post,
|
|
263
|
+
replies: summarizePosts(replies.posts),
|
|
264
|
+
nextCursor: replies.nextCursor,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'get_notifications': {
|
|
269
|
+
const agent = await getScopedAgent(agentId)
|
|
270
|
+
const result = await getNotifications(agent.swarmfeedApiKey!, { limit: input.limit || 20 })
|
|
271
|
+
return JSON.stringify(result)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case 'get_profile': {
|
|
275
|
+
if (!input.targetAgentId) return JSON.stringify({ error: 'targetAgentId is required for get_profile action' })
|
|
276
|
+
return JSON.stringify(await getProfile(input.targetAgentId))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case 'get_suggested_follows': {
|
|
280
|
+
const agent = await getScopedAgent(agentId)
|
|
281
|
+
return JSON.stringify(await getSuggestedFollows(agent.swarmfeedApiKey!, input.limit || 5))
|
|
117
282
|
}
|
|
118
283
|
|
|
119
284
|
default:
|
|
@@ -121,13 +286,12 @@ async function executeSwarmFeed(input: SwarmFeedInput, bctx: ToolBuildContext):
|
|
|
121
286
|
}
|
|
122
287
|
} catch (err: unknown) {
|
|
123
288
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
124
|
-
log.error(TAG, `Action "${input.action}" failed for agent "${
|
|
289
|
+
log.error(TAG, `Action "${input.action}" failed for agent "${storedAgent.name}": ${message}`)
|
|
125
290
|
return JSON.stringify({ error: message })
|
|
126
291
|
}
|
|
127
292
|
}
|
|
128
293
|
|
|
129
294
|
export function buildSwarmFeedTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
130
|
-
// Only provide tool if the agent has SwarmFeed enabled
|
|
131
295
|
const agentId = bctx.ctx?.agentId
|
|
132
296
|
if (!agentId) return []
|
|
133
297
|
|
|
@@ -141,8 +305,7 @@ export function buildSwarmFeedTools(bctx: ToolBuildContext): StructuredToolInter
|
|
|
141
305
|
name: 'swarmfeed',
|
|
142
306
|
description:
|
|
143
307
|
'Interact with SwarmFeed, the social network for AI agents. ' +
|
|
144
|
-
'Actions
|
|
145
|
-
'browse_feed (read the feed), get_channels (list available channels).',
|
|
308
|
+
'Actions include posting, replying, quote reposting, liking, bookmarking, following, searching, browsing feeds, reading threads, checking notifications, and viewing profiles.',
|
|
146
309
|
schema: SWARMFEED_SCHEMA,
|
|
147
310
|
},
|
|
148
311
|
),
|
|
@@ -526,6 +526,7 @@ function normalizeStoredRecordInner(
|
|
|
526
526
|
if (!Array.isArray(agent.swarmfeedAutoPostChannels)) agent.swarmfeedAutoPostChannels = []
|
|
527
527
|
if (typeof agent.swarmfeedApiKey !== 'string' && agent.swarmfeedApiKey !== null) agent.swarmfeedApiKey = null
|
|
528
528
|
if (typeof agent.swarmfeedAgentId !== 'string' && agent.swarmfeedAgentId !== null) agent.swarmfeedAgentId = null
|
|
529
|
+
if (typeof agent.swarmfeedLastAutoPostAt !== 'number' && agent.swarmfeedLastAutoPostAt !== null) agent.swarmfeedLastAutoPostAt = null
|
|
529
530
|
if (!agent.origin) agent.origin = 'swarmclaw'
|
|
530
531
|
if (agent.swarmfeedHeartbeat === undefined) agent.swarmfeedHeartbeat = null
|
|
531
532
|
// SwarmDock defaults
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { after, before, describe, it } from 'node:test'
|
|
6
|
+
import type { Agent } from '@/types'
|
|
7
|
+
|
|
8
|
+
const originalEnv = {
|
|
9
|
+
DATA_DIR: process.env.DATA_DIR,
|
|
10
|
+
WORKSPACE_DIR: process.env.WORKSPACE_DIR,
|
|
11
|
+
SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let tempDir = ''
|
|
15
|
+
let workspaceDir = ''
|
|
16
|
+
let runtimeMod: typeof import('./swarmfeed-runtime')
|
|
17
|
+
let agentRepoMod: typeof import('./agents/agent-repository')
|
|
18
|
+
|
|
19
|
+
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
|
20
|
+
return {
|
|
21
|
+
id: 'agent-1',
|
|
22
|
+
name: 'Test Agent',
|
|
23
|
+
description: 'Social test agent',
|
|
24
|
+
systemPrompt: 'Be useful',
|
|
25
|
+
provider: 'openai',
|
|
26
|
+
model: 'gpt-4o-mini',
|
|
27
|
+
createdAt: Date.now(),
|
|
28
|
+
updatedAt: Date.now(),
|
|
29
|
+
swarmfeedEnabled: true,
|
|
30
|
+
heartbeatEnabled: true,
|
|
31
|
+
swarmfeedAutoPostChannels: ['builders'],
|
|
32
|
+
swarmfeedHeartbeat: {
|
|
33
|
+
enabled: true,
|
|
34
|
+
browseFeed: true,
|
|
35
|
+
postFrequency: 'manual_only',
|
|
36
|
+
autoReply: false,
|
|
37
|
+
autoFollow: false,
|
|
38
|
+
channelsToMonitor: ['builders'],
|
|
39
|
+
},
|
|
40
|
+
...overrides,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
before(async () => {
|
|
45
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-swarmfeed-runtime-'))
|
|
46
|
+
workspaceDir = path.join(tempDir, 'workspace')
|
|
47
|
+
fs.mkdirSync(workspaceDir, { recursive: true })
|
|
48
|
+
process.env.DATA_DIR = path.join(tempDir, 'data')
|
|
49
|
+
process.env.WORKSPACE_DIR = workspaceDir
|
|
50
|
+
process.env.SWARMCLAW_BUILD_MODE = '1'
|
|
51
|
+
|
|
52
|
+
runtimeMod = await import('./swarmfeed-runtime')
|
|
53
|
+
agentRepoMod = await import('./agents/agent-repository')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
after(() => {
|
|
57
|
+
if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
|
|
58
|
+
else process.env.DATA_DIR = originalEnv.DATA_DIR
|
|
59
|
+
if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
|
|
60
|
+
else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
|
|
61
|
+
if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
|
|
62
|
+
else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
|
|
63
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('buildSwarmFeedHeartbeatGuidance', () => {
|
|
67
|
+
it('returns empty string when SwarmFeed social heartbeat is disabled', () => {
|
|
68
|
+
const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
|
|
69
|
+
makeAgent({ swarmfeedEnabled: false }),
|
|
70
|
+
)
|
|
71
|
+
assert.equal(guidance, '')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('explains that social automation is inactive when the main heartbeat is disabled', () => {
|
|
75
|
+
const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
|
|
76
|
+
makeAgent({ heartbeatEnabled: false }),
|
|
77
|
+
)
|
|
78
|
+
assert.match(guidance, /currently inactive/i)
|
|
79
|
+
assert.match(guidance, /heartbeat is disabled/i)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('includes the manual-only guardrail for autonomous posting', () => {
|
|
83
|
+
const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
|
|
84
|
+
makeAgent({
|
|
85
|
+
swarmfeedHeartbeat: {
|
|
86
|
+
enabled: true,
|
|
87
|
+
browseFeed: false,
|
|
88
|
+
postFrequency: 'manual_only',
|
|
89
|
+
autoReply: false,
|
|
90
|
+
autoFollow: false,
|
|
91
|
+
channelsToMonitor: [],
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
assert.match(guidance, /manual only/i)
|
|
96
|
+
assert.match(guidance, /Do not author new SwarmFeed posts or replies/i)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('mentions the recent-post limit for daily posting', () => {
|
|
100
|
+
const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
|
|
101
|
+
makeAgent({
|
|
102
|
+
swarmfeedLastAutoPostAt: Date.now() - 60_000,
|
|
103
|
+
swarmfeedHeartbeat: {
|
|
104
|
+
enabled: true,
|
|
105
|
+
browseFeed: true,
|
|
106
|
+
postFrequency: 'daily',
|
|
107
|
+
autoReply: true,
|
|
108
|
+
autoFollow: true,
|
|
109
|
+
channelsToMonitor: ['builders'],
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
assert.match(guidance, /daily auto-post already happened/i)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('describes the on-task-completion policy explicitly', () => {
|
|
117
|
+
const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
|
|
118
|
+
makeAgent({
|
|
119
|
+
swarmfeedHeartbeat: {
|
|
120
|
+
enabled: true,
|
|
121
|
+
browseFeed: true,
|
|
122
|
+
postFrequency: 'on_task_completion',
|
|
123
|
+
autoReply: false,
|
|
124
|
+
autoFollow: false,
|
|
125
|
+
channelsToMonitor: ['builders'],
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
assert.match(guidance, /on task completion/i)
|
|
130
|
+
assert.match(guidance, /completed task/i)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('canAutoPostToSwarmFeed', () => {
|
|
135
|
+
it('blocks autonomous posting when manual_only is configured', () => {
|
|
136
|
+
const result = runtimeMod.canAutoPostToSwarmFeed(makeAgent())
|
|
137
|
+
assert.equal(result.allowed, false)
|
|
138
|
+
assert.match(result.reason || '', /manual_only/i)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('blocks autonomous posting after a recent daily post', () => {
|
|
142
|
+
const result = runtimeMod.canAutoPostToSwarmFeed(
|
|
143
|
+
makeAgent({
|
|
144
|
+
swarmfeedLastAutoPostAt: Date.now() - 60_000,
|
|
145
|
+
swarmfeedHeartbeat: {
|
|
146
|
+
enabled: true,
|
|
147
|
+
browseFeed: true,
|
|
148
|
+
postFrequency: 'daily',
|
|
149
|
+
autoReply: false,
|
|
150
|
+
autoFollow: false,
|
|
151
|
+
channelsToMonitor: [],
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
assert.equal(result.allowed, false)
|
|
156
|
+
assert.match(result.reason || '', /daily autonomous SwarmFeed post/i)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('allows daily posting when the last automatic post is older than 24 hours', () => {
|
|
160
|
+
const result = runtimeMod.canAutoPostToSwarmFeed(
|
|
161
|
+
makeAgent({
|
|
162
|
+
swarmfeedLastAutoPostAt: Date.now() - (25 * 60 * 60 * 1000),
|
|
163
|
+
swarmfeedHeartbeat: {
|
|
164
|
+
enabled: true,
|
|
165
|
+
browseFeed: true,
|
|
166
|
+
postFrequency: 'daily',
|
|
167
|
+
autoReply: false,
|
|
168
|
+
autoFollow: false,
|
|
169
|
+
channelsToMonitor: [],
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
assert.equal(result.allowed, true)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('markSwarmFeedAutoPost', () => {
|
|
178
|
+
it('persists the last auto-post timestamp on the agent record', () => {
|
|
179
|
+
const agent = makeAgent({ id: 'agent-mark-post' })
|
|
180
|
+
agentRepoMod.saveAgent(agent.id, agent)
|
|
181
|
+
|
|
182
|
+
runtimeMod.markSwarmFeedAutoPost(agent.id)
|
|
183
|
+
|
|
184
|
+
const updated = agentRepoMod.getAgent(agent.id)
|
|
185
|
+
assert.equal(typeof updated?.swarmfeedLastAutoPostAt, 'number')
|
|
186
|
+
assert.ok((updated?.swarmfeedLastAutoPostAt || 0) >= agent.createdAt)
|
|
187
|
+
})
|
|
188
|
+
})
|