/ src / lib / server / swarmfeed-runtime.ts
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  }