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:
Fusion
2026-05-06 21:25:59 -07:00
committed by gsxdsm
parent 46f3ac2114
commit fca8d27342
10 changed files with 125 additions and 1 deletions

View 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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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");

View File

@@ -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",

View File

@@ -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 ?? "");

View File

@@ -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 } : {}),
};
}

View File

@@ -57,6 +57,7 @@ export interface AgentManifest extends AgentCompaniesFrontmatter {
reportsTo?: string | null;
skills?: string[];
instructionBody?: string;
memory?: string;
}
export type ProjectManifest = AgentCompaniesFrontmatter;

View File

@@ -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: [{

View File

@@ -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,