feat(FN-1451): complete Step 3 — align CLI hierarchy imports
This commit is contained in:
@@ -9,6 +9,8 @@ import { runAgentImport } from "./agent-import.js";
|
||||
function makeAgentManifest(options: {
|
||||
name: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
reportsTo?: string;
|
||||
skills?: string[];
|
||||
body?: string;
|
||||
}): string {
|
||||
@@ -16,6 +18,12 @@ function makeAgentManifest(options: {
|
||||
if (options.title) {
|
||||
lines.push(`title: ${options.title}`);
|
||||
}
|
||||
if (options.slug) {
|
||||
lines.push(`slug: ${options.slug}`);
|
||||
}
|
||||
if (options.reportsTo) {
|
||||
lines.push(`reportsTo: ${options.reportsTo}`);
|
||||
}
|
||||
if (options.skills && options.skills.length > 0) {
|
||||
lines.push("skills:");
|
||||
for (const skill of options.skills) {
|
||||
@@ -55,6 +63,48 @@ function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
function createHierarchyCompanyDirectory(basePath: string): string {
|
||||
mkdirSync(basePath, { recursive: true });
|
||||
writeFileSync(
|
||||
join(basePath, "COMPANY.md"),
|
||||
"---\nname: Example Company\nslug: example-company\n---\nCompany description",
|
||||
);
|
||||
|
||||
mkdirSync(join(basePath, "agents", "ceo"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(basePath, "agents", "ceo", "AGENTS.md"),
|
||||
makeAgentManifest({
|
||||
name: "CEO",
|
||||
slug: "ceo",
|
||||
title: "Chief Executive",
|
||||
body: "Lead the company",
|
||||
}),
|
||||
);
|
||||
|
||||
mkdirSync(join(basePath, "agents", "vp-eng"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(basePath, "agents", "vp-eng", "AGENTS.md"),
|
||||
makeAgentManifest({
|
||||
name: "VP Engineering",
|
||||
slug: "vp-eng",
|
||||
reportsTo: "ceo",
|
||||
body: "Lead engineering",
|
||||
}),
|
||||
);
|
||||
|
||||
mkdirSync(join(basePath, "agents", "staff-eng"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(basePath, "agents", "staff-eng", "AGENTS.md"),
|
||||
makeAgentManifest({
|
||||
name: "Staff Engineer",
|
||||
reportsTo: "../vp-eng/AGENTS.md",
|
||||
body: "Build systems",
|
||||
}),
|
||||
);
|
||||
|
||||
return basePath;
|
||||
}
|
||||
|
||||
describe("agent-import", () => {
|
||||
const tmpDir = join(tmpdir(), `kb-agent-import-test-${process.pid}`);
|
||||
let createAgentMock: ReturnType<typeof vi.fn>;
|
||||
@@ -63,7 +113,10 @@ describe("agent-import", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
createAgentMock = vi.fn();
|
||||
createAgentMock = vi.fn().mockImplementation(async (input: any) => ({
|
||||
id: `agent-${String(input.name).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
|
||||
...input,
|
||||
}));
|
||||
listAgentsMock = vi.fn().mockResolvedValue([]);
|
||||
initMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -133,6 +186,55 @@ describe("agent-import", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves imported manager hierarchy to created Fusion agent ids", async () => {
|
||||
const companyDir = createHierarchyCompanyDirectory(join(tmpDir, "company-hierarchy"));
|
||||
createAgentMock
|
||||
.mockResolvedValueOnce({ id: "agent-ceo", name: "CEO" })
|
||||
.mockResolvedValueOnce({ id: "agent-vp-eng", name: "VP Engineering" })
|
||||
.mockResolvedValueOnce({ id: "agent-staff-eng", name: "Staff Engineer" });
|
||||
|
||||
await runAgentImport(companyDir);
|
||||
|
||||
expect(createAgentMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
name: "CEO",
|
||||
role: "custom",
|
||||
}));
|
||||
expect(createAgentMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
name: "VP Engineering",
|
||||
role: "custom",
|
||||
reportsTo: "agent-ceo",
|
||||
}));
|
||||
expect(createAgentMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
name: "Staff Engineer",
|
||||
role: "custom",
|
||||
reportsTo: "agent-vp-eng",
|
||||
}));
|
||||
});
|
||||
|
||||
it("resolves skipped existing managers before importing their reports", async () => {
|
||||
const companyDir = createHierarchyCompanyDirectory(join(tmpDir, "company-existing-manager"));
|
||||
listAgentsMock.mockResolvedValue([
|
||||
{
|
||||
id: "agent-ceo-existing",
|
||||
name: "CEO",
|
||||
role: "custom",
|
||||
metadata: { agentCompaniesSlug: "ceo" },
|
||||
},
|
||||
]);
|
||||
|
||||
await runAgentImport(companyDir, { skipExisting: true });
|
||||
|
||||
expect(createAgentMock).toHaveBeenCalledTimes(2);
|
||||
expect(createAgentMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
name: "VP Engineering",
|
||||
reportsTo: "agent-ceo-existing",
|
||||
}));
|
||||
expect(createAgentMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
name: "Staff Engineer",
|
||||
reportsTo: "agent-vp-engineering",
|
||||
}));
|
||||
});
|
||||
|
||||
it("imports agents from a single AGENTS.md file", async () => {
|
||||
const manifestPath = join(tmpDir, "AGENTS.md");
|
||||
writeFileSync(
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
parseCompanyDirectory,
|
||||
parseCompanyArchive,
|
||||
parseSingleAgentManifest,
|
||||
convertAgentCompanies,
|
||||
prepareAgentCompaniesImport,
|
||||
AgentCompaniesParseError,
|
||||
} from "@fusion/core";
|
||||
import type { AgentCreateInput } from "@fusion/core";
|
||||
@@ -111,12 +111,23 @@ export async function runAgentImport(
|
||||
|
||||
const existingAgents = await agentStore.listAgents();
|
||||
const existingNames = new Set(existingAgents.map((a) => a.name));
|
||||
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
|
||||
const conversionOptions = {
|
||||
...(skipExisting ? { skipExisting: [...existingNames] } : {}),
|
||||
existingAgents,
|
||||
};
|
||||
|
||||
let companyName: string | undefined;
|
||||
let agentCount = 0;
|
||||
let teamCount = 0;
|
||||
let inputs: AgentCreateInput[] = [];
|
||||
let importItems: Array<{
|
||||
manifestKey: string;
|
||||
input: AgentCreateInput;
|
||||
reportsTo?: {
|
||||
raw: string;
|
||||
resolvedAgentId?: string;
|
||||
deferredManifestKey?: string;
|
||||
};
|
||||
}> = [];
|
||||
let result: {
|
||||
created: string[];
|
||||
skipped: string[];
|
||||
@@ -135,13 +146,13 @@ export async function runAgentImport(
|
||||
companyName = pkg.company?.name;
|
||||
agentCount = pkg.agents.length;
|
||||
teamCount = pkg.teams.length;
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
|
||||
} else if (isArchivePath(sourcePath)) {
|
||||
const pkg = await parseCompanyArchive(sourcePath);
|
||||
companyName = pkg.company?.name;
|
||||
agentCount = pkg.agents.length;
|
||||
teamCount = pkg.teams.length;
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
|
||||
} else if (sourcePath.endsWith(".md")) {
|
||||
const content = readFileSync(sourcePath, "utf-8");
|
||||
const { manifest } = parseSingleAgentManifest(content);
|
||||
@@ -154,7 +165,7 @@ export async function runAgentImport(
|
||||
};
|
||||
agentCount = pkg.agents.length;
|
||||
teamCount = 0;
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
|
||||
} else {
|
||||
throw new Error(UNSUPPORTED_FORMAT_MESSAGE);
|
||||
}
|
||||
@@ -189,19 +200,40 @@ export async function runAgentImport(
|
||||
// Create agents
|
||||
const created: string[] = [];
|
||||
const errors: Array<{ name: string; error: string }> = [...result.errors];
|
||||
const createdAgentIdsByManifestKey = new Map<string, string>();
|
||||
|
||||
for (const input of inputs) {
|
||||
for (const item of importItems) {
|
||||
try {
|
||||
// Double-check for duplicates if not using skipExisting
|
||||
if (!skipExisting && existingNames.has(input.name)) {
|
||||
errors.push({ name: input.name, error: "Agent with this name already exists" });
|
||||
if (!skipExisting && existingNames.has(item.input.name)) {
|
||||
errors.push({ name: item.input.name, error: "Agent with this name already exists" });
|
||||
continue;
|
||||
}
|
||||
|
||||
await agentStore.createAgent(input);
|
||||
const input: AgentCreateInput = {
|
||||
...item.input,
|
||||
...(item.input.metadata ? { metadata: { ...item.input.metadata } } : {}),
|
||||
};
|
||||
|
||||
if (item.reportsTo?.deferredManifestKey) {
|
||||
const resolvedReportsTo = createdAgentIdsByManifestKey.get(item.reportsTo.deferredManifestKey);
|
||||
if (!resolvedReportsTo) {
|
||||
errors.push({
|
||||
name: item.input.name,
|
||||
error: `Could not resolve reportsTo reference "${item.reportsTo.raw}" because the manager was not created`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
input.reportsTo = resolvedReportsTo;
|
||||
} else if (item.reportsTo?.resolvedAgentId) {
|
||||
input.reportsTo = item.reportsTo.resolvedAgentId;
|
||||
}
|
||||
|
||||
const agent = await agentStore.createAgent(input);
|
||||
created.push(input.name);
|
||||
createdAgentIdsByManifestKey.set(item.manifestKey, agent.id);
|
||||
} catch (err) {
|
||||
errors.push({ name: input.name, error: (err as Error).message });
|
||||
errors.push({ name: item.input.name, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,10 +180,10 @@ function createUniqueManifestKey(baseKey: string, usedKeys: Set<string>): string
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function topologicallySortImportPlanItems(
|
||||
items: PreparedAgentCompaniesImportItem[],
|
||||
function topologicallySortImportPlanItems<T extends PreparedAgentCompaniesImportItem>(
|
||||
items: T[],
|
||||
): {
|
||||
orderedItems: PreparedAgentCompaniesImportItem[];
|
||||
orderedItems: T[];
|
||||
cycleErrors: Array<{ name: string; error: string }>;
|
||||
} {
|
||||
const byKey = new Map(items.map((item) => [item.manifestKey, item]));
|
||||
@@ -209,7 +209,7 @@ function topologicallySortImportPlanItems(
|
||||
const ready = items
|
||||
.filter((item) => (indegree.get(item.manifestKey) ?? 0) === 0)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
const orderedItems: PreparedAgentCompaniesImportItem[] = [];
|
||||
const orderedItems: T[] = [];
|
||||
|
||||
while (ready.length > 0) {
|
||||
const current = ready.shift();
|
||||
|
||||
Reference in New Issue
Block a user