fix(subagents): handle CRLF/BOM frontmatter for custom agent files (#1424)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -1,5 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getAllSubagentConfigs } from "../../agent/subagents";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearSubagentConfigCache,
|
||||
getAllSubagentConfigs,
|
||||
} from "../../agent/subagents";
|
||||
|
||||
let tempDir: string | null = null;
|
||||
|
||||
function createTempProjectDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "letta-subagents-test-"));
|
||||
}
|
||||
|
||||
function writeCustomSubagent(
|
||||
projectDir: string,
|
||||
fileName: string,
|
||||
content: string,
|
||||
) {
|
||||
const agentsDir = join(projectDir, ".letta", "agents");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
writeFileSync(join(agentsDir, fileName), content, "utf-8");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearSubagentConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearSubagentConfigCache();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("built-in subagents", () => {
|
||||
test("includes reflection subagent in available configs", async () => {
|
||||
@@ -15,4 +49,68 @@ describe("built-in subagents", () => {
|
||||
expect(configs["general-purpose"]?.mode).toBe("stateful");
|
||||
expect(configs.memory?.mode).toBe("stateful");
|
||||
});
|
||||
|
||||
test("custom CRLF reflection override replaces built-in reflection", async () => {
|
||||
tempDir = createTempProjectDir();
|
||||
writeCustomSubagent(
|
||||
tempDir,
|
||||
"reflection.md",
|
||||
[
|
||||
"---",
|
||||
"name: reflection",
|
||||
"description: Custom reflection override",
|
||||
"tools: Read",
|
||||
"model: zaisigno/glm-5",
|
||||
"memoryBlocks: none",
|
||||
"---",
|
||||
"Custom prompt body",
|
||||
].join("\r\n"),
|
||||
);
|
||||
|
||||
const configs = await getAllSubagentConfigs(tempDir);
|
||||
expect(configs.reflection).toBeDefined();
|
||||
expect(configs.reflection?.description).toBe("Custom reflection override");
|
||||
expect(configs.reflection?.recommendedModel).toBe("zaisigno/glm-5");
|
||||
});
|
||||
|
||||
test("blank model field falls back to inherit", async () => {
|
||||
tempDir = createTempProjectDir();
|
||||
writeCustomSubagent(
|
||||
tempDir,
|
||||
"reflection.md",
|
||||
`---
|
||||
name: reflection
|
||||
description: Custom reflection override
|
||||
tools: Read
|
||||
model:
|
||||
memoryBlocks: none
|
||||
---
|
||||
Custom prompt body`,
|
||||
);
|
||||
|
||||
const configs = await getAllSubagentConfigs(tempDir);
|
||||
expect(configs.reflection).toBeDefined();
|
||||
expect(configs.reflection?.recommendedModel).toBe("inherit");
|
||||
});
|
||||
|
||||
test("frontmatter name remains override key (filename can differ)", async () => {
|
||||
tempDir = createTempProjectDir();
|
||||
writeCustomSubagent(
|
||||
tempDir,
|
||||
"reflector.md",
|
||||
`---
|
||||
name: reflection
|
||||
description: Custom reflection override from different filename
|
||||
tools: Read
|
||||
memoryBlocks: none
|
||||
---
|
||||
Custom prompt body`,
|
||||
);
|
||||
|
||||
const configs = await getAllSubagentConfigs(tempDir);
|
||||
expect(configs.reflection).toBeDefined();
|
||||
expect(configs.reflection?.description).toBe(
|
||||
"Custom reflection override from different filename",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
41
src/tests/utils/frontmatter.test.ts
Normal file
41
src/tests/utils/frontmatter.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseFrontmatter } from "../../utils/frontmatter";
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
test("parses LF frontmatter", () => {
|
||||
const content = `---
|
||||
name: reflection
|
||||
description: custom reflection
|
||||
---
|
||||
Prompt body`;
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter.name).toBe("reflection");
|
||||
expect(frontmatter.description).toBe("custom reflection");
|
||||
expect(body).toBe("Prompt body");
|
||||
});
|
||||
|
||||
test("parses CRLF frontmatter", () => {
|
||||
const content =
|
||||
"---\r\nname: reflection\r\ndescription: custom reflection\r\n---\r\nPrompt body\r\nLine 2";
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter.name).toBe("reflection");
|
||||
expect(frontmatter.description).toBe("custom reflection");
|
||||
expect(body).toBe("Prompt body\nLine 2");
|
||||
expect(body.includes("\r")).toBe(false);
|
||||
});
|
||||
|
||||
test("parses BOM + CRLF frontmatter", () => {
|
||||
const content =
|
||||
"\uFEFF---\r\nname: reflection\r\ndescription: custom reflection\r\n---\r\nPrompt body";
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter.name).toBe("reflection");
|
||||
expect(frontmatter.description).toBe("custom reflection");
|
||||
expect(body).toBe("Prompt body");
|
||||
});
|
||||
});
|
||||
@@ -31,11 +31,20 @@ export function parseFrontmatter(content: string): {
|
||||
frontmatter: Record<string, string | string[]>;
|
||||
body: string;
|
||||
} {
|
||||
// Normalize common cross-platform file encodings so frontmatter parsing
|
||||
// works for user-authored files in .letta/agents/.
|
||||
// - Strip UTF-8 BOM when present
|
||||
// - Normalize CRLF (and lone CR) to LF
|
||||
const normalized = content
|
||||
.replace(/^\uFEFF/, "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
const match = normalized.match(frontmatterRegex);
|
||||
|
||||
if (!match || !match[1] || !match[2]) {
|
||||
return { frontmatter: {}, body: content };
|
||||
return { frontmatter: {}, body: normalized };
|
||||
}
|
||||
|
||||
const frontmatterText = match[1];
|
||||
|
||||
Reference in New Issue
Block a user