feat: add client side skills (#1320)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Sarah Wooders
2026-03-10 13:18:14 -07:00
committed by GitHub
parent 87312720d5
commit e82a2d33f8
25 changed files with 377 additions and 151 deletions

View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import type { Skill, SkillDiscoveryResult } from "../../agent/skills";
const baseSkill: Skill = {
id: "base",
name: "Base",
description: "Base skill",
path: "/tmp/base/SKILL.md",
source: "project",
};
describe("buildClientSkillsPayload", () => {
test("returns deterministically sorted client skills and path map", async () => {
const { buildClientSkillsPayload } = await import(
"../../agent/clientSkills"
);
const discoverSkillsFn = async (): Promise<SkillDiscoveryResult> => ({
skills: [
{
...baseSkill,
id: "z-skill",
description: "z",
path: "/tmp/z/SKILL.md",
source: "project",
},
{
...baseSkill,
id: "a-skill",
description: "a",
path: "/tmp/a/SKILL.md",
source: "bundled",
},
],
errors: [],
});
const result = await buildClientSkillsPayload({
agentId: "agent-1",
skillsDirectory: "/tmp/.skills",
skillSources: ["project", "bundled"],
discoverSkillsFn,
});
expect(result.clientSkills).toEqual([
{
name: "a-skill",
description: "a",
location: "/tmp/a/SKILL.md",
},
{
name: "z-skill",
description: "z",
location: "/tmp/z/SKILL.md",
},
]);
expect(result.skillPathById).toEqual({
"a-skill": "/tmp/a/SKILL.md",
"z-skill": "/tmp/z/SKILL.md",
});
expect(result.errors).toEqual([]);
});
test("fails open with empty client_skills when discovery throws", async () => {
const { buildClientSkillsPayload } = await import(
"../../agent/clientSkills"
);
const discoverSkillsFn = async (): Promise<SkillDiscoveryResult> => {
throw new Error("boom");
};
const logs: string[] = [];
const result = await buildClientSkillsPayload({
skillsDirectory: "/tmp/.skills",
discoverSkillsFn,
logger: (m) => logs.push(m),
});
expect(result.clientSkills).toEqual([]);
expect(result.skillPathById).toEqual({});
expect(result.errors).toHaveLength(1);
expect(result.errors[0]?.path).toBe("/tmp/.skills");
expect(
logs.some((m) => m.includes("Failed to build client_skills payload")),
).toBe(true);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { buildConversationMessagesCreateRequestBody } from "../../agent/message";
describe("buildConversationMessagesCreateRequestBody client_skills", () => {
test("includes client_skills alongside client_tools", () => {
const body = buildConversationMessagesCreateRequestBody(
"default",
[{ type: "message", role: "user", content: "hello" }],
{ agentId: "agent-1", streamTokens: true, background: true },
[
{
name: "ShellCommand",
description: "Run shell command",
parameters: { type: "object", properties: {} },
},
],
[
{
name: "debugging",
description: "Debugging checklist",
location: "/tmp/.skills/debugging/SKILL.md",
},
],
);
expect(body.client_tools).toHaveLength(1);
expect(body.client_skills).toEqual([
{
name: "debugging",
description: "Debugging checklist",
location: "/tmp/.skills/debugging/SKILL.md",
},
]);
});
});

View File

@@ -107,5 +107,24 @@ describe.skipIf(process.platform === "win32")(
result.errors.some((error) => error.path.includes("broken-link")),
).toBe(true);
});
test("returns discovered skills in deterministic sorted order", async () => {
mkdirSync(projectSkillsDir, { recursive: true });
writeSkill(join(projectSkillsDir, "z-skill"), "Z Skill");
writeSkill(join(projectSkillsDir, "a-skill"), "A Skill");
writeSkill(join(projectSkillsDir, "m-skill"), "M Skill");
const result = await discoverSkills(projectSkillsDir, undefined, {
skipBundled: true,
sources: ["project"],
});
expect(result.errors).toHaveLength(0);
expect(result.skills.map((skill) => skill.id)).toEqual([
"a-skill",
"m-skill",
"z-skill",
]);
});
},
);

View File

@@ -66,4 +66,53 @@ describe("Skills formatting (system reminder)", () => {
expect(result).toContain("project-skill (project)");
expect(result).toContain("global-skill (global)");
});
test("sorts skills deterministically before formatting", () => {
const skills: Skill[] = [
{
id: "z-skill",
name: "Z Skill",
description: "Last by id",
path: "/test/.skills/z-skill/SKILL.md",
source: "project",
},
{
id: "a-skill",
name: "A Skill",
description: "First by id",
path: "/test/.skills/a-skill/SKILL.md",
source: "project",
},
{
id: "same-id",
name: "Same Id Global",
description: "Global variant",
path: "/global/.skills/same-id/SKILL.md",
source: "global",
},
{
id: "same-id",
name: "Same Id Project",
description: "Project variant",
path: "/project/.skills/same-id/SKILL.md",
source: "project",
},
];
const result = formatSkillsAsSystemReminder(skills);
const aSkillIndex = result.indexOf("- a-skill (project): First by id");
const sameIdGlobalIndex = result.indexOf(
"- same-id (global): Global variant",
);
const sameIdProjectIndex = result.indexOf(
"- same-id (project): Project variant",
);
const zSkillIndex = result.indexOf("- z-skill (project): Last by id");
expect(aSkillIndex).toBeGreaterThan(-1);
expect(sameIdGlobalIndex).toBeGreaterThan(aSkillIndex);
expect(sameIdProjectIndex).toBeGreaterThan(sameIdGlobalIndex);
expect(zSkillIndex).toBeGreaterThan(sameIdProjectIndex);
});
});