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 |
|
| `instructionsPath` | ✓ | ✓ | ✗ | File-backed instructions path |
|
||||||
| `instructionsText` | ✓ | ✓ | ✓ (from manifest `instructionBody`) | Inline instructions |
|
| `instructionsText` | ✓ | ✓ | ✓ (from manifest `instructionBody`) | Inline instructions |
|
||||||
| `soul` | ✓ | ✓ | ✗ | Personality/identity description |
|
| `soul` | ✓ | ✓ | ✗ | Personality/identity description |
|
||||||
| `memory` | ✓ | ✓ | ✗ | Per-agent accumulated knowledge |
|
| `memory` | ✓ | ✓ | ✓ (from manifest) | Per-agent accumulated knowledge |
|
||||||
| `bundleConfig` | ✓ | ✓ | ✗ | Structured instruction bundle |
|
| `bundleConfig` | ✓ | ✓ | ✗ | Structured instruction bundle |
|
||||||
|
|
||||||
### Agent Companies Manifest Fields
|
### 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` |
|
| `role` | `role` (mapped to AgentCapability) | `custom` |
|
||||||
| `reportsTo` | `reportsTo` | — |
|
| `reportsTo` | `reportsTo` | — |
|
||||||
| `instructionBody` | `instructionsText` | — |
|
| `instructionBody` | `instructionsText` | — |
|
||||||
|
| `memory` | `memory` | — |
|
||||||
| `skills` | `metadata.skills` | — |
|
| `skills` | `metadata.skills` | — |
|
||||||
|
|
||||||
### System-Managed Fields (Not User-Editable)
|
### System-Managed Fields (Not User-Editable)
|
||||||
|
|||||||
@@ -653,6 +653,7 @@ Export Fusion agents to an Agent Companies package directory.
|
|||||||
**Behavior notes:**
|
**Behavior notes:**
|
||||||
- Usage: `fn agent export <dir> [--company-name <name>] [--company-slug <slug>]`.
|
- 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`.
|
- 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).
|
- 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.
|
- 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:**
|
**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.
|
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:**
|
**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.
|
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
|
...(overrides.instructionsText !== undefined
|
||||||
? { instructionsText: overrides.instructionsText }
|
? { instructionsText: overrides.instructionsText }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(overrides.memory !== undefined ? { memory: overrides.memory } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ describe("agent-companies-exporter", () => {
|
|||||||
role: "reviewer",
|
role: "reviewer",
|
||||||
reportsTo: "agent-root",
|
reportsTo: "agent-root",
|
||||||
instructionsText: "Lead strategy and review architecture.",
|
instructionsText: "Lead strategy and review architecture.",
|
||||||
|
memory: "Prefer concise updates and explicit risk tracking.",
|
||||||
metadata: {
|
metadata: {
|
||||||
description: "Company lead",
|
description: "Company lead",
|
||||||
skills: ["review", { name: "architecture" }],
|
skills: ["review", { name: "architecture" }],
|
||||||
@@ -78,6 +80,7 @@ describe("agent-companies-exporter", () => {
|
|||||||
description: "Company lead",
|
description: "Company lead",
|
||||||
schema: "agentcompanies/v1",
|
schema: "agentcompanies/v1",
|
||||||
instructionBody: "Lead strategy and review architecture.",
|
instructionBody: "Lead strategy and review architecture.",
|
||||||
|
memory: "Prefer concise updates and explicit risk tracking.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +109,7 @@ describe("agent-companies-exporter", () => {
|
|||||||
icon: "shield",
|
icon: "shield",
|
||||||
role: "reviewer",
|
role: "reviewer",
|
||||||
instructionsText: "Always verify tests and edge-cases.",
|
instructionsText: "Always verify tests and edge-cases.",
|
||||||
|
memory: "Track flaky-test patterns across reviews.",
|
||||||
metadata: {
|
metadata: {
|
||||||
description: "Ensures quality",
|
description: "Ensures quality",
|
||||||
skills: ["qa"],
|
skills: ["qa"],
|
||||||
@@ -123,6 +127,7 @@ describe("agent-companies-exporter", () => {
|
|||||||
skills: ["qa"],
|
skills: ["qa"],
|
||||||
description: "Ensures quality",
|
description: "Ensures quality",
|
||||||
schema: "agentcompanies/v1",
|
schema: "agentcompanies/v1",
|
||||||
|
memory: "Track flaky-test patterns across reviews.",
|
||||||
});
|
});
|
||||||
expect(parsed.body).toBe("Always verify tests and edge-cases.");
|
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"));
|
const reviewerManifest = parseYamlFrontmatter(readFileSync(reviewerPath, "utf-8"));
|
||||||
expect(reviewerManifest.frontmatter.reportsTo).toBe("../ceo/AGENTS.md");
|
expect(reviewerManifest.frontmatter.reportsTo).toBe("../ceo/AGENTS.md");
|
||||||
|
expect(reviewerManifest.frontmatter.memory).toBeUndefined();
|
||||||
|
|
||||||
expect(readFileSync(strategySkillPath, "utf-8")).toContain("kind: skill");
|
expect(readFileSync(strategySkillPath, "utf-8")).toContain("kind: skill");
|
||||||
expect(result.filesWritten).toEqual(
|
expect(result.filesWritten).toEqual(
|
||||||
@@ -208,6 +214,7 @@ describe("agent-companies-exporter", () => {
|
|||||||
readFileSync(join(outputDir, "agents", "solo", "AGENTS.md"), "utf-8"),
|
readFileSync(join(outputDir, "agents", "solo", "AGENTS.md"), "utf-8"),
|
||||||
);
|
);
|
||||||
expect(parsed.frontmatter.reportsTo).toBeNull();
|
expect(parsed.frontmatter.reportsTo).toBeNull();
|
||||||
|
expect(parsed.frontmatter.memory).toBeUndefined();
|
||||||
expect(parsed.frontmatter.skills).toEqual([]);
|
expect(parsed.frontmatter.skills).toEqual([]);
|
||||||
expect(parsed.body).toBe("");
|
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 () => {
|
it("captures per-agent write errors", async () => {
|
||||||
const outputDir = createTempDir();
|
const outputDir = createTempDir();
|
||||||
const conflictPath = join(outputDir, "agents", "ceo");
|
const conflictPath = join(outputDir, "agents", "ceo");
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ reportsTo: null
|
|||||||
skills:
|
skills:
|
||||||
- plan-ceo-review
|
- plan-ceo-review
|
||||||
- review
|
- review
|
||||||
|
memory: Preserve architecture rationale between incidents.
|
||||||
---
|
---
|
||||||
Agent instructions.`);
|
Agent instructions.`);
|
||||||
|
|
||||||
@@ -190,6 +191,7 @@ Agent instructions.`);
|
|||||||
expect(manifest.title).toBe("Chief Executive Officer");
|
expect(manifest.title).toBe("Chief Executive Officer");
|
||||||
expect(manifest.reportsTo).toBeNull();
|
expect(manifest.reportsTo).toBeNull();
|
||||||
expect(manifest.skills).toEqual(["plan-ceo-review", "review"]);
|
expect(manifest.skills).toEqual(["plan-ceo-review", "review"]);
|
||||||
|
expect(manifest.memory).toBe("Preserve architecture rationale between incidents.");
|
||||||
expect(manifest.instructionBody).toBe("Agent instructions.");
|
expect(manifest.instructionBody).toBe("Agent instructions.");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -517,6 +519,7 @@ name: Nested Archive CEO
|
|||||||
name: "CEO",
|
name: "CEO",
|
||||||
title: "Chief Executive Officer",
|
title: "Chief Executive Officer",
|
||||||
instructionBody: "Lead strategy",
|
instructionBody: "Lead strategy",
|
||||||
|
memory: "Track strategic assumptions each quarter.",
|
||||||
skills: ["review"],
|
skills: ["review"],
|
||||||
reportsTo: null,
|
reportsTo: null,
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -529,6 +532,7 @@ name: Nested Archive CEO
|
|||||||
role: "custom",
|
role: "custom",
|
||||||
title: "Chief Executive Officer",
|
title: "Chief Executive Officer",
|
||||||
instructionsText: "Lead strategy",
|
instructionsText: "Lead strategy",
|
||||||
|
memory: "Track strategic assumptions each quarter.",
|
||||||
metadata: {
|
metadata: {
|
||||||
skills: ["review"],
|
skills: ["review"],
|
||||||
sources: [{ kind: "git", repo: "acme/repo" }],
|
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", () => {
|
it("maps manifest role to first-class field", () => {
|
||||||
const input = agentManifestToAgentCreateInput({
|
const input = agentManifestToAgentCreateInput({
|
||||||
name: "Reviewer",
|
name: "Reviewer",
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function agentToCompaniesManifest(
|
|||||||
description,
|
description,
|
||||||
schema: "agentcompanies/v1",
|
schema: "agentcompanies/v1",
|
||||||
instructionBody: trimToUndefined(agent.instructionsText) ?? "",
|
instructionBody: trimToUndefined(agent.instructionsText) ?? "",
|
||||||
|
memory: trimToUndefined(agent.memory),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ export function generateAgentMd(agent: Agent): string {
|
|||||||
skills: manifest.skills,
|
skills: manifest.skills,
|
||||||
description: manifest.description,
|
description: manifest.description,
|
||||||
schema: manifest.schema,
|
schema: manifest.schema,
|
||||||
|
memory: manifest.memory,
|
||||||
};
|
};
|
||||||
|
|
||||||
return toFrontmatterMarkdown(frontmatter, manifest.instructionBody ?? "");
|
return toFrontmatterMarkdown(frontmatter, manifest.instructionBody ?? "");
|
||||||
@@ -274,6 +276,7 @@ export async function exportAgentsToDirectory(
|
|||||||
skills: manifest.skills,
|
skills: manifest.skills,
|
||||||
description: manifest.description,
|
description: manifest.description,
|
||||||
schema: manifest.schema,
|
schema: manifest.schema,
|
||||||
|
memory: manifest.memory,
|
||||||
};
|
};
|
||||||
const content = toFrontmatterMarkdown(frontmatter, manifest.instructionBody ?? "");
|
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
|
...(typeof agent.instructionBody === "string" && agent.instructionBody.trim().length > 0
|
||||||
? { instructionsText: agent.instructionBody.trim() }
|
? { instructionsText: agent.instructionBody.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof agent.memory === "string" && agent.memory.trim().length > 0
|
||||||
|
? { memory: agent.memory.trim() }
|
||||||
|
: {}),
|
||||||
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface AgentManifest extends AgentCompaniesFrontmatter {
|
|||||||
reportsTo?: string | null;
|
reportsTo?: string | null;
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
instructionBody?: string;
|
instructionBody?: string;
|
||||||
|
memory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectManifest = AgentCompaniesFrontmatter;
|
export type ProjectManifest = AgentCompaniesFrontmatter;
|
||||||
|
|||||||
@@ -366,6 +366,70 @@ describe("POST /api/agents/import", () => {
|
|||||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
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 () => {
|
it("maps store-level duplicate errors to skipped results", async () => {
|
||||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||||
items: [{
|
items: [{
|
||||||
|
|||||||
@@ -690,6 +690,9 @@ async function persistImportedSkills(
|
|||||||
instructionsText: typeof item.input.instructionsText === "string"
|
instructionsText: typeof item.input.instructionsText === "string"
|
||||||
? item.input.instructionsText.slice(0, 200) + (item.input.instructionsText.length > 200 ? "..." : "")
|
? item.input.instructionsText.slice(0, 200) + (item.input.instructionsText.length > 200 ? "..." : "")
|
||||||
: undefined,
|
: 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)
|
skills: Array.isArray(item.input.metadata?.skills)
|
||||||
? item.input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
|
? item.input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user