From ef7defe5d9cb73dba03c5f0d0bc1ec7c158e5191 Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:07:59 -0700 Subject: [PATCH] fix(subagents): handle CRLF/BOM frontmatter for custom agent files (#1424) Co-authored-by: Letta Code --- src/tests/agent/subagent-builtins.test.ts | 102 +++++++++++++++++++++- src/tests/utils/frontmatter.test.ts | 41 +++++++++ src/utils/frontmatter.ts | 13 ++- 3 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 src/tests/utils/frontmatter.test.ts diff --git a/src/tests/agent/subagent-builtins.test.ts b/src/tests/agent/subagent-builtins.test.ts index 24444bf..87a030b 100644 --- a/src/tests/agent/subagent-builtins.test.ts +++ b/src/tests/agent/subagent-builtins.test.ts @@ -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", + ); + }); }); diff --git a/src/tests/utils/frontmatter.test.ts b/src/tests/utils/frontmatter.test.ts new file mode 100644 index 0000000..df4854c --- /dev/null +++ b/src/tests/utils/frontmatter.test.ts @@ -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"); + }); +}); diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index bfb040a..d7f1f9a 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -31,11 +31,20 @@ export function parseFrontmatter(content: string): { frontmatter: Record; 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];