forked from CodebuffAI/freebuff
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlocal-agent-registry.ts
More file actions
489 lines (424 loc) · 15.9 KB
/
Copy pathlocal-agent-registry.ts
File metadata and controls
489 lines (424 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
import fs from 'fs'
import os from 'os'
import path from 'path'
import { pluralize } from '@codebuff/common/util/string'
import {
loadLocalAgents as sdkLoadLocalAgents,
loadMCPConfigSync,
} from '@codebuff/sdk'
import type { MCPConfig } from '@codebuff/common/types/mcp'
import { getSelectedFreebuffModel } from '../state/freebuff-model-store'
import { getProjectRoot } from '../project-files'
import { IS_FREEBUFF, type AgentMode } from './constants'
import { getAgentIdForMode } from './freebuff-agent-selection'
import { logger } from './logger'
import * as bundledAgentsModule from '../agents/bundled-agents.generated'
import type { AgentDefinition } from '@codebuff/common/templates/initial-agents-dir/types/agent-definition'
// ============================================================================
// Constants and types
// ============================================================================
const AGENTS_DIR_NAME = '.agents'
export interface LocalAgentInfo {
id: string
displayName: string
filePath: string
/** True if this is a bundled Codebuff agent (not user-created) */
isBundled?: boolean
}
// ============================================================================
// User agents cache (loaded via SDK at startup)
// ============================================================================
let userAgentsCache: Record<string, AgentDefinition> = {}
// Map from agent ID to source file path (for UI "Open file" links)
let userAgentFilePaths: Map<string, string> = new Map()
// Cache for MCP servers loaded from mcp.json in .agents directories
let mcpServersCache: Record<string, MCPConfig> = {}
/**
* Initialize the agent registry by loading user agents via the SDK.
* This must be called at CLI startup before any sync agent loading functions.
*
* Agents are loaded from:
* - {cwd}/.agents (project)
* - {cwd}/../.agents (parent, e.g. monorepo root)
* - ~/.agents (global, user's home directory)
*
* Later directories take precedence, so project agents override global ones.
*/
export async function initializeAgentRegistry(): Promise<void> {
try {
// Let SDK load from all default directories (cwd, parent, home)
userAgentsCache = await sdkLoadLocalAgents({ verbose: false })
// Build ID-to-filepath map by scanning all agent directories
userAgentFilePaths = buildAgentFilePathMap(getDefaultAgentDirs())
} catch (error) {
// Fall back to empty cache if SDK loading fails, but log a warning
logger.warn(
{ error },
'Failed to load user agents from .agents directories',
)
userAgentsCache = {}
userAgentFilePaths = new Map()
}
// Load MCP config from mcp.json files in .agents directories
try {
const mcpConfig = loadMCPConfigSync({ verbose: false })
mcpServersCache = mcpConfig.mcpServers
if (Object.keys(mcpServersCache).length > 0) {
logger.debug(
{
mcpServers: Object.keys(mcpServersCache),
source: mcpConfig._sourceFilePath,
},
'[agents] Loaded MCP servers from mcp.json',
)
}
} catch (error) {
logger.warn({ error }, 'Failed to load MCP config from .agents directories')
mcpServersCache = {}
}
}
/**
* Reload the local agent registry for the current working directory.
*
* Called after the project changes at runtime (e.g. the project picker), where
* the CLI was started from an ancestor directory so .agents content was read
* from the old cwd at startup. Clears the derived caches before re-initializing
* so agent listings, the resolved .agents directory, and MCP servers from the
* newly selected project's mcp.json all reflect the new working directory.
*/
export async function reloadLocalAgentRegistry(): Promise<void> {
cachedAgentsByMode.clear()
cachedAgentsDir = null
await initializeAgentRegistry()
}
/**
* Get default agent directories to scan.
* Matches the SDK's getDefaultAgentDirs() to ensure consistency.
*/
const getDefaultAgentDirs = (): string[] => {
const cwdAgents = path.join(process.cwd(), AGENTS_DIR_NAME)
const parentAgents = path.join(process.cwd(), '..', AGENTS_DIR_NAME)
const homeAgents = path.join(os.homedir(), AGENTS_DIR_NAME)
return [cwdAgents, parentAgents, homeAgents]
}
/**
* Scan agent directories and build a map from agent ID to source file path.
* Uses regex to extract IDs from files without requiring module loading.
* Later directories in the list take precedence (can override earlier ones).
*/
const buildAgentFilePathMap = (agentsDirs: string[]): Map<string, string> => {
const idToPath = new Map<string, string>()
const idRegex = /id\s*:\s*['"`]([^'"`]+)['"`]/i
const scanDirectory = (dir: string): void => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory() && !entry.name.startsWith('.')) {
scanDirectory(fullPath)
continue
}
if (
!entry.isFile() ||
!entry.name.endsWith('.ts') ||
entry.name.endsWith('.d.ts') ||
entry.name.endsWith('.test.ts')
) {
continue
}
try {
const content = fs.readFileSync(fullPath, 'utf8')
const match = content.match(idRegex)
if (match?.[1]) {
idToPath.set(match[1], fullPath)
}
} catch {
// Skip files that can't be read
}
}
} catch {
// Skip directories that can't be read
}
}
// Scan all directories - later directories override earlier ones
for (const agentsDir of agentsDirs) {
scanDirectory(agentsDir)
}
return idToPath
}
/**
* Get user agents from the cache as LocalAgentInfo[]
*/
const getUserAgentsAsLocalInfo = (): LocalAgentInfo[] => {
return Object.values(userAgentsCache).map((def) => ({
id: def.id,
displayName: def.displayName || def.id,
filePath: userAgentFilePaths.get(def.id) || '',
}))
}
/**
* Get user agents from the cache as AgentDefinition[]
*/
const getUserAgentDefinitions = (): AgentDefinition[] => {
return Object.values(userAgentsCache) as AgentDefinition[]
}
// ============================================================================
// Bundled agents loading (generated at build time by prebuild-agents.ts)
// ============================================================================
const getBundledAgents = (): Record<string, AgentDefinition> => {
return bundledAgentsModule.bundledAgents ?? {}
}
const getBundledAgentsAsLocalInfo = (): LocalAgentInfo[] => {
return bundledAgentsModule.getBundledAgentsAsLocalInfo?.() ?? []
}
// ============================================================================
// Directory finding
// ============================================================================
let cachedAgentsDir: string | null = null
export const findAgentsDirectory = (): string | null => {
if (cachedAgentsDir && fs.existsSync(cachedAgentsDir)) {
return cachedAgentsDir
}
const projectRoot = getProjectRoot() || process.cwd()
if (projectRoot) {
const rootCandidate = path.join(projectRoot, AGENTS_DIR_NAME)
if (
fs.existsSync(rootCandidate) &&
fs.statSync(rootCandidate).isDirectory()
) {
cachedAgentsDir = rootCandidate
return cachedAgentsDir
}
}
let currentDir = process.cwd()
const filesystemRoot = path.parse(currentDir).root
while (true) {
const candidate = path.join(currentDir, AGENTS_DIR_NAME)
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
cachedAgentsDir = candidate
return cachedAgentsDir
}
if (currentDir === filesystemRoot) {
break
}
const parentDir = path.dirname(currentDir)
if (parentDir === currentDir) {
break
}
currentDir = parentDir
}
cachedAgentsDir = null
return null
}
// ============================================================================
// Agent loading - LocalAgentInfo (lightweight, for UI/listing)
// ============================================================================
// Cache keyed by agent mode (or 'all' for no filtering)
const cachedAgentsByMode: Map<string, LocalAgentInfo[]> = new Map()
/**
* Load local agents for display in the '@' menu.
*
* @param currentAgentMode - If provided, filters bundled agents to only include
* subagents of the current mode's agent (e.g., base2's spawnableAgents for DEFAULT mode).
* User's local agents from .agents/ are always included regardless of mode.
*/
export const loadLocalAgents = (
currentAgentMode?: AgentMode,
): LocalAgentInfo[] => {
const selectedFreebuffModel = IS_FREEBUFF ? getSelectedFreebuffModel() : null
const cacheKey = selectedFreebuffModel
? `${currentAgentMode ?? 'all'}:${selectedFreebuffModel}`
: (currentAgentMode ?? 'all')
const cached = cachedAgentsByMode.get(cacheKey)
if (cached) {
return cached
}
// Get bundled agents - these are the default Codebuff agents
// compiled into the CLI binary at build time
const bundledAgentsInfo = getBundledAgentsAsLocalInfo()
const bundledAgents = getBundledAgents()
// Filter bundled agents to only include subagents of the current mode's agent
let filteredBundledAgents: LocalAgentInfo[]
if (currentAgentMode) {
const currentAgentId = getAgentIdForMode(currentAgentMode)
const currentAgentDef = bundledAgents[currentAgentId]
? bundledAgents[currentAgentId]
: undefined
const spawnableAgentIds = new Set(currentAgentDef?.spawnableAgents ?? [])
// Only include bundled agents that are in the spawnableAgents list
filteredBundledAgents = bundledAgentsInfo.filter((agent) =>
spawnableAgentIds.has(agent.id),
)
} else {
filteredBundledAgents = bundledAgentsInfo
}
const results: LocalAgentInfo[] = [...filteredBundledAgents]
const includedIds = new Set(filteredBundledAgents.map((a) => a.id))
// Get user agents from the SDK-loaded cache
// User agents are always included (not filtered by mode) and can override bundled agents
const userAgents = getUserAgentsAsLocalInfo()
// Merge user agents - they override bundled agents with same ID
// and are always included regardless of mode filtering
for (const userAgent of userAgents) {
if (includedIds.has(userAgent.id)) {
// Replace bundled agent with user's version
const idx = results.findIndex((a) => a.id === userAgent.id)
if (idx !== -1) {
results[idx] = userAgent
}
} else {
results.push(userAgent)
includedIds.add(userAgent.id)
}
}
const sorted = results.sort((a, b) =>
a.displayName.localeCompare(b.displayName, 'en'),
)
cachedAgentsByMode.set(cacheKey, sorted)
return sorted
}
// ============================================================================
// Agent loading - AgentDefinition (full definitions for runtime)
// ============================================================================
/**
* Load agent definitions from bundled agents and user's .agents directory.
* Bundled agents are compiled into the CLI binary at build time.
* User agents from .agents/ are loaded via SDK at startup and cached.
* User agents can override bundled agents with the same ID.
*
* Additionally, all user agent IDs are automatically added to the spawnableAgents
* of any base agent (agents with IDs starting with 'base'), so users can spawn
* their custom agents without needing to modify the base agent definition.
*/
export const loadAgentDefinitions = (): AgentDefinition[] => {
// Start with bundled agents - these are the default Codebuff agents
const bundledAgents = getBundledAgents()
const definitions: AgentDefinition[] = Object.values(bundledAgents).map(
(def) => ({ ...def }),
)
const bundledIds = new Set(Object.keys(bundledAgents))
// Get user agents from the SDK-loaded cache
const userAgentDefs = getUserAgentDefinitions()
const userAgentIds = userAgentDefs.map((def) => def.id)
for (const agentDef of userAgentDefs) {
// User agents override bundled agents with the same ID
if (bundledIds.has(agentDef.id)) {
const idx = definitions.findIndex((d) => d.id === agentDef.id)
if (idx !== -1) {
definitions[idx] = { ...agentDef }
}
} else {
definitions.push({ ...agentDef })
}
}
// Auto-add user agent IDs to spawnableAgents of base agents
// This allows users to spawn their custom agents without needing to
// explicitly add them to the base agent's spawnableAgents list
if (userAgentIds.length > 0) {
for (const def of definitions) {
// Consider any agent with an ID starting with 'base' as a base agent
if (def.id.startsWith('base') && def.spawnableAgents) {
const existingSpawnable = new Set(def.spawnableAgents)
for (const userAgentId of userAgentIds) {
if (!existingSpawnable.has(userAgentId)) {
def.spawnableAgents = [...def.spawnableAgents, userAgentId]
}
}
}
}
}
// Merge MCP servers from mcp.json into base agents
// This allows users to configure MCP tools that are available to the main agent
if (Object.keys(mcpServersCache).length > 0) {
for (const def of definitions) {
// Consider any agent with an ID starting with 'base' as a base agent
if (def.id.startsWith('base')) {
// Initialize mcpServers if not present
if (!def.mcpServers) {
def.mcpServers = {}
}
// Merge MCP servers (user config can override existing servers)
def.mcpServers = {
...def.mcpServers,
...mcpServersCache,
}
}
}
}
return definitions
}
// ============================================================================
// UI/Display utilities
// ============================================================================
export const announceLoadedAgents = (): void => {
const agents = loadLocalAgents()
const agentsDir = findAgentsDirectory()
if (!agentsDir) {
logger.debug('[agents] No .agents directory found in this project.')
return
}
if (!agents.length) {
logger.debug({ agentsDir }, '[agents] No agent files found')
return
}
const agentIdentifiers = agents.map((agent) =>
agent.displayName && agent.displayName !== agent.id
? `${agent.displayName} (${agent.id})`
: agent.displayName || agent.id,
)
logger.debug(
{ agentsDir, agents: agentIdentifiers },
`[agents] Loaded ${pluralize(agents.length, 'local agent')}`,
)
}
export const getLoadedAgentsMessage = (): string | null => {
const agents = loadLocalAgents()
const agentsDir = findAgentsDirectory()
if (!agentsDir || !agents.length) {
return null
}
const agentCount = agents.length
const header = `Loaded ${pluralize(agentCount, 'local agent')} from ${agentsDir}`
const agentList = agents
.map((agent) => {
const identifier =
agent.displayName && agent.displayName !== agent.id
? `${agent.displayName} (${agent.id})`
: agent.displayName || agent.id
return ` - ${identifier}`
})
.join('\n')
return `${header}\n${agentList}`
}
export const getLoadedAgentsData = (): {
agents: LocalAgentInfo[]
agentsDir: string
} | null => {
const agents = loadLocalAgents()
const agentsDir = findAgentsDirectory()
if (!agentsDir || !agents.length) {
return null
}
return { agents, agentsDir }
}
// ============================================================================
// Testing utilities
// ============================================================================
/**
* Clear cached agent listings. Intended for test scenarios that need to
* re-evaluate the filesystem state between cases.
*/
export const __resetLocalAgentRegistryForTests = (): void => {
cachedAgentsByMode.clear()
cachedAgentsDir = null
userAgentsCache = {}
userAgentFilePaths = new Map()
mcpServersCache = {}
}
/**
* Get the currently loaded MCP servers from mcp.json.
* Useful for debugging and displaying loaded MCP configuration.
*/
export const getLoadedMCPServers = (): Record<string, MCPConfig> => {
return { ...mcpServersCache }
}