feat: update Skill tool to support load/unload commands (#219)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-15 18:36:27 -08:00
committed by GitHub
parent 693ae8b4e0
commit 4d5287520a
12 changed files with 334 additions and 112 deletions

View File

@@ -16,6 +16,12 @@ export const PROJECT_BLOCK_LABELS = [
"loaded_skills",
] as const;
/**
* Block labels that should be read-only (agent cannot modify via memory tools).
* These blocks are managed by specific tools (e.g., Skill tool for skills/loaded_skills).
*/
export const READ_ONLY_BLOCK_LABELS = ["skills", "loaded_skills"] as const;
/**
* Check if a block label is a project-level block
*/
@@ -77,8 +83,9 @@ async function loadMemoryBlocksFromMdx(): Promise<CreateBlock[]> {
}
const { frontmatter, body } = parseMdxFrontmatter(content);
const label = frontmatter.label || filename.replace(".mdx", "");
const block: CreateBlock = {
label: frontmatter.label || filename.replace(".mdx", ""),
label,
value: body,
};
@@ -86,6 +93,11 @@ async function loadMemoryBlocksFromMdx(): Promise<CreateBlock[]> {
block.description = frontmatter.description;
}
// Set read-only for skills blocks (managed by Skill tool, not memory tools)
if ((READ_ONLY_BLOCK_LABELS as readonly string[]).includes(label)) {
block.read_only = true;
}
memoryBlocks.push(block);
} catch (error) {
console.error(`Error loading ${filename}:`, error);

View File

@@ -154,7 +154,8 @@ How to store Skills:
How to use Skills:
- Skills are automatically discovered on bootup.
- Review available skills from the `skills` block and loaded skills from the `loaded_skills` block when you are asked to complete a task.
- If any skill is relevant, load it using the `Skill` tool.
- If any skill is relevant, load it using the `Skill` tool with `command: "load"`.
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
- When the task is completed, unload irrelevant skills from the `loaded_skills` block.
IMPORTANT: Always remove irrelevant skills using memory management tools from the `loaded_skills` block.
- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`.
- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list.
IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space.

View File

@@ -139,7 +139,8 @@ How to store Skills:
How to use Skills:
- Skills are automatically discovered on bootup.
- Review available skills from the `skills` block and loaded skills from the `loaded_skills` block when you are asked to complete a task.
- If any skill is relevant, load it using the `Skill` tool.
- If any skill is relevant, load it using the `Skill` tool with `command: "load"`.
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
- When the task is completed, unload irrelevant skills from the `loaded_skills` block.
IMPORTANT: Always remove irrelevant skills using memory management tools from the `loaded_skills` block.
- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`.
- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list.
IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space.

View File

@@ -100,7 +100,8 @@ How to store Skills:
How to use Skills:
- Skills are automatically discovered on bootup.
- Review available skills from the `skills` block and loaded skills from the `loaded_skills` block when you are asked to complete a task.
- If any skill is relevant, load it using the `Skill` tool.
- If any skill is relevant, load it using the `Skill` tool with `command: "load"`.
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
- When the task is completed, unload irrelevant skills from the `loaded_skills` block.
IMPORTANT: Always remove irrelevant skills using memory management tools from the `loaded_skills` block.
- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`.
- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list.
IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space.

View File

@@ -1,6 +1,6 @@
---
label: loaded_skills
description: A memory block to store the full instructions and capabilities from each loaded SKILL.md file in this block.
description: A memory block to store the full instructions and capabilities from each loaded SKILL.md file in this block. Do not manually edit this block - use the Skill tool to load and unload skills.
---
[CURRENTLY EMPTY]
[CURRENTLY EMPTY]

View File

@@ -16,7 +16,7 @@ Your goal is to guide the user through a **focused, collaborative workflow** to
- `loaded_skills` SKILL.md contents for currently loaded skills
2. If a `skill-creator` skill is **not already loaded** in `loaded_skills`, you should **attempt to load it** using the `Skill` tool:
- Call the `Skill` tool with:
- `skill: "skill-creator"`
- `command: "load", skills: ["skill-creator"]`
- The environment may resolve this from either the projects `.skills` directory or a bundled `skills/skills/skill-creator/SKILL.md` location.
3. If loading `skill-creator` fails (for example, the tool errors or the file is missing), or if the environment does not provide it, continue using your own judgment based on these instructions.
@@ -118,6 +118,5 @@ Your goal is to:
1. Understand the desired skill thoroughly.
2. Propose a clear, reusable design.
3. Implement or update the actual skill files in the repository.
4. Leave the user with a readytouse skill that appears in the `skills` memory block and can be loaded with the `Skill` tool.
4. After creating or updating the skill files, use the `Skill` tool with `command: "refresh"` to re-scan the skills directory and update the `skills` memory block.
5. Leave the user with a readytouse skill that appears in the `skills` memory block and can be loaded with the `Skill` tool.

View File

@@ -1,6 +1,5 @@
<system-reminder>
The `loaded_skills` block has at least one skill loaded. You should:
1. Check if loaded skills are relevant for the current task.
2. For any skills that are irrelevant, unload them using the `memory` tool.
If the block will be empty after unloading, add a "[CURRENTLY EMPTY]" tag.
2. For any skills that are irrelevant, unload them using the `Skill` tool with `command: "unload"`.
</system-reminder>

View File

@@ -1,6 +1,6 @@
---
label: skills
description: A memory block to store all available Skills with their metadata (name and description). Whenever a new Skill is discovered / created or an existing Skill is updated, I should store it here. I should always check the `.skills` directory for an updated skill list.
description: A memory block listing all available skills with their metadata (name and description). This block is auto-generated based on the `.skills` directory. Do not manually edit this block.
---
[CURRENTLY EMPTY: CREATE A LIST OF AVAILABLE SKILLS (DIRECTORIES WITH SKILL.MD FILE) WITH DESCRIPTIONS IN THIS MEMORY BLOCK. ALWAYS CHECK THE `.skills` DIRECTORY TO MAKE SURE YOU HAVE AN UPDATED LIST OF SKILLS]
[CURRENTLY EMPTY]

View File

@@ -35,7 +35,8 @@ How to store Skills:
How to use Skills:
- Skills are automatically discovered on bootup.
- Review available skills from the `skills` block and loaded skills from the `loaded_skills` block when you are asked to complete a task.
- If any skill is relevant, load it using the `Skill` tool.
- If any skill is relevant, load it using the `Skill` tool with `command: "load"`.
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
- When the task is completed, unload irrelevant skills from the `loaded_skills` block.
IMPORTANT: Always remove irrelevant skills using memory management tools from the `loaded_skills` block.
- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`.
- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list.
IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space.

View File

@@ -1,28 +1,35 @@
# Skill
Load a skill into the system prompt within the main conversation
Load or unload skills into the agent's memory.
<skills_instructions>
When users ask you to perform tasks, check if any of the available skills can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
How to use skills:
- Invoke skills using this tool with the skill name only (no arguments)
- When you invoke a skill, the SKILL.md content will be loaded into the `loaded_skills` memory block
- Use `command: "load"` with a list of skill IDs to load skills
- Use `command: "unload"` with a list of skill IDs to unload skills
- Use `command: "refresh"` to re-scan the skills directory and update the available skills list
- When you load a skill, the SKILL.md content will be added to the `loaded_skills` memory block
- The skill's prompt will provide detailed instructions on how to complete the task
- Examples:
- `skill: "data-analysis"` - invoke the data-analysis skill
- `skill: "web-scraper"` - invoke the web-scraper skill
- `command: "load", skills: ["data-analysis"]` - load the data-analysis skill
- `command: "load", skills: ["web-scraper", "pdf"]` - load multiple skills
- `command: "unload", skills: ["data-analysis"]` - unload the data-analysis skill
- `command: "refresh"` - re-scan and update available skills list
Important:
- Only load skills that are available in the `skills` memory block
- Skills remain loaded until you unload them
- Unload skills when done to free up context space
- Do not invoke a skill that is already loaded
- You can check what skills are currently loaded in the `loaded_skills` memory block
- Loading an already-loaded skill will be skipped (no error)
- Unloading a not-loaded skill will be skipped (no error)
- Use `refresh` after creating a new skill to make it available for loading
</skills_instructions>
Usage notes:
- The `skill` parameter is required and should be the skill ID (e.g., "data-analysis")
- The `command` parameter is required: either "load", "unload", or "refresh"
- The `skills` parameter is required for load/unload: an array of skill IDs to load or unload
- The `skills` parameter is not used for refresh
- Skills are loaded from the skills directory specified in the `skills` memory block
- Skills remain loaded in the `loaded_skills` memory block until explicitly unloaded
- Only use skill IDs that appear in the `skills` memory block

View File

@@ -6,11 +6,16 @@ import {
getSkillsDirectory,
setHasLoadedSkills,
} from "../../agent/context";
import { SKILLS_DIR } from "../../agent/skills";
import {
discoverSkills,
formatSkillsForMemory,
SKILLS_DIR,
} from "../../agent/skills";
import { validateRequiredParams } from "./validation.js";
interface SkillArgs {
skill: string;
command: "load" | "unload" | "refresh";
skills?: string[];
}
interface SkillResult {
@@ -18,13 +23,63 @@ interface SkillResult {
}
/**
* Parse loaded_skills block content to extract skill IDs
* Parse loaded_skills block content to extract skill IDs and their content boundaries
*/
function parseLoadedSkills(value: string): string[] {
function parseLoadedSkills(
value: string,
): Map<string, { start: number; end: number }> {
const skillMap = new Map<string, { start: number; end: number }>();
const skillHeaderRegex = /# Skill: ([^\n]+)/g;
const headers: { id: string; start: number }[] = [];
// Find all skill headers
let match = skillHeaderRegex.exec(value);
while (match !== null) {
const skillId = match[1]?.trim();
if (skillId) {
headers.push({ id: skillId, start: match.index });
}
match = skillHeaderRegex.exec(value);
}
// Determine boundaries for each skill
for (let i = 0; i < headers.length; i++) {
const current = headers[i];
const next = headers[i + 1];
if (!current) continue;
let end: number;
if (next) {
// Find the separator before the next skill
const searchStart = current.start;
const searchEnd = next.start;
const substring = value.substring(searchStart, searchEnd);
const sepMatch = substring.lastIndexOf("\n\n---\n\n");
if (sepMatch !== -1) {
end = searchStart + sepMatch;
} else {
end = searchEnd;
}
} else {
end = value.length;
}
skillMap.set(current.id, { start: current.start, end });
}
return skillMap;
}
/**
* Get list of loaded skill IDs
*/
function getLoadedSkillIds(value: string): string[] {
const skillRegex = /# Skill: ([^\n]+)/g;
const skills: string[] = [];
let match: RegExpExecArray | null = skillRegex.exec(value);
let match = skillRegex.exec(value);
while (match !== null) {
const skillId = match[1]?.trim();
if (skillId) {
@@ -44,16 +99,115 @@ function extractSkillsDir(skillsBlockValue: string): string | null {
return match ? match[1]?.trim() || null : null;
}
/**
* Read skill content from file
*/
async function readSkillContent(
skillId: string,
skillsDir: string,
): Promise<string> {
// Try primary skills directory
const skillPath = join(skillsDir, skillId, "SKILL.md");
try {
return await readFile(skillPath, "utf-8");
} catch (primaryError) {
// Fallback: check for bundled skills in a repo-level skills directory
try {
const bundledSkillsDir = join(process.cwd(), "skills", "skills");
const bundledSkillPath = join(bundledSkillsDir, skillId, "SKILL.md");
return await readFile(bundledSkillPath, "utf-8");
} catch {
// If bundled fallback also fails, rethrow the original error
throw primaryError;
}
}
}
/**
* Get skills directory, trying multiple sources
*/
async function getResolvedSkillsDir(
client: ReturnType<typeof getCurrentClient>,
agentId: string,
): Promise<string> {
let skillsDir = getSkillsDirectory();
if (!skillsDir) {
// Try to extract from skills block
try {
const skillsBlock = await client.agents.blocks.retrieve("skills", {
agent_id: agentId,
});
if (skillsBlock?.value) {
skillsDir = extractSkillsDir(skillsBlock.value);
}
} catch {
// Skills block doesn't exist, will fall back to default
}
}
if (!skillsDir) {
// Fall back to default .skills directory in cwd
skillsDir = join(process.cwd(), SKILLS_DIR);
}
return skillsDir;
}
export async function skill(args: SkillArgs): Promise<SkillResult> {
validateRequiredParams(args, ["skill"], "Skill");
const { skill: skillId } = args;
validateRequiredParams(args, ["command"], "Skill");
const { command, skills: skillIds } = args;
if (command !== "load" && command !== "unload" && command !== "refresh") {
throw new Error(
`Invalid command "${command}". Must be "load", "unload", or "refresh".`,
);
}
// For load/unload, skills array is required
if (command !== "refresh") {
if (!Array.isArray(skillIds) || skillIds.length === 0) {
throw new Error(
`Skill tool requires a non-empty 'skills' array for "${command}" command`,
);
}
}
try {
// Get current agent context
const client = getCurrentClient();
const agentId = getCurrentAgentId();
// Retrieve the loaded_skills block directly
// Handle refresh command
if (command === "refresh") {
const skillsDir = await getResolvedSkillsDir(client, agentId);
// Discover skills from directory
const { skills, errors } = await discoverSkills(skillsDir);
// Log any errors
if (errors.length > 0) {
for (const error of errors) {
console.warn(
`Skill discovery error: ${error.path}: ${error.message}`,
);
}
}
// Format and update the skills block
const formattedSkills = formatSkillsForMemory(skills, skillsDir);
await client.agents.blocks.update("skills", {
agent_id: agentId,
value: formattedSkills,
});
return {
message: `Refreshed skills list: found ${skills.length} skill(s)${errors.length > 0 ? `, ${errors.length} error(s)` : ""}`,
};
}
// Retrieve the loaded_skills block for load/unload
let loadedSkillsBlock: Awaited<
ReturnType<typeof client.agents.blocks.retrieve>
>;
@@ -67,86 +221,125 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
);
}
// Determine skills directory
let skillsDir = getSkillsDirectory();
const skillsDir = await getResolvedSkillsDir(client, agentId);
if (!skillsDir) {
// Try to extract from skills block
try {
const skillsBlock = await client.agents.blocks.retrieve("skills", {
agent_id: agentId,
});
if (skillsBlock?.value) {
skillsDir = extractSkillsDir(skillsBlock.value);
}
} catch {
// Skills block doesn't exist, will fall back to default
}
}
if (!skillsDir) {
// Fall back to default .skills directory in cwd
skillsDir = join(process.cwd(), SKILLS_DIR);
}
// Construct path to SKILL.md in the primary skills directory
let skillPath = join(skillsDir, skillId, "SKILL.md");
// Read the skill file directly, with a fallback to bundled skills if not found
let skillContent: string;
try {
skillContent = await readFile(skillPath, "utf-8");
} catch (primaryError) {
// Fallback: check for bundled skills in a repo-level skills directory
try {
const bundledSkillsDir = join(process.cwd(), "skills", "skills");
const bundledSkillPath = join(bundledSkillsDir, skillId, "SKILL.md");
skillContent = await readFile(bundledSkillPath, "utf-8");
// Update path and directory to reflect bundled location for this invocation
skillsDir = bundledSkillsDir;
skillPath = bundledSkillPath;
} catch {
// If bundled fallback also fails, rethrow the original error
throw primaryError;
}
}
// Parse current loaded_skills block value
let currentValue = loadedSkillsBlock.value?.trim() || "";
const loadedSkills = parseLoadedSkills(currentValue);
const loadedSkillIds = getLoadedSkillIds(currentValue);
const results: string[] = [];
// Check if skill is already loaded
if (loadedSkills.includes(skillId)) {
return {
message: `Skill "${skillId}" is already loaded`,
};
// skillIds is guaranteed to be non-empty for load/unload (validated above)
const skillsToProcess = skillIds as string[];
if (command === "load") {
// Load skills
for (const skillId of skillsToProcess) {
if (loadedSkillIds.includes(skillId)) {
results.push(`"${skillId}" already loaded`);
continue;
}
try {
const skillContent = await readSkillContent(skillId, skillsDir);
// Replace placeholder if this is the first skill
if (currentValue === "[CURRENTLY EMPTY]") {
currentValue = "";
}
// Append new skill
const separator = currentValue ? "\n\n---\n\n" : "";
currentValue = `${currentValue}${separator}# Skill: ${skillId}\n${skillContent}`;
loadedSkillIds.push(skillId);
results.push(`"${skillId}" loaded`);
} catch (error) {
results.push(
`"${skillId}" failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Update the block
await client.agents.blocks.update("loaded_skills", {
agent_id: agentId,
value: currentValue,
});
// Update the cached flag
if (loadedSkillIds.length > 0) {
setHasLoadedSkills(true);
}
} else {
// Unload skills
const skillBoundaries = parseLoadedSkills(currentValue);
// Sort skills to unload by their position (descending) so we can remove from end first
const sortedSkillsToUnload = skillsToProcess
.filter((id) => skillBoundaries.has(id))
.sort((a, b) => {
const boundaryA = skillBoundaries.get(a);
const boundaryB = skillBoundaries.get(b);
return (boundaryB?.start || 0) - (boundaryA?.start || 0);
});
for (const skillId of skillsToProcess) {
if (!loadedSkillIds.includes(skillId)) {
results.push(`"${skillId}" not loaded`);
continue;
}
results.push(`"${skillId}" unloaded`);
}
// Remove skills from content (in reverse order to maintain indices)
for (const skillId of sortedSkillsToUnload) {
const boundary = skillBoundaries.get(skillId);
if (boundary) {
// Check if there's a separator before this skill
const beforeStart = boundary.start;
let actualStart = beforeStart;
// Look for preceding separator
const precedingSep = "\n\n---\n\n";
if (beforeStart >= precedingSep.length) {
const potentialSep = currentValue.substring(
beforeStart - precedingSep.length,
beforeStart,
);
if (potentialSep === precedingSep) {
actualStart = beforeStart - precedingSep.length;
}
}
// Remove the skill content
currentValue =
currentValue.substring(0, actualStart) +
currentValue.substring(boundary.end);
}
}
// Clean up the value
currentValue = currentValue.trim();
if (currentValue === "") {
currentValue = "[CURRENTLY EMPTY]";
}
// Update the block
await client.agents.blocks.update("loaded_skills", {
agent_id: agentId,
value: currentValue,
});
// Update the cached flag
const remainingSkills = getLoadedSkillIds(currentValue);
setHasLoadedSkills(remainingSkills.length > 0);
}
// Replace placeholder if this is the first skill
if (currentValue === "[CURRENTLY EMPTY]") {
currentValue = "";
}
// Append new skill to loaded_skills block
const separator = currentValue ? "\n\n---\n\n" : "";
const newValue = `${currentValue}${separator}# Skill: ${skillId}\n${skillContent}`;
// Update the block
await client.agents.blocks.update("loaded_skills", {
agent_id: agentId,
value: newValue,
});
// Update the cached flag to indicate skills are loaded
setHasLoadedSkills(true);
return {
message: `Skill "${skillId}" loaded successfully`,
message: results.join(", "),
};
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to load skill: ${String(error)}`);
throw new Error(`Failed to ${command} skill(s): ${String(error)}`);
}
}

View File

@@ -1,12 +1,20 @@
{
"type": "object",
"properties": {
"skill": {
"command": {
"type": "string",
"description": "The skill name/id (e.g., \"data-analysis\", \"web-scraper\")"
"enum": ["load", "unload", "refresh"],
"description": "The operation to perform: \"load\" to load skills, \"unload\" to unload skills, \"refresh\" to re-scan the skills directory and update the available skills list"
},
"skills": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of skill IDs to load or unload (required for load/unload, not used for refresh)"
}
},
"required": ["skill"],
"required": ["command"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}