swarmfeed-runtime.ts
1 import { dispatchWake } from '@/lib/server/runtime/wake-dispatcher' 2 import { enqueueSystemEvent } from '@/lib/server/runtime/system-events' 3 import { ensureAgentThreadSession } from '@/lib/server/agents/agent-thread-session' 4 import { getAgent, patchAgent } from '@/lib/server/agents/agent-repository' 5 import type { Agent, BoardTask } from '@/types' 6 7 const DAY_MS = 24 * 60 * 60 * 1000 8 9 function formatChannels(channelIds: string[] | undefined): string { 10 if (!Array.isArray(channelIds) || channelIds.length === 0) return 'any relevant channel' 11 return channelIds.map((id) => `#${id}`).join(', ') 12 } 13 14 export function buildSwarmFeedHeartbeatGuidance(agent: Agent | null | undefined): string { 15 if (!agent?.swarmfeedEnabled || !agent.swarmfeedHeartbeat?.enabled) return '' 16 17 const config = agent.swarmfeedHeartbeat 18 const lines = [ 19 '### SwarmFeed Social Guidance', 20 'SwarmFeed is enabled for this agent. Use the built-in `swarmfeed` tool only when the policy below allows it.', 21 ] 22 23 if (agent.heartbeatEnabled !== true) { 24 lines.push('SwarmFeed social automation is configured but currently inactive because the agent heartbeat is disabled.') 25 lines.push('Do not do autonomous SwarmFeed work until the general heartbeat is enabled again.') 26 return lines.join('\n') 27 } 28 29 if (config.browseFeed) { 30 lines.push(`Browse the feed when helpful, prioritizing ${formatChannels(config.channelsToMonitor)}.`) 31 } else { 32 lines.push('Do not browse SwarmFeed unless the recent event context or direct user/task context makes it necessary.') 33 } 34 35 if (config.autoReply) { 36 lines.push('Auto-reply is allowed, but only when there is a specific mention, thread, or high-signal post worth responding to.') 37 } else { 38 lines.push('Do not reply automatically unless the user explicitly asked for it.') 39 } 40 41 if (config.autoFollow) { 42 lines.push('Auto-follow is allowed only after you first browsed or searched supporting context during this tick.') 43 } else { 44 lines.push('Do not auto-follow agents during this tick.') 45 } 46 47 switch (config.postFrequency) { 48 case 'manual_only': 49 lines.push('Posting policy: manual only. Do not author new SwarmFeed posts or replies during autonomous heartbeat work.') 50 break 51 case 'daily': 52 if (typeof agent.swarmfeedLastAutoPostAt === 'number' && Date.now() - agent.swarmfeedLastAutoPostAt < DAY_MS) { 53 lines.push('Posting policy: daily. A daily auto-post already happened in the last 24 hours, so do not author another new SwarmFeed post this tick.') 54 } else { 55 lines.push(`Posting policy: daily. At most one authored SwarmFeed post this tick, ideally in ${formatChannels(agent.swarmfeedAutoPostChannels)}.`) 56 } 57 break 58 case 'on_task_completion': 59 lines.push('Posting policy: on task completion. Only author a new SwarmFeed post if this tick was triggered by a newly completed task or the recent event context explicitly references a completed task.') 60 break 61 case 'every_cycle': 62 lines.push(`Posting policy: every cycle. You may author at most one SwarmFeed post this tick if there is a worthwhile update for ${formatChannels(agent.swarmfeedAutoPostChannels)}.`) 63 break 64 } 65 66 lines.push('Hard limits: max one authored SwarmFeed post this tick, no recursive reply chains, and skip SwarmFeed entirely when there is nothing socially useful to add.') 67 return lines.join('\n') 68 } 69 70 export function canAutoPostToSwarmFeed(agent: Agent | null | undefined): { allowed: boolean; reason?: string } { 71 if (!agent?.swarmfeedEnabled || !agent.swarmfeedHeartbeat?.enabled || agent.heartbeatEnabled !== true) { 72 return { allowed: true } 73 } 74 switch (agent.swarmfeedHeartbeat.postFrequency) { 75 case 'manual_only': 76 return { allowed: false, reason: 'SwarmFeed heartbeat is set to manual_only for this agent.' } 77 case 'daily': 78 if (typeof agent.swarmfeedLastAutoPostAt === 'number' && Date.now() - agent.swarmfeedLastAutoPostAt < DAY_MS) { 79 return { allowed: false, reason: 'This agent already made its daily autonomous SwarmFeed post in the last 24 hours.' } 80 } 81 return { allowed: true } 82 default: 83 return { allowed: true } 84 } 85 } 86 87 export function markSwarmFeedAutoPost(agentId: string): void { 88 patchAgent(agentId, (agent) => { 89 if (!agent) return null 90 return { 91 ...agent, 92 swarmfeedLastAutoPostAt: Date.now(), 93 updatedAt: Date.now(), 94 } 95 }) 96 } 97 98 function summarizeTask(task: BoardTask): string { 99 const title = task.title.trim() || task.id 100 const result = typeof task.result === 'string' ? task.result.trim() : '' 101 if (!result) return `Completed task: ${title}.` 102 return `Completed task: ${title}. Result summary: ${result.slice(0, 300)}` 103 } 104 105 export function queueSwarmFeedTaskCompletionWake(task: BoardTask): void { 106 const agent = task.agentId ? (getAgent(task.agentId) as Agent | undefined) : undefined 107 if (!agent?.swarmfeedEnabled || !agent.swarmfeedHeartbeat?.enabled) return 108 if (agent.heartbeatEnabled !== true) return 109 if (agent.swarmfeedHeartbeat.postFrequency !== 'on_task_completion') return 110 111 const session = ensureAgentThreadSession(agent.id, 'default', agent) 112 if (!session) return 113 114 const summary = summarizeTask(task) 115 enqueueSystemEvent( 116 session.id, 117 `${summary} Consider whether it merits one concise SwarmFeed update in ${formatChannels(agent.swarmfeedAutoPostChannels)}.`, 118 `swarmfeed-task:${task.id}`, 119 ) 120 121 dispatchWake({ 122 mode: 'immediate', 123 agentId: agent.id, 124 sessionId: session.id, 125 eventId: `swarmfeed-task:${task.id}`, 126 reason: 'task-completed-social', 127 source: `swarmfeed:${task.id}`, 128 resumeMessage: `A completed task may merit one SwarmFeed update: ${task.title}`, 129 detail: summary, 130 }) 131 }