diff --git a/packages/core/src/agent-companies-parser.test.ts b/packages/core/src/agent-companies-parser.test.ts index e66ed8864..bd8fd03c6 100644 --- a/packages/core/src/agent-companies-parser.test.ts +++ b/packages/core/src/agent-companies-parser.test.ts @@ -9,6 +9,7 @@ import { AgentCompaniesParseError, agentManifestToAgentCreateInput, convertAgentCompanies, + prepareAgentCompaniesImport, mapRoleToCapability, parseAgentManifest, parseCompanyArchive, @@ -386,6 +387,96 @@ name: Zip CEO }); }); + it("prepares imports with manager-first ordering and deferred hierarchy refs", () => { + const { items, result } = prepareAgentCompaniesImport({ + company: { name: "Example" }, + agents: [ + { name: "IC", reportsTo: "../vp-eng/AGENTS.md" }, + { name: "CEO", slug: "ceo" }, + { name: "VP Eng", slug: "vp-eng", reportsTo: "ceo" }, + ], + teams: [], + projects: [], + tasks: [], + }); + + expect(items.map((item) => item.input.name)).toEqual(["CEO", "VP Eng", "IC"]); + expect(items[0]).not.toHaveProperty("reportsTo"); + expect(items[1]?.reportsTo).toEqual({ + raw: "ceo", + deferredManifestKey: "ceo", + }); + expect(items[2]?.reportsTo).toEqual({ + raw: "../vp-eng/AGENTS.md", + deferredManifestKey: "vp-eng", + }); + expect(result.errors).toEqual([]); + }); + + it("resolves existing manager refs by slug, path, and agent id", () => { + const existingAgents = [ + { + id: "agent-ceo01", + name: "Chief Executive Officer", + metadata: { agentCompaniesSlug: "ceo" }, + }, + ]; + + const { items, result } = prepareAgentCompaniesImport( + { + company: { name: "Example" }, + agents: [ + { name: "Ops Lead", reportsTo: "ceo" }, + { name: "QA Lead", reportsTo: "../ceo/AGENTS.md" }, + { name: "Staff Eng", reportsTo: "agent-ceo01" }, + ], + teams: [], + projects: [], + tasks: [], + }, + { existingAgents }, + ); + + expect(items.map((item) => item.input.reportsTo)).toEqual([ + "agent-ceo01", + "agent-ceo01", + "agent-ceo01", + ]); + expect(result.errors).toEqual([]); + }); + + it("keeps unresolved internal refs out of the import plan", () => { + const { items, result } = prepareAgentCompaniesImport({ + company: { name: "Example" }, + agents: [{ name: "Worker", reportsTo: "unknown-manager" }], + teams: [], + projects: [], + tasks: [], + }); + + expect(items).toEqual([]); + expect(result).toEqual({ + created: [], + skipped: [], + errors: [ + { + name: "Worker", + error: + 'Could not resolve reportsTo reference "unknown-manager" to an imported or existing Fusion agent', + }, + ], + }); + }); + + it("stores the manifest slug in metadata for future hierarchy resolution", () => { + const input = agentManifestToAgentCreateInput({ + name: "CEO", + slug: "ceo", + }); + + expect(input.metadata).toEqual({ agentCompaniesSlug: "ceo" }); + }); + it("defaults to custom role when no skills are present", () => { const input = agentManifestToAgentCreateInput({ name: "Generalist" }); expect(input.role).toBe("custom"); diff --git a/packages/core/src/agent-companies-parser.ts b/packages/core/src/agent-companies-parser.ts index be4a37d1b..995ff5c95 100644 --- a/packages/core/src/agent-companies-parser.ts +++ b/packages/core/src/agent-companies-parser.ts @@ -40,6 +40,226 @@ const VALID_ROLES: Set = new Set([ "custom", ]); +function slugifyAgentReference(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function normalizeReference(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizePathReference(value: string): string { + const normalized = value.trim().replace(/\\/g, "/").replace(/\/+/g, "/"); + const withoutFile = normalized.replace(/\/AGENTS\.md$/i, "").replace(/\.md$/i, ""); + return withoutFile.replace(/^\.\//, "").replace(/\/$/, "").toLowerCase(); +} + +function extractPathBasename(value: string): string | undefined { + const normalized = normalizePathReference(value); + if (!normalized.includes("/")) { + return undefined; + } + + const segments = normalized.split("/").filter(Boolean); + return segments.at(-1); +} + +function looksLikeFusionAgentId(value: string): boolean { + return /^agent-[a-z0-9-]+$/i.test(value.trim()); +} + +function pushAlias(aliases: Set, value: string | undefined | null): void { + if (typeof value !== "string") { + return; + } + + const normalized = normalizeReference(value); + if (normalized.length > 0) { + aliases.add(normalized); + } + + const slug = slugifyAgentReference(value); + if (slug.length > 0) { + aliases.add(slug); + } + + const pathRef = normalizePathReference(value); + if (pathRef !== normalized && pathRef.length > 0) { + aliases.add(pathRef); + } + + const basename = extractPathBasename(value); + if (basename) { + aliases.add(basename); + const basenameSlug = slugifyAgentReference(basename); + if (basenameSlug.length > 0) { + aliases.add(basenameSlug); + } + } +} + +function collectAgentManifestAliases(agent: AgentManifest): string[] { + const aliases = new Set(); + pushAlias(aliases, agent.slug); + pushAlias(aliases, agent.name); + pushAlias(aliases, agent.title); + return [...aliases]; +} + +function collectExistingAgentAliases(agent: { + id: string; + name: string; + title?: string; + metadata?: Record; +}): string[] { + const aliases = new Set(); + pushAlias(aliases, agent.id); + pushAlias(aliases, agent.name); + pushAlias(aliases, agent.title); + + const metadataSlug = agent.metadata?.agentCompaniesSlug; + if (typeof metadataSlug === "string") { + pushAlias(aliases, metadataSlug); + } + + return [...aliases]; +} + +function addAliases( + index: Map>, + ownerKey: string, + aliases: Iterable, +): void { + for (const alias of aliases) { + const bucket = index.get(alias) ?? new Set(); + bucket.add(ownerKey); + index.set(alias, bucket); + } +} + +function resolveUniqueAlias( + aliasIndex: Map>, + reference: string, +): { value?: string; ambiguous?: true } { + const aliases = new Set(); + pushAlias(aliases, reference); + + const matches = new Set(); + for (const alias of aliases) { + for (const match of aliasIndex.get(alias) ?? []) { + matches.add(match); + } + } + + if (matches.size === 1) { + return { value: [...matches][0] }; + } + + if (matches.size > 1) { + return { ambiguous: true }; + } + + return {}; +} + +function createUniqueManifestKey(baseKey: string, usedKeys: Set): string { + let candidate = baseKey; + let counter = 2; + + while (usedKeys.has(candidate)) { + candidate = `${baseKey}#${counter}`; + counter += 1; + } + + usedKeys.add(candidate); + return candidate; +} + +function topologicallySortImportPlanItems( + items: PreparedAgentCompaniesImportItem[], +): { + orderedItems: PreparedAgentCompaniesImportItem[]; + cycleErrors: Array<{ name: string; error: string }>; +} { + const byKey = new Map(items.map((item) => [item.manifestKey, item])); + const indegree = new Map(); + const dependents = new Map(); + + for (const item of items) { + indegree.set(item.manifestKey, 0); + } + + for (const item of items) { + const deferredKey = item.reportsTo?.deferredManifestKey; + if (!deferredKey || !byKey.has(deferredKey)) { + continue; + } + + indegree.set(item.manifestKey, (indegree.get(item.manifestKey) ?? 0) + 1); + const downstream = dependents.get(deferredKey) ?? []; + downstream.push(item.manifestKey); + dependents.set(deferredKey, downstream); + } + + const ready = items + .filter((item) => (indegree.get(item.manifestKey) ?? 0) === 0) + .sort((a, b) => a.index - b.index); + const orderedItems: PreparedAgentCompaniesImportItem[] = []; + + while (ready.length > 0) { + const current = ready.shift(); + if (!current) { + continue; + } + + orderedItems.push(current); + + for (const dependentKey of dependents.get(current.manifestKey) ?? []) { + const nextDegree = (indegree.get(dependentKey) ?? 0) - 1; + indegree.set(dependentKey, nextDegree); + if (nextDegree === 0) { + const dependent = byKey.get(dependentKey); + if (dependent) { + ready.push(dependent); + ready.sort((a, b) => a.index - b.index); + } + } + } + } + + const cycleErrors = items + .filter((item) => !orderedItems.some((ordered) => ordered.manifestKey === item.manifestKey)) + .sort((a, b) => a.index - b.index) + .map((item) => ({ + name: item.input.name, + error: `Could not resolve reportsTo hierarchy for ${item.input.name} because the import graph contains a cycle involving "${item.reportsTo?.raw ?? "unknown"}"`, + })); + + return { orderedItems, cycleErrors }; +} + +export interface PreparedAgentCompaniesImportItem { + manifestKey: string; + aliases: string[]; + input: AgentCreateInput; + index: number; + reportsTo?: { + raw: string; + resolvedAgentId?: string; + deferredManifestKey?: string; + }; +} + +export interface PreparedAgentCompaniesImportResult { + items: PreparedAgentCompaniesImportItem[]; + result: AgentCompaniesImportResult; +} + /** * Map a role string to a Fusion agent capability. * Unknown roles fall back to "custom". @@ -291,6 +511,9 @@ export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCrea if (Array.isArray(agent.metadata?.sources) && agent.metadata.sources.length > 0) { metadata.sources = agent.metadata.sources; } + if (typeof agent.slug === "string" && agent.slug.trim().length > 0) { + metadata.agentCompaniesSlug = agent.slug.trim(); + } return { name: agent.name, @@ -311,34 +534,162 @@ export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCrea }; } -export function convertAgentCompanies( +export function prepareAgentCompaniesImport( pkg: AgentCompaniesPackage, - options?: { skipExisting?: string[] }, -): { inputs: AgentCreateInput[]; result: AgentCompaniesImportResult } { + options?: { + skipExisting?: string[]; + existingAgents?: Array<{ + id: string; + name: string; + title?: string; + metadata?: Record; + }>; + }, +): PreparedAgentCompaniesImportResult { const existingNames = new Set(options?.skipExisting ?? []); - const inputs: AgentCreateInput[] = []; + const existingAliasIndex = new Map>(); + const existingAgentsById = new Map( + (options?.existingAgents ?? []).map((agent) => [agent.id, agent]), + ); + + for (const agent of options?.existingAgents ?? []) { + addAliases(existingAliasIndex, agent.id, collectExistingAgentAliases(agent)); + } + + const plannedAgents: Array = []; + const pendingAgents: Array = []; + const manifestAliasIndex = new Map>(); + const usedManifestKeys = new Set(); const result: AgentCompaniesImportResult = { created: [], skipped: [], errors: [], }; - for (const agent of pkg.agents) { + for (const [index, agent] of pkg.agents.entries()) { if (existingNames.has(agent.name)) { result.skipped.push(agent.name); continue; } - try { - inputs.push(agentManifestToAgentCreateInput(agent)); - result.created.push(agent.name); - } catch (error) { - result.errors.push({ - name: agent.name, - error: (error as Error).message, - }); - } + const aliases = collectAgentManifestAliases(agent); + const baseKey = aliases[0] ?? createUniqueManifestKey(`agent-${index + 1}`, usedManifestKeys); + const manifestKey = createUniqueManifestKey(baseKey, usedManifestKeys); + + const planned = { + manifest: agent, + manifestKey, + aliases, + input: agentManifestToAgentCreateInput(agent), + index, + }; + + addAliases(manifestAliasIndex, manifestKey, aliases); + plannedAgents.push(planned); } - return { inputs, result }; + for (const planned of plannedAgents) { + const rawReportsTo = typeof planned.manifest.reportsTo === "string" + ? planned.manifest.reportsTo.trim() + : undefined; + + if (!rawReportsTo) { + delete planned.input.reportsTo; + pendingAgents.push(planned); + result.created.push(planned.input.name); + continue; + } + + const existingMatch = resolveUniqueAlias(existingAliasIndex, rawReportsTo); + if (existingMatch.ambiguous) { + result.errors.push({ + name: planned.input.name, + error: `reportsTo reference "${rawReportsTo}" is ambiguous among existing Fusion agents`, + }); + continue; + } + if (existingMatch.value) { + planned.input.reportsTo = existingMatch.value; + planned.reportsTo = { + raw: rawReportsTo, + resolvedAgentId: existingMatch.value, + }; + pendingAgents.push(planned); + result.created.push(planned.input.name); + continue; + } + + const manifestMatch = resolveUniqueAlias(manifestAliasIndex, rawReportsTo); + if (manifestMatch.ambiguous) { + result.errors.push({ + name: planned.input.name, + error: `reportsTo reference "${rawReportsTo}" matches multiple imported agents`, + }); + continue; + } + if (manifestMatch.value) { + if (manifestMatch.value === planned.manifestKey) { + result.errors.push({ + name: planned.input.name, + error: `reportsTo reference "${rawReportsTo}" resolves to the agent itself`, + }); + continue; + } + + delete planned.input.reportsTo; + planned.reportsTo = { + raw: rawReportsTo, + deferredManifestKey: manifestMatch.value, + }; + pendingAgents.push(planned); + result.created.push(planned.input.name); + continue; + } + + if (looksLikeFusionAgentId(rawReportsTo) && !existingAgentsById.has(rawReportsTo)) { + planned.input.reportsTo = rawReportsTo; + planned.reportsTo = { + raw: rawReportsTo, + resolvedAgentId: rawReportsTo, + }; + pendingAgents.push(planned); + result.created.push(planned.input.name); + continue; + } + + result.errors.push({ + name: planned.input.name, + error: `Could not resolve reportsTo reference "${rawReportsTo}" to an imported or existing Fusion agent`, + }); + } + + const { orderedItems, cycleErrors } = topologicallySortImportPlanItems(pendingAgents); + for (const error of cycleErrors) { + result.errors.push(error); + result.created = result.created.filter((name) => name !== error.name); + } + + return { + items: orderedItems.map(({ manifest: _manifest, ...item }) => item), + result, + }; +} + +export function convertAgentCompanies( + pkg: AgentCompaniesPackage, + options?: { + skipExisting?: string[]; + existingAgents?: Array<{ + id: string; + name: string; + title?: string; + metadata?: Record; + }>; + }, +): { inputs: AgentCreateInput[]; result: AgentCompaniesImportResult } { + const { items, result } = prepareAgentCompaniesImport(pkg, options); + return { + inputs: items.map((item) => item.input), + result, + }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8b4e27111..88da0453c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -444,9 +444,14 @@ export { parseCompanyArchive, mapRoleToCapability, agentManifestToAgentCreateInput, + prepareAgentCompaniesImport, convertAgentCompanies, AgentCompaniesParseError, } from "./agent-companies-parser.js"; +export type { + PreparedAgentCompaniesImportItem, + PreparedAgentCompaniesImportResult, +} from "./agent-companies-parser.js"; // ── Agent Companies Exporter ──────────────────────────────