feat: add client side skills (#1320)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
88
src/tests/agent/clientSkills.test.ts
Normal file
88
src/tests/agent/clientSkills.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
src/tests/agent/message-client-skills.test.ts
Normal file
35
src/tests/agent/message-client-skills.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user