feat(FN-3619): preserve agent memory across import/export flows
- Extend agent company manifest contract to include memory payloads during export and parse - Align dashboard agent import/export route generation to pass memory through unchanged - Add parser, exporter, and route-level tests covering memory passthrough behavior - Document memory import/export parity in agent and CLI documentation Ref: Runfusion/Fusion#53 Fusion-Task-Id: FN-3619
This commit is contained in:
5
.changeset/fn-3619-agent-memory-import-export.md
Normal file
5
.changeset/fn-3619-agent-memory-import-export.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Preserve agent inline memory in Agent Companies import/export flows so AGENTS manifests round-trip memory without loss.
|
||||
@@ -24,7 +24,7 @@ Every first-class editable agent field has a defined create/edit/import/template
|
||||
| `instructionsPath` | ✓ | ✓ | ✗ | File-backed instructions path |
|
||||
| `instructionsText` | ✓ | ✓ | ✓ (from manifest `instructionBody`) | Inline instructions |
|
||||
| `soul` | ✓ | ✓ | ✗ | Personality/identity description |
|
||||
| `memory` | ✓ | ✓ | ✗ | Per-agent accumulated knowledge |
|
||||
| `memory` | ✓ | ✓ | ✓ (from manifest) | Per-agent accumulated knowledge |
|
||||
| `bundleConfig` | ✓ | ✓ | ✗ | Structured instruction bundle |
|
||||
|
||||
### Agent Companies Manifest Fields
|
||||
@@ -37,6 +37,7 @@ Every first-class editable agent field has a defined create/edit/import/template
|
||||
| `role` | `role` (mapped to AgentCapability) | `custom` |
|
||||
| `reportsTo` | `reportsTo` | — |
|
||||
| `instructionBody` | `instructionsText` | — |
|
||||
| `memory` | `memory` | — |
|
||||
| `skills` | `metadata.skills` | — |
|
||||
|
||||
### System-Managed Fields (Not User-Editable)
|
||||
|
||||
@@ -653,6 +653,7 @@ Export Fusion agents to an Agent Companies package directory.
|
||||
**Behavior notes:**
|
||||
- Usage: `fn agent export <dir> [--company-name <name>] [--company-slug <slug>]`.
|
||||
- If no agents exist in the selected project, the command exits with `No agents found to export`.
|
||||
- Exported `AGENTS.md` manifests include inline `memory` for each agent so memory round-trips across package export/import.
|
||||
- Successful runs print a summary including output directory, agents exported, skills exported, files written, and per-agent errors (if any).
|
||||
- Output directory paths are resolved to absolute paths before export.
|
||||
|
||||
@@ -681,6 +682,9 @@ Import agents from [companies.sh](https://companies.sh) packages. Supports singl
|
||||
**Team hierarchy:**
|
||||
When importing a companies.sh package with team structure, the importer preserves manager/report relationships for both fresh and partial imports. Manifest-style manager references such as `ceo`, `../ceo/AGENTS.md`, and already-valid Fusion agent IDs are resolved to actual Fusion `reportsTo` agent IDs before agents are created, and `--skip-existing` reuses matching existing managers when available instead of flattening the org tree.
|
||||
|
||||
**Memory import/export parity:**
|
||||
Manifest-provided inline `memory` is preserved during `fn agent import` (including `--dry-run` previews) and restored onto created agents, matching export behavior so operator-authored memory is not dropped.
|
||||
|
||||
**Skill imports:**
|
||||
When importing from a package directory or archive, the importer also imports any package skill manifests (`skills/*/SKILL.md`). Skills are written to `{project}/skills/imported/{company-slug}/{skill-slug}/SKILL.md`. Existing skill files at the target path are skipped (not overwritten). Single `AGENTS.md` file imports do not include package skills.
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
...(overrides.instructionsText !== undefined
|
||||
? { instructionsText: overrides.instructionsText }
|
||||
: {}),
|
||||
...(overrides.memory !== undefined ? { memory: overrides.memory } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +61,7 @@ describe("agent-companies-exporter", () => {
|
||||
role: "reviewer",
|
||||
reportsTo: "agent-root",
|
||||
instructionsText: "Lead strategy and review architecture.",
|
||||
memory: "Prefer concise updates and explicit risk tracking.",
|
||||
metadata: {
|
||||
description: "Company lead",
|
||||
skills: ["review", { name: "architecture" }],
|
||||
@@ -78,6 +80,7 @@ describe("agent-companies-exporter", () => {
|
||||
description: "Company lead",
|
||||
schema: "agentcompanies/v1",
|
||||
instructionBody: "Lead strategy and review architecture.",
|
||||
memory: "Prefer concise updates and explicit risk tracking.",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +109,7 @@ describe("agent-companies-exporter", () => {
|
||||
icon: "shield",
|
||||
role: "reviewer",
|
||||
instructionsText: "Always verify tests and edge-cases.",
|
||||
memory: "Track flaky-test patterns across reviews.",
|
||||
metadata: {
|
||||
description: "Ensures quality",
|
||||
skills: ["qa"],
|
||||
@@ -123,6 +127,7 @@ describe("agent-companies-exporter", () => {
|
||||
skills: ["qa"],
|
||||
description: "Ensures quality",
|
||||
schema: "agentcompanies/v1",
|
||||
memory: "Track flaky-test patterns across reviews.",
|
||||
});
|
||||
expect(parsed.body).toBe("Always verify tests and edge-cases.");
|
||||
});
|
||||
@@ -168,6 +173,7 @@ describe("agent-companies-exporter", () => {
|
||||
|
||||
const reviewerManifest = parseYamlFrontmatter(readFileSync(reviewerPath, "utf-8"));
|
||||
expect(reviewerManifest.frontmatter.reportsTo).toBe("../ceo/AGENTS.md");
|
||||
expect(reviewerManifest.frontmatter.memory).toBeUndefined();
|
||||
|
||||
expect(readFileSync(strategySkillPath, "utf-8")).toContain("kind: skill");
|
||||
expect(result.filesWritten).toEqual(
|
||||
@@ -208,6 +214,7 @@ describe("agent-companies-exporter", () => {
|
||||
readFileSync(join(outputDir, "agents", "solo", "AGENTS.md"), "utf-8"),
|
||||
);
|
||||
expect(parsed.frontmatter.reportsTo).toBeNull();
|
||||
expect(parsed.frontmatter.memory).toBeUndefined();
|
||||
expect(parsed.frontmatter.skills).toEqual([]);
|
||||
expect(parsed.body).toBe("");
|
||||
});
|
||||
@@ -231,6 +238,22 @@ describe("agent-companies-exporter", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("exports memory in AGENTS.md frontmatter when present", async () => {
|
||||
const outputDir = createTempDir();
|
||||
const agent = makeAgent({
|
||||
id: "agent-memory",
|
||||
name: "Memory Keeper",
|
||||
memory: "Remember to include rollback plans for risky changes.",
|
||||
});
|
||||
|
||||
await exportAgentsToDirectory([agent], outputDir);
|
||||
|
||||
const parsed = parseYamlFrontmatter(
|
||||
readFileSync(join(outputDir, "agents", "memory-keeper", "AGENTS.md"), "utf-8"),
|
||||
);
|
||||
expect(parsed.frontmatter.memory).toBe("Remember to include rollback plans for risky changes.");
|
||||
});
|
||||
|
||||
it("captures per-agent write errors", async () => {
|
||||
const outputDir = createTempDir();
|
||||
const conflictPath = join(outputDir, "agents", "ceo");
|
||||
|
||||
@@ -183,6 +183,7 @@ reportsTo: null
|
||||
skills:
|
||||
- plan-ceo-review
|
||||
- review
|
||||
memory: Preserve architecture rationale between incidents.
|
||||
---
|
||||
Agent instructions.`);
|
||||
|
||||
@@ -190,6 +191,7 @@ Agent instructions.`);
|
||||
expect(manifest.title).toBe("Chief Executive Officer");
|
||||
expect(manifest.reportsTo).toBeNull();
|
||||
expect(manifest.skills).toEqual(["plan-ceo-review", "review"]);
|
||||
expect(manifest.memory).toBe("Preserve architecture rationale between incidents.");
|
||||
expect(manifest.instructionBody).toBe("Agent instructions.");
|
||||
});
|
||||
|
||||
@@ -517,6 +519,7 @@ name: Nested Archive CEO
|
||||
name: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
instructionBody: "Lead strategy",
|
||||
memory: "Track strategic assumptions each quarter.",
|
||||
skills: ["review"],
|
||||
reportsTo: null,
|
||||
metadata: {
|
||||
@@ -529,6 +532,7 @@ name: Nested Archive CEO
|
||||
role: "custom",
|
||||
title: "Chief Executive Officer",
|
||||
instructionsText: "Lead strategy",
|
||||
memory: "Track strategic assumptions each quarter.",
|
||||
metadata: {
|
||||
skills: ["review"],
|
||||
sources: [{ kind: "git", repo: "acme/repo" }],
|
||||
@@ -679,6 +683,19 @@ name: Nested Archive CEO
|
||||
});
|
||||
});
|
||||
|
||||
it("maps manifest memory to first-class field", () => {
|
||||
const input = agentManifestToAgentCreateInput({
|
||||
name: "Researcher",
|
||||
memory: "Keep a running list of unresolved assumptions.",
|
||||
});
|
||||
|
||||
expect(input).toEqual({
|
||||
name: "Researcher",
|
||||
role: "custom",
|
||||
memory: "Keep a running list of unresolved assumptions.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps manifest role to first-class field", () => {
|
||||
const input = agentManifestToAgentCreateInput({
|
||||
name: "Reviewer",
|
||||
|
||||
@@ -119,6 +119,7 @@ export function agentToCompaniesManifest(
|
||||
description,
|
||||
schema: "agentcompanies/v1",
|
||||
instructionBody: trimToUndefined(agent.instructionsText) ?? "",
|
||||
memory: trimToUndefined(agent.memory),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +164,7 @@ export function generateAgentMd(agent: Agent): string {
|
||||
skills: manifest.skills,
|
||||
description: manifest.description,
|
||||
schema: manifest.schema,
|
||||
memory: manifest.memory,
|
||||
};
|
||||
|
||||
return toFrontmatterMarkdown(frontmatter, manifest.instructionBody ?? "");
|
||||
@@ -274,6 +276,7 @@ export async function exportAgentsToDirectory(
|
||||
skills: manifest.skills,
|
||||
description: manifest.description,
|
||||
schema: manifest.schema,
|
||||
memory: manifest.memory,
|
||||
};
|
||||
const content = toFrontmatterMarkdown(frontmatter, manifest.instructionBody ?? "");
|
||||
|
||||
|
||||
@@ -546,6 +546,9 @@ export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCrea
|
||||
...(typeof agent.instructionBody === "string" && agent.instructionBody.trim().length > 0
|
||||
? { instructionsText: agent.instructionBody.trim() }
|
||||
: {}),
|
||||
...(typeof agent.memory === "string" && agent.memory.trim().length > 0
|
||||
? { memory: agent.memory.trim() }
|
||||
: {}),
|
||||
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface AgentManifest extends AgentCompaniesFrontmatter {
|
||||
reportsTo?: string | null;
|
||||
skills?: string[];
|
||||
instructionBody?: string;
|
||||
memory?: string;
|
||||
}
|
||||
|
||||
export type ProjectManifest = AgentCompaniesFrontmatter;
|
||||
|
||||
@@ -366,6 +366,70 @@ describe("POST /api/agents/import", () => {
|
||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes manifest memory in dry-run preview", async () => {
|
||||
const memory = "Capture operational constraints and open risks before each handoff.";
|
||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||
items: [{
|
||||
manifestKey: "memory-preview-agent",
|
||||
aliases: ["memory-preview-agent"],
|
||||
index: 0,
|
||||
input: { name: "Memory Preview Agent", role: "custom", memory },
|
||||
}],
|
||||
result: {
|
||||
created: ["Memory Preview Agent"],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await postImport(app, {
|
||||
manifest: "---\nname: Memory Preview Agent\nmemory: Capture operational constraints and open risks before each handoff.\n---\nInstructions",
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = response.body as any;
|
||||
expect(body.agents).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Memory Preview Agent",
|
||||
memory,
|
||||
}),
|
||||
]);
|
||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves manifest memory on live import", async () => {
|
||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||
items: [{
|
||||
manifestKey: "memory-agent",
|
||||
aliases: ["memory-agent"],
|
||||
index: 0,
|
||||
input: {
|
||||
name: "Memory Agent",
|
||||
role: "custom",
|
||||
memory: "Capture failed deployment patterns and mitigations.",
|
||||
},
|
||||
}],
|
||||
result: {
|
||||
created: ["Memory Agent"],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await postImport(app, {
|
||||
manifest: "---\nname: Memory Agent\nmemory: Capture failed deployment patterns and mitigations.\n---\nInstructions",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCreateAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Memory Agent",
|
||||
memory: "Capture failed deployment patterns and mitigations.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps store-level duplicate errors to skipped results", async () => {
|
||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||
items: [{
|
||||
|
||||
@@ -690,6 +690,9 @@ async function persistImportedSkills(
|
||||
instructionsText: typeof item.input.instructionsText === "string"
|
||||
? item.input.instructionsText.slice(0, 200) + (item.input.instructionsText.length > 200 ? "..." : "")
|
||||
: undefined,
|
||||
memory: typeof item.input.memory === "string"
|
||||
? item.input.memory.slice(0, 200) + (item.input.memory.length > 200 ? "..." : "")
|
||||
: undefined,
|
||||
skills: Array.isArray(item.input.metadata?.skills)
|
||||
? item.input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user