Skip to content

Commit c765ba9

Browse files
committed
feat(imports): add namespacing support for imported resources
- Add new registry functions for import metadata lookup - Implement namespacing for agents, workflows, and placeholders - Support both raw and namespaced IDs in agent resolution - Cache configs and add debug logging for troubleshooting
1 parent 129a477 commit c765ba9

8 files changed

Lines changed: 406 additions & 71 deletions

File tree

src/agents/runner/config.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from 'node:path';
33

44
import { collectAgentDefinitions, resolveProjectRoot } from '../../shared/agents/index.js';
55
import type { AgentDefinition } from '../../shared/agents/config/types.js';
6-
import { resolvePromptPath } from '../../shared/imports/index.js';
6+
import { resolvePromptPath, getAllInstalledImports } from '../../shared/imports/index.js';
77
import { resolvePackageRoot } from '../../shared/runtime/root.js';
88

99
const packageRoot = resolvePackageRoot(import.meta.url, 'agent runner config');
@@ -34,6 +34,7 @@ function getDefaultPromptPath(agentId: string): string {
3434

3535
/**
3636
* Loads the agent configuration by ID from all available agent files
37+
* Supports both raw IDs and namespaced IDs (e.g., 'bmad-pm' or 'bmad:bmad-pm')
3738
*/
3839
export async function loadAgentConfig(agentId: string, projectRoot?: string): Promise<AgentConfig> {
3940
const lookupBase = projectRoot ?? process.env.CODEMACHINE_CWD ?? process.cwd();
@@ -42,7 +43,23 @@ export async function loadAgentConfig(agentId: string, projectRoot?: string): Pr
4243
// Collect all agent definitions from all config files
4344
const agents = await collectAgentDefinitions(resolvedRoot);
4445

45-
const config = agents.find((a) => a.id === agentId) as AgentConfig | undefined;
46+
// Try direct lookup first
47+
let config = agents.find((a) => a.id === agentId) as AgentConfig | undefined;
48+
49+
// If not found, try looking up with import namespaces prefixed
50+
// This handles cases where workflow templates reference agents by raw ID
51+
// but agents are registered with namespace (e.g., 'bmad-pm' -> 'bmad:bmad-pm')
52+
if (!config) {
53+
const imports = getAllInstalledImports();
54+
for (const imp of imports) {
55+
const namespacedId = `${imp.name}:${agentId}`;
56+
config = agents.find((a) => a.id === namespacedId) as AgentConfig | undefined;
57+
if (config) {
58+
break;
59+
}
60+
}
61+
}
62+
4663
if (!config) {
4764
throw new Error(`Unknown agent id: ${agentId}. Available agents: ${agents.map(a => a.id).join(', ')}`);
4865
}

src/cli/tui/shared/config/agent-characters.ts

Lines changed: 116 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as path from "node:path"
1212
import { existsSync, readFileSync } from "node:fs"
1313
import { resolvePackageRoot } from "../../../../shared/runtime/root.js"
1414
import { getAllInstalledImports } from "../../../../shared/imports/registry.js"
15+
import { debug } from "../../../../shared/logging/logger.js"
1516
import type { ActivityType, AgentCharactersConfig, Persona } from "./agent-characters.types.js"
1617

1718
let cachedConfig: AgentCharactersConfig | null = null
@@ -30,14 +31,44 @@ function getPackageRoot(): string | null {
3031
/**
3132
* Merges two agent characters configs
3233
* Imported config extends/overrides the base config
34+
* @param packageName - If provided, namespace agent IDs with this package name
3335
*/
3436
function mergeCharactersConfigs(
3537
base: AgentCharactersConfig,
36-
imported: AgentCharactersConfig
38+
imported: Partial<AgentCharactersConfig>,
39+
packageName?: string
3740
): AgentCharactersConfig {
41+
// Namespace agent IDs if from an imported package
42+
const namespacedAgents: Record<string, string> = {}
43+
if (imported.agents) {
44+
for (const [agentId, personaName] of Object.entries(imported.agents)) {
45+
const namespacedId = packageName ? `${packageName}:${agentId}` : agentId
46+
namespacedAgents[namespacedId] = personaName
47+
}
48+
}
49+
50+
// Namespace persona names if from an imported package
51+
const namespacedPersonas: Record<string, Persona> = {}
52+
if (imported.personas) {
53+
for (const [personaName, persona] of Object.entries(imported.personas)) {
54+
const namespacedName = packageName ? `${packageName}:${personaName}` : personaName
55+
namespacedPersonas[namespacedName] = persona
56+
}
57+
}
58+
59+
// Update agent mappings to use namespaced persona names
60+
const finalAgents: Record<string, string> = { ...base.agents }
61+
for (const [agentId, personaName] of Object.entries(namespacedAgents)) {
62+
// If persona was namespaced AND the persona exists in imported, update the mapping
63+
// Otherwise keep the original persona name (allows referencing base personas)
64+
const namespacedPersonaName = packageName ? `${packageName}:${personaName}` : personaName
65+
const finalPersonaName = imported.personas?.[personaName] ? namespacedPersonaName : personaName
66+
finalAgents[agentId] = finalPersonaName
67+
}
68+
3869
return {
39-
personas: { ...base.personas, ...imported.personas },
40-
agents: { ...base.agents, ...imported.agents },
70+
personas: { ...base.personas, ...namespacedPersonas },
71+
agents: finalAgents,
4172
defaultPersona: base.defaultPersona, // Keep base default
4273
}
4374
}
@@ -47,13 +78,17 @@ function mergeCharactersConfigs(
4778
*/
4879
function loadCharactersFromPath(configPath: string): AgentCharactersConfig | null {
4980
if (!existsSync(configPath)) {
81+
debug('[Characters] loadCharactersFromPath: file does not exist: %s', configPath)
5082
return null
5183
}
5284

5385
try {
5486
const content = readFileSync(configPath, "utf-8")
55-
return JSON.parse(content) as AgentCharactersConfig
56-
} catch {
87+
const parsed = JSON.parse(content) as AgentCharactersConfig
88+
debug('[Characters] loadCharactersFromPath: successfully loaded %s', configPath)
89+
return parsed
90+
} catch (err) {
91+
debug('[Characters] loadCharactersFromPath: parse error for %s: %s', configPath, err)
5792
return null
5893
}
5994
}
@@ -68,6 +103,8 @@ export function loadAgentCharactersConfig(): AgentCharactersConfig {
68103
return cachedConfig
69104
}
70105

106+
debug('[Characters] loadAgentCharactersConfig: loading fresh config...')
107+
71108
// Start with default config
72109
let config = getDefaultConfig()
73110

@@ -78,22 +115,47 @@ export function loadAgentCharactersConfig(): AgentCharactersConfig {
78115
const baseConfig = loadCharactersFromPath(basePath)
79116
if (baseConfig) {
80117
config = baseConfig
118+
debug('[Characters] Loaded base config from %s, personas=%o, agents=%o, default=%s',
119+
basePath, Object.keys(config.personas), Object.keys(config.agents), config.defaultPersona)
81120
}
82121
}
83122

84-
// Merge characters from all installed imports
123+
// Merge characters from all installed imports with namespacing
85124
try {
86125
const imports = getAllInstalledImports()
126+
debug('[Characters] Found %d imports to merge', imports.length)
87127
for (const imp of imports) {
88-
const importedConfig = loadCharactersFromPath(imp.resolvedPaths.characters)
89-
if (importedConfig) {
90-
config = mergeCharactersConfigs(config, importedConfig)
128+
try {
129+
const charactersPath = imp.resolvedPaths?.characters
130+
debug('[Characters] Import %s: charactersPath=%s, exists=%s',
131+
imp.name, charactersPath, charactersPath ? existsSync(charactersPath) : 'no-path')
132+
if (!charactersPath) {
133+
debug('[Characters] Import %s has no characters path, skipping', imp.name)
134+
continue
135+
}
136+
const importedConfig = loadCharactersFromPath(charactersPath)
137+
if (importedConfig) {
138+
debug('[Characters] Merging import %s: personas=%o, agents=%o',
139+
imp.name,
140+
importedConfig.personas ? Object.keys(importedConfig.personas) : [],
141+
importedConfig.agents ? Object.keys(importedConfig.agents) : [])
142+
// Namespace agent IDs and persona names with the import package name
143+
config = mergeCharactersConfigs(config, importedConfig, imp.name)
144+
} else {
145+
debug('[Characters] Import %s: failed to load config from %s', imp.name, charactersPath)
146+
}
147+
} catch (importErr) {
148+
// Log error for this specific import but continue with others
149+
debug('[Characters] Error processing import %s: %s', imp.name, importErr)
91150
}
92151
}
93-
} catch {
94-
// Silently ignore import errors - may not have imports system initialized
152+
} catch (err) {
153+
debug('[Characters] Error loading imports registry: %s', err)
95154
}
96155

156+
debug('[Characters] Final config: personas=%o, agents=%o, default=%s',
157+
Object.keys(config.personas), Object.keys(config.agents), config.defaultPersona)
158+
97159
cachedConfig = config
98160
return cachedConfig
99161
}
@@ -124,14 +186,55 @@ function getDefaultConfig(): AgentCharactersConfig {
124186
}
125187
}
126188

189+
/**
190+
* Resolve agent ID to find matching character config
191+
* Handles workflow agent IDs with step suffixes (e.g., "bmad-analyst-step-0")
192+
* and finds namespaced agents (e.g., "bmad:bmad-analyst")
193+
*/
194+
function resolveAgentId(agentId: string, agents: Record<string, string>): string | null {
195+
debug('[Characters] resolveAgentId: input=%s, available agents=%o', agentId, Object.keys(agents))
196+
197+
// 1. Try exact match first
198+
if (agents[agentId]) {
199+
debug('[Characters] resolveAgentId: exact match found for %s', agentId)
200+
return agentId
201+
}
202+
203+
// 2. Strip -step-N suffix and try again (workflow agent IDs)
204+
const baseId = agentId.replace(/-step-\d+$/, '')
205+
debug('[Characters] resolveAgentId: baseId (stripped suffix)=%s', baseId)
206+
207+
if (baseId !== agentId && agents[baseId]) {
208+
debug('[Characters] resolveAgentId: match found for baseId %s', baseId)
209+
return baseId
210+
}
211+
212+
// 3. Look for namespaced version (e.g., "bmad:bmad-analyst" for "bmad-analyst")
213+
for (const key of Object.keys(agents)) {
214+
// Check if key ends with ":baseId" (namespaced match)
215+
if (key.endsWith(`:${baseId}`)) {
216+
debug('[Characters] resolveAgentId: namespaced match found %s for baseId %s', key, baseId)
217+
return key
218+
}
219+
}
220+
221+
debug('[Characters] resolveAgentId: no match found for %s, will use default', agentId)
222+
return null
223+
}
224+
127225
/**
128226
* Gets the persona configuration for a specific agent
129227
* Looks up agent -> persona mapping, falls back to defaultPersona
228+
* Handles workflow agent IDs with step suffixes and namespaced imports
130229
*/
131230
export function getCharacter(agentId: string): Persona {
132231
const config = loadAgentCharactersConfig()
133-
const personaName = config.agents[agentId] ?? config.defaultPersona
134-
return config.personas[personaName] ?? config.personas[config.defaultPersona] ?? getDefaultConfig().personas.default
232+
const resolvedId = resolveAgentId(agentId, config.agents)
233+
const personaName = resolvedId ? config.agents[resolvedId] : config.defaultPersona
234+
const persona = config.personas[personaName] ?? config.personas[config.defaultPersona] ?? getDefaultConfig().personas.default
235+
debug('[Characters] getCharacter: agentId=%s → resolvedId=%s → persona=%s → face=%s',
236+
agentId, resolvedId ?? 'null', personaName, persona.baseFace)
237+
return persona
135238
}
136239

137240
/**

src/runtime/services/workspace/discovery.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
55

66
import { collectAgentsFromWorkflows } from '../../../shared/agents/index.js';
77
import { resolvePackageRoot } from '../../../shared/runtime/root.js';
8+
import { getImportRootsWithMetadata } from '../../../shared/imports/index.js';
89

910
const __filename = fileURLToPath(import.meta.url);
1011
const __dirname = path.dirname(__filename);
@@ -40,29 +41,52 @@ export function debugLog(...args: unknown[]): void {
4041
export type AgentDefinition = Record<string, unknown> & { mirrorPath?: string };
4142
export type LoadedAgent = AgentDefinition & { id: string; source?: 'main' | 'sub' | 'legacy' | 'workflow' };
4243

44+
/**
45+
* Module candidate with metadata for namespacing
46+
*/
47+
interface ModuleCandidate {
48+
source: 'main' | 'sub' | 'legacy';
49+
packageName: string | null;
50+
}
51+
4352
export async function loadAgents(
4453
candidateRoots: string[],
4554
filterIds?: string[]
4655
): Promise<{ allAgents: AgentDefinition[]; subAgents: AgentDefinition[] }> {
47-
const candidateModules = new Map<string, 'main' | 'sub' | 'legacy'>();
56+
// Build a map from import path to package name for namespacing
57+
const importedRootsWithMeta = getImportRootsWithMetadata();
58+
const importPathToPackage = new Map<string, string>();
59+
for (const imp of importedRootsWithMeta) {
60+
importPathToPackage.set(path.resolve(imp.path), imp.packageName);
61+
}
62+
63+
const candidateModules = new Map<string, ModuleCandidate>();
4864

4965
for (const root of candidateRoots) {
5066
if (!root) continue;
5167
const resolvedRoot = path.resolve(root);
68+
69+
// Determine if this root is from an import
70+
const packageName = importPathToPackage.get(resolvedRoot) ?? null;
71+
5272
for (const filename of AGENT_MODULE_FILENAMES) {
5373
const moduleCandidate = path.join(resolvedRoot, 'config', filename);
5474
const distCandidate = path.join(resolvedRoot, 'dist', 'config', filename);
5575

56-
const tag = filename === 'main.agents.js' ? 'main' : filename === 'sub.agents.js' ? 'sub' : 'legacy';
76+
const source = filename === 'main.agents.js' ? 'main' : filename === 'sub.agents.js' ? 'sub' : 'legacy';
5777

58-
if (existsSync(moduleCandidate)) candidateModules.set(moduleCandidate, tag);
59-
if (existsSync(distCandidate)) candidateModules.set(distCandidate, tag);
78+
if (existsSync(moduleCandidate)) {
79+
candidateModules.set(moduleCandidate, { source, packageName });
80+
}
81+
if (existsSync(distCandidate)) {
82+
candidateModules.set(distCandidate, { source, packageName });
83+
}
6084
}
6185
}
6286

6387
const byId = new Map<string, LoadedAgent>();
6488

65-
for (const [modulePath, source] of candidateModules.entries()) {
89+
for (const [modulePath, { source, packageName }] of candidateModules.entries()) {
6690
try {
6791
delete require.cache[require.resolve(modulePath)];
6892
} catch {
@@ -76,10 +100,14 @@ export async function loadAgents(
76100
if (!agent || typeof agent.id !== 'string') {
77101
continue;
78102
}
79-
const id = agent.id.trim();
80-
if (!id) {
103+
const rawId = agent.id.trim();
104+
if (!rawId) {
81105
continue;
82106
}
107+
108+
// Namespace the ID if from an imported package
109+
const id = packageName ? `${packageName}:${rawId}` : rawId;
110+
83111
const existing = byId.get(id);
84112
const sourceTag = existing?.source ?? source;
85113
const merged: LoadedAgent = {
@@ -92,7 +120,8 @@ export async function loadAgents(
92120
}
93121
}
94122

95-
const workflowAgents = await collectAgentsFromWorkflows(candidateRoots);
123+
// Pass import path map for workflow agent namespacing
124+
const workflowAgents = await collectAgentsFromWorkflows(candidateRoots, importPathToPackage);
96125
for (const agent of workflowAgents) {
97126
if (!agent || typeof agent.id !== 'string') continue;
98127
const id = agent.id.trim();
@@ -111,6 +140,7 @@ export async function loadAgents(
111140
const allAgents = Array.from(byId.values()).map(({ source: _source, ...agent }) => ({ ...agent }));
112141

113142
// Filter sub-agents by IDs if filterIds is provided
143+
// Note: filterIds should use namespaced IDs for imported agents
114144
const subAgents = Array.from(byId.values())
115145
.filter((agent) => {
116146
if (agent.source !== 'sub') return false;

0 commit comments

Comments
 (0)