feat: add agent-scoped skills directory support (#692)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-26 17:33:59 -08:00
committed by GitHub
parent 4556bf371a
commit 5776c55728
2 changed files with 84 additions and 19 deletions

View File

@@ -1,10 +1,11 @@
/**
* Skills module - provides skill discovery and management functionality
*
* Skills are discovered from three sources (in order of priority):
* Skills are discovered from four sources (in order of priority):
* 1. Project skills: .skills/ in current directory (highest priority - overrides)
* 2. Global skills: ~/.letta/skills/ for user's personal skills
* 3. Bundled skills: embedded in package (lowest priority - defaults)
* 2. Agent skills: ~/.letta/agents/{agent-id}/skills/ for agent-specific skills
* 3. Global skills: ~/.letta/skills/ for user's personal skills
* 4. Bundled skills: embedded in package (lowest priority - defaults)
*/
import { existsSync } from "node:fs";
@@ -34,7 +35,7 @@ function getBundledSkillsPath(): string {
/**
* Source of a skill (for display and override resolution)
*/
export type SkillSource = "bundled" | "global" | "project";
export type SkillSource = "bundled" | "global" | "agent" | "project";
/**
* Represents a skill that can be used by the agent
@@ -91,6 +92,20 @@ export const GLOBAL_SKILLS_DIR = join(
".letta/skills",
);
/**
* Get the agent-scoped skills directory for a specific agent
* @param agentId - The Letta agent ID (e.g., "agent-abc123")
* @returns Path like ~/.letta/agents/agent-abc123/skills/
*/
export function getAgentSkillsDir(agentId: string): string {
return join(
process.env.HOME || process.env.USERPROFILE || "~",
".letta/agents",
agentId,
"skills",
);
}
/**
* Skills block character limit.
* If formatted skills exceed this, fall back to compact tree format.
@@ -142,19 +157,22 @@ async function discoverSkillsFromDir(
}
/**
* Discovers skills from all sources (bundled, global, project)
* Discovers skills from all sources (bundled, global, agent, project)
* Later sources override earlier ones with the same ID.
*
* Priority order (highest to lowest):
* 1. Project skills (.skills/ in current directory)
* 2. Global skills (~/.letta/skills/)
* 3. Bundled skills (embedded in package)
* 2. Agent skills (~/.letta/agents/{agent-id}/skills/)
* 3. Global skills (~/.letta/skills/)
* 4. Bundled skills (embedded in package)
*
* @param projectSkillsPath - The project skills directory (default: .skills in current directory)
* @param agentId - Optional agent ID for agent-scoped skills
* @returns A result containing discovered skills and any errors
*/
export async function discoverSkills(
projectSkillsPath: string = join(process.cwd(), SKILLS_DIR),
agentId?: string,
): Promise<SkillDiscoveryResult> {
const allErrors: SkillDiscoveryError[] = [];
const skillsById = new Map<string, Skill>();
@@ -172,7 +190,17 @@ export async function discoverSkills(
skillsById.set(skill.id, skill);
}
// 3. Add project skills (override global and bundled)
// 3. Add agent skills if agentId provided (override global)
if (agentId) {
const agentSkillsDir = getAgentSkillsDir(agentId);
const agentResult = await discoverSkillsFromDir(agentSkillsDir, "agent");
allErrors.push(...agentResult.errors);
for (const skill of agentResult.skills) {
skillsById.set(skill.id, skill);
}
}
// 4. Add project skills (override all - highest priority)
const projectResult = await discoverSkillsFromDir(
projectSkillsPath,
"project",
@@ -390,14 +418,20 @@ function formatSkillsAsTree(skills: Skill[], skillsDirectory: string): string {
* Formats discovered skills with full metadata
* @param skills - Array of discovered skills
* @param skillsDirectory - Absolute path to the skills directory
* @param agentId - Optional agent ID for agent-scoped skills display
* @returns Full metadata string representation
*/
function formatSkillsWithMetadata(
skills: Skill[],
skillsDirectory: string,
agentId?: string,
): string {
let output = `Skills Directory: ${skillsDirectory}\n`;
output += `Global Skills Directory: ${GLOBAL_SKILLS_DIR}\n\n`;
output += `Global Skills Directory: ${GLOBAL_SKILLS_DIR}\n`;
if (agentId) {
output += `Agent Skills Directory: ${getAgentSkillsDir(agentId)}\n`;
}
output += "\n";
if (skills.length === 0) {
return `${output}[NO SKILLS AVAILABLE]`;
@@ -405,7 +439,7 @@ function formatSkillsWithMetadata(
output += "Available Skills:\n";
output +=
"(source: bundled = built-in to Letta Code, global = ~/.letta/skills/, project = .skills/)\n\n";
"(source: bundled = built-in to Letta Code, global = shared across all agents on this machine (~/.letta/skills/), agent = skills specific to you (~/.letta/agents/{id}/skills/), project = current project only (.skills/))\n\n";
// Group skills by category if categories exist
const categorized = new Map<string, Skill[]>();
@@ -464,11 +498,13 @@ function formatSkill(skill: Skill): string {
* Tries full metadata format first, falls back to compact tree if it exceeds limit.
* @param skills - Array of discovered skills
* @param skillsDirectory - Absolute path to the skills directory
* @param agentId - Optional agent ID for agent-scoped skills display
* @returns Formatted string representation of skills
*/
export function formatSkillsForMemory(
skills: Skill[],
skillsDirectory: string,
agentId?: string,
): string {
// Handle empty case
if (skills.length === 0) {
@@ -476,7 +512,7 @@ export function formatSkillsForMemory(
}
// Try full metadata format first
const fullFormat = formatSkillsWithMetadata(skills, skillsDirectory);
const fullFormat = formatSkillsWithMetadata(skills, skillsDirectory, agentId);
// If within limit, use full format
if (fullFormat.length <= SKILLS_BLOCK_CHAR_LIMIT) {
@@ -565,8 +601,8 @@ export async function syncSkillsToAgent(
skillsDirectory: string,
options?: { skipIfUnchanged?: boolean },
): Promise<{ synced: boolean; skills: Skill[] }> {
// Discover skills from filesystem
const { skills, errors } = await discoverSkills(skillsDirectory);
// Discover skills from filesystem (including agent-scoped skills)
const { skills, errors } = await discoverSkills(skillsDirectory, agentId);
if (errors.length > 0) {
for (const error of errors) {
@@ -575,7 +611,11 @@ export async function syncSkillsToAgent(
}
// Format skills for memory block
const formattedSkills = formatSkillsForMemory(skills, skillsDirectory);
const formattedSkills = formatSkillsForMemory(
skills,
skillsDirectory,
agentId,
);
// Check if we can skip the update
if (options?.skipIfUnchanged) {

View File

@@ -12,6 +12,7 @@ import {
discoverSkills,
formatSkillsForMemory,
GLOBAL_SKILLS_DIR,
getAgentSkillsDir,
getBundledSkills,
SKILLS_DIR,
} from "../../agent/skills";
@@ -269,10 +270,17 @@ function hasAdditionalFiles(skillMdPath: string): boolean {
/**
* Read skill content from file or bundled source
* Returns both content and the path to the SKILL.md file
*
* Search order (highest priority first):
* 1. Project skills (.skills/)
* 2. Agent skills (~/.letta/agents/{id}/skills/)
* 3. Global skills (~/.letta/skills/)
* 4. Bundled skills
*/
async function readSkillContent(
skillId: string,
skillsDir: string,
agentId?: string,
): Promise<{ content: string; path: string }> {
// 1. Check bundled skills first (they have a path now)
const bundledSkills = await getBundledSkills();
@@ -295,7 +303,22 @@ async function readSkillContent(
// Not in global, continue
}
// 3. Try project skills directory
// 3. Try agent skills directory (if agentId provided)
if (agentId) {
const agentSkillPath = join(
getAgentSkillsDir(agentId),
skillId,
"SKILL.md",
);
try {
const content = await readFile(agentSkillPath, "utf-8");
return { content, path: agentSkillPath };
} catch {
// Not in agent dir, continue
}
}
// 4. Try project skills directory
const projectSkillPath = join(skillsDir, skillId, "SKILL.md");
try {
const content = await readFile(projectSkillPath, "utf-8");
@@ -376,8 +399,8 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
if (command === "refresh") {
const skillsDir = await getResolvedSkillsDir(client, agentId);
// Discover skills from directory
const { skills, errors } = await discoverSkills(skillsDir);
// Discover skills from directory (including agent-scoped skills)
const { skills, errors } = await discoverSkills(skillsDir, agentId);
// Log any errors
if (errors.length > 0) {
@@ -389,7 +412,7 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
}
// Format and update the skills block
const formattedSkills = formatSkillsForMemory(skills, skillsDir);
const formattedSkills = formatSkillsForMemory(skills, skillsDir, agentId);
await updateBlock(client, agentId, "skills", formattedSkills);
const successMsg =
@@ -435,7 +458,7 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
try {
const { content: skillContent, path: skillPath } =
await readSkillContent(skillId, skillsDir);
await readSkillContent(skillId, skillsDir, agentId);
// Replace placeholder if this is the first skill (support old and new formats)
if (
@@ -613,6 +636,7 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
export async function preloadSkillsContent(
skillIds: string[],
skillsDir: string,
agentId?: string,
): Promise<string> {
if (skillIds.length === 0) {
return "No skills currently loaded.";
@@ -625,6 +649,7 @@ export async function preloadSkillsContent(
const { content: skillContent, path: skillPath } = await readSkillContent(
skillId,
skillsDir,
agentId,
);
const skillDir = dirname(skillPath);