mission-control/src/lib/agent-sync.ts

377 lines
11 KiB
TypeScript

/**
* Agent Config Sync
*
* Reads agents from openclaw.json and upserts them into the MC database.
* Used by both the /api/agents/sync endpoint and the startup scheduler.
*/
import { config } from './config'
import { getDatabase, db_helpers, logAuditEvent } from './db'
import { eventBus } from './event-bus'
import { join } from 'path'
import { existsSync, readFileSync } from 'fs'
import { resolveWithin } from './paths'
import { logger } from './logger'
interface OpenClawAgent {
id: string
name?: string
default?: boolean
workspace?: string
agentDir?: string
model?: {
primary?: string
fallbacks?: string[]
}
identity?: {
name?: string
theme?: string
emoji?: string
}
subagents?: any
sandbox?: {
mode?: string
workspaceAccess?: string
scope?: string
docker?: any
}
tools?: {
allow?: string[]
deny?: string[]
}
memorySearch?: any
}
export interface SyncResult {
synced: number
created: number
updated: number
agents: Array<{
id: string
name: string
action: 'created' | 'updated' | 'unchanged'
}>
error?: string
}
export interface SyncDiff {
inConfig: number
inMC: number
newAgents: string[]
updatedAgents: string[]
onlyInMC: string[]
}
function parseIdentityFromFile(content: string): { name?: string; theme?: string; emoji?: string; content?: string } {
if (!content.trim()) return {}
const lines = content.split('\n').map((line) => line.trim()).filter(Boolean)
let name: string | undefined
let theme: string | undefined
let emoji: string | undefined
for (const line of lines) {
if (!name && line.startsWith('#')) {
name = line.replace(/^#+\s*/, '').trim()
continue
}
if (!theme) {
const themeMatch = line.match(/^theme\s*:\s*(.+)$/i)
if (themeMatch?.[1]) {
theme = themeMatch[1].trim()
continue
}
}
if (!emoji) {
const emojiMatch = line.match(/^emoji\s*:\s*(.+)$/i)
if (emojiMatch?.[1]) {
emoji = emojiMatch[1].trim()
}
}
}
return {
...(name ? { name } : {}),
...(theme ? { theme } : {}),
...(emoji ? { emoji } : {}),
content: lines.slice(0, 8).join('\n'),
}
}
function parseToolsFromFile(content: string): { allow?: string[]; raw?: string } {
if (!content.trim()) return {}
const parsedTools = new Set<string>()
for (const line of content.split('\n')) {
const cleaned = line.trim()
if (!cleaned || cleaned.startsWith('#')) continue
const listMatch = cleaned.match(/^[-*]\s+`?([^`]+?)`?\s*$/)
if (listMatch?.[1]) {
parsedTools.add(listMatch[1].trim())
continue
}
const inlineMatch = cleaned.match(/^`([^`]+)`$/)
if (inlineMatch?.[1]) {
parsedTools.add(inlineMatch[1].trim())
}
}
const allow = [...parsedTools].filter(Boolean)
return {
...(allow.length > 0 ? { allow } : {}),
raw: content.split('\n').map((line) => line.trim()).filter(Boolean).slice(0, 24).join('\n'),
}
}
function getConfigPath(): string | null {
if (!config.openclawHome) return null
return join(config.openclawHome, 'openclaw.json')
}
/** Safely read a file from an agent's workspace directory */
function readWorkspaceFile(workspace: string | undefined, filename: string): string | null {
if (!workspace || !config.openclawHome) return null
try {
const safeWorkspace = resolveWithin(config.openclawHome, workspace)
const safePath = resolveWithin(safeWorkspace, filename)
if (existsSync(safePath)) {
return readFileSync(safePath, 'utf-8')
}
} catch (err) {
logger.warn({ err, workspace, filename }, 'Failed to read workspace file')
}
return null
}
export function enrichAgentConfigFromWorkspace(configData: any): any {
if (!configData || typeof configData !== 'object') return configData
const workspace = typeof configData.workspace === 'string' ? configData.workspace : undefined
if (!workspace) return configData
const identityFile = readWorkspaceFile(workspace, 'identity.md')
const toolsFile = readWorkspaceFile(workspace, 'TOOLS.md')
const mergedIdentity = {
...parseIdentityFromFile(identityFile || ''),
...((configData.identity && typeof configData.identity === 'object') ? configData.identity : {}),
}
const mergedTools = {
...parseToolsFromFile(toolsFile || ''),
...((configData.tools && typeof configData.tools === 'object') ? configData.tools : {}),
}
return {
...configData,
identity: Object.keys(mergedIdentity).length > 0 ? mergedIdentity : configData.identity,
tools: Object.keys(mergedTools).length > 0 ? mergedTools : configData.tools,
}
}
/** Read and parse openclaw.json agents list */
async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
const configPath = getConfigPath()
if (!configPath) throw new Error('OPENCLAW_HOME not configured')
const { readFile } = require('fs/promises')
const raw = await readFile(configPath, 'utf-8')
const parsed = JSON.parse(raw)
return parsed?.agents?.list || []
}
/** Extract MC-friendly fields from an OpenClaw agent config */
function mapAgentToMC(agent: OpenClawAgent): {
name: string
role: string
config: any
soul_content: string | null
} {
const name = agent.identity?.name || agent.name || agent.id
const role = agent.identity?.theme || 'agent'
// Store the full config minus systemPrompt/soul (which can be large)
const configData = enrichAgentConfigFromWorkspace({
openclawId: agent.id,
model: agent.model,
identity: agent.identity,
sandbox: agent.sandbox,
tools: agent.tools,
subagents: agent.subagents,
memorySearch: agent.memorySearch,
workspace: agent.workspace,
agentDir: agent.agentDir,
isDefault: agent.default || false,
})
// Read soul.md from the agent's workspace if available
const soul_content = readWorkspaceFile(agent.workspace, 'soul.md')
return { name, role, config: configData, soul_content }
}
/** Sync agents from openclaw.json into the MC database */
export async function syncAgentsFromConfig(actor: string = 'system'): Promise<SyncResult> {
let agents: OpenClawAgent[]
try {
agents = await readOpenClawAgents()
} catch (err: any) {
return { synced: 0, created: 0, updated: 0, agents: [], error: err.message }
}
if (agents.length === 0) {
return { synced: 0, created: 0, updated: 0, agents: [] }
}
const db = getDatabase()
const now = Math.floor(Date.now() / 1000)
let created = 0
let updated = 0
const results: SyncResult['agents'] = []
const findByName = db.prepare('SELECT id, name, role, config, soul_content FROM agents WHERE name = ?')
const insertAgent = db.prepare(`
INSERT INTO agents (name, role, soul_content, status, created_at, updated_at, config)
VALUES (?, ?, ?, 'offline', ?, ?, ?)
`)
const updateAgent = db.prepare(`
UPDATE agents SET role = ?, config = ?, soul_content = ?, updated_at = ? WHERE name = ?
`)
db.transaction(() => {
for (const agent of agents) {
const mapped = mapAgentToMC(agent)
const configJson = JSON.stringify(mapped.config)
const existing = findByName.get(mapped.name) as any
if (existing) {
// Check if config or soul_content actually changed
const existingConfig = existing.config || '{}'
const existingSoul = existing.soul_content || null
const configChanged = existingConfig !== configJson || existing.role !== mapped.role
const soulChanged = mapped.soul_content !== null && mapped.soul_content !== existingSoul
if (configChanged || soulChanged) {
// Only overwrite soul_content if we read a new value from workspace
const soulToWrite = mapped.soul_content ?? existingSoul
updateAgent.run(mapped.role, configJson, soulToWrite, now, mapped.name)
results.push({ id: agent.id, name: mapped.name, action: 'updated' })
updated++
} else {
results.push({ id: agent.id, name: mapped.name, action: 'unchanged' })
}
} else {
insertAgent.run(mapped.name, mapped.role, mapped.soul_content, now, now, configJson)
results.push({ id: agent.id, name: mapped.name, action: 'created' })
created++
}
}
})()
const synced = agents.length
// Log audit event
if (created > 0 || updated > 0) {
logAuditEvent({
action: 'agent_config_sync',
actor,
detail: { synced, created, updated, agents: results.filter(a => a.action !== 'unchanged').map(a => a.name) },
})
// Broadcast sync event
eventBus.broadcast('agent.created', { type: 'sync', synced, created, updated })
}
logger.info({ synced, created, updated }, 'Agent sync complete')
return { synced, created, updated, agents: results }
}
/** Preview the diff between openclaw.json and MC database without writing */
export async function previewSyncDiff(): Promise<SyncDiff> {
let agents: OpenClawAgent[]
try {
agents = await readOpenClawAgents()
} catch {
return { inConfig: 0, inMC: 0, newAgents: [], updatedAgents: [], onlyInMC: [] }
}
const db = getDatabase()
const allMCAgents = db.prepare('SELECT name, role, config FROM agents').all() as Array<{ name: string; role: string; config: string }>
const mcNames = new Set(allMCAgents.map(a => a.name))
const newAgents: string[] = []
const updatedAgents: string[] = []
const configNames = new Set<string>()
for (const agent of agents) {
const mapped = mapAgentToMC(agent)
configNames.add(mapped.name)
const existing = allMCAgents.find(a => a.name === mapped.name)
if (!existing) {
newAgents.push(mapped.name)
} else {
const configJson = JSON.stringify(mapped.config)
if (existing.config !== configJson || existing.role !== mapped.role) {
updatedAgents.push(mapped.name)
}
}
}
const onlyInMC = allMCAgents
.map(a => a.name)
.filter(name => !configNames.has(name))
return {
inConfig: agents.length,
inMC: allMCAgents.length,
newAgents,
updatedAgents,
onlyInMC,
}
}
/** Write an agent config back to openclaw.json agents.list */
export async function writeAgentToConfig(agentConfig: any): Promise<void> {
const configPath = getConfigPath()
if (!configPath) throw new Error('OPENCLAW_HOME not configured')
const { readFile, writeFile } = require('fs/promises')
const raw = await readFile(configPath, 'utf-8')
const parsed = JSON.parse(raw)
if (!parsed.agents) parsed.agents = {}
if (!parsed.agents.list) parsed.agents.list = []
// Find existing by id
const idx = parsed.agents.list.findIndex((a: any) => a.id === agentConfig.id)
if (idx >= 0) {
// Deep merge: preserve fields not in update
parsed.agents.list[idx] = deepMerge(parsed.agents.list[idx], agentConfig)
} else {
parsed.agents.list.push(agentConfig)
}
await writeFile(configPath, JSON.stringify(parsed, null, 2) + '\n')
}
/** Deep merge two objects (target <- source), preserving target fields not in source */
function deepMerge(target: any, source: any): any {
const result = { ...target }
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key]) &&
target[key] &&
typeof target[key] === 'object' &&
!Array.isArray(target[key])
) {
result[key] = deepMerge(target[key], source[key])
} else {
result[key] = source[key]
}
}
return result
}