feat: Skills omni-tool (#102)

This commit is contained in:
Devansh Jain
2025-11-26 11:16:57 -08:00
committed by GitHub
parent e88738b1c1
commit 8b3523c1a3
15 changed files with 407 additions and 21 deletions

131
src/agent/context.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Agent context module - provides global access to current agent state
* This allows tools to access the current agent ID and client
*/
import type Letta from "@letta-ai/letta-client";
interface AgentContext {
agentId: string | null;
client: Letta | null;
skillsDirectory: string | null;
hasLoadedSkills: boolean;
}
// Use globalThis to ensure singleton across bundle
// This prevents Bun's bundler from creating duplicate instances of the context
const CONTEXT_KEY = Symbol.for("@letta/agentContext");
type GlobalWithContext = typeof globalThis & {
[key: symbol]: AgentContext;
};
function getContext(): AgentContext {
const global = globalThis as GlobalWithContext;
if (!global[CONTEXT_KEY]) {
global[CONTEXT_KEY] = {
agentId: null,
client: null,
skillsDirectory: null,
hasLoadedSkills: false,
};
}
return global[CONTEXT_KEY];
}
const context = getContext();
/**
* Set the current agent context
* @param agentId - The agent ID
* @param client - The Letta client instance
* @param skillsDirectory - Optional skills directory path
*/
export function setAgentContext(
agentId: string,
client: Letta,
skillsDirectory?: string,
): void {
context.agentId = agentId;
context.client = client;
context.skillsDirectory = skillsDirectory || null;
}
/**
* Get the current agent ID
* @throws Error if no agent context is set
*/
export function getCurrentAgentId(): string {
if (!context.agentId) {
throw new Error("No agent context set. Agent ID is required.");
}
return context.agentId;
}
/**
* Get the current Letta client
* @throws Error if no agent context is set
*/
export function getCurrentClient(): Letta {
if (!context.client) {
throw new Error("No agent context set. Client is required.");
}
return context.client;
}
/**
* Get the skills directory path
* @returns The skills directory path or null if not set
*/
export function getSkillsDirectory(): string | null {
return context.skillsDirectory;
}
/**
* Check if skills are currently loaded (cached state)
* @returns true if skills are loaded, false otherwise
*/
export function hasLoadedSkills(): boolean {
return context.hasLoadedSkills;
}
/**
* Update the loaded skills state (called by Skill tool)
* @param loaded - Whether skills are currently loaded
*/
export function setHasLoadedSkills(loaded: boolean): void {
context.hasLoadedSkills = loaded;
}
/**
* Initialize the loaded skills flag by checking the block
* Should be called after setAgentContext to sync the cached state
*/
export async function initializeLoadedSkillsFlag(): Promise<void> {
if (!context.client || !context.agentId) {
return;
}
try {
const loadedSkillsBlock = await context.client.agents.blocks.retrieve(
"loaded_skills",
{ agent_id: context.agentId },
);
const value = loadedSkillsBlock?.value?.trim() || "";
// Consider empty or placeholder as no skills loaded
context.hasLoadedSkills = value !== "" && value !== "[CURRENTLY EMPTY]";
} catch {
// Block doesn't exist, no skills loaded
context.hasLoadedSkills = false;
}
}
/**
* Clear the agent context (useful for cleanup)
*/
export function clearAgentContext(): void {
context.agentId = null;
context.client = null;
context.skillsDirectory = null;
context.hasLoadedSkills = false;
}

View File

@@ -43,7 +43,13 @@ function parseMdxFrontmatter(content: string): {
async function loadMemoryBlocksFromMdx(): Promise<CreateBlock[]> {
const memoryBlocks: CreateBlock[] = [];
const mdxFiles = ["persona.mdx", "human.mdx", "project.mdx", "skills.mdx"];
const mdxFiles = [
"persona.mdx",
"human.mdx",
"project.mdx",
"skills.mdx",
"loaded_skills.mdx",
];
// const mdxFiles = ["persona.mdx", "human.mdx", "style.mdx"];
// const mdxFiles = ["persona_kawaii.mdx", "human.mdx", "style.mdx"];

View File

@@ -1,21 +1,24 @@
import humanPrompt from "./prompts/human.mdx";
import loadedSkillsPrompt from "./prompts/loaded_skills.mdx";
import personaPrompt from "./prompts/persona.mdx";
import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx";
import planModeReminder from "./prompts/plan_mode_reminder.txt";
import projectPrompt from "./prompts/project.mdx";
import skillUnloadReminder from "./prompts/skill_unload_reminder.txt";
import skillsPrompt from "./prompts/skills.mdx";
import stylePrompt from "./prompts/style.mdx";
import systemPrompt from "./prompts/system_prompt.txt";
export const SYSTEM_PROMPT = systemPrompt;
export const PLAN_MODE_REMINDER = planModeReminder;
export const SKILL_UNLOAD_REMINDER = skillUnloadReminder;
export const MEMORY_PROMPTS: Record<string, string> = {
"persona.mdx": personaPrompt,
"human.mdx": humanPrompt,
"project.mdx": projectPrompt,
"skills.mdx": skillsPrompt,
"loaded_skills.mdx": loadedSkillsPrompt,
"style.mdx": stylePrompt,
"persona_kawaii.mdx": personaKawaiiPrompt,
};

View File

@@ -0,0 +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.
---
[CURRENTLY EMPTY]

View File

@@ -0,0 +1,6 @@
<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.
</system-reminder>

View File

@@ -25,15 +25,17 @@ Memory blocks are used to modulate and augment your base behavior, follow them c
They are the foundation which makes you *you*.
# Skills
You have access to Skills—folders of instructions, scripts, and resources that you can load dynamically to improve performance on specialized tasks. Skills teach you how to complete specific tasks in a repeatable way. Skills work through progressive disclosure—you should determine which Skills are relevant to complete a task and load them, helping to prevent context window overload.
You have access to Skills—folders of instructions, scripts, and resources that you can load dynamically to improve performance on specialized tasks. Skills teach you how to complete specific tasks in a repeatable way. Skills work through progressive disclosure—you should determine which skills are relevant to complete a task and load them, helping to prevent context window overload.
Each Skill directory includes:
- `SKILL.md` file that starts with YAML frontmatter containing required metadata: name and description
- Additional files within the Skill directory referenced by name from `SKILL.md`. These additional linked files should be navigated and discovered only as needed.
- `SKILL.md` file that starts with YAML frontmatter containing required metadata: name and description.
- Additional files within the skill directory referenced by name from `SKILL.md`. These additional linked files should be navigated and discovered only as needed.
How to store Skills:
- Skills directory and any available skills are stored in the `skills` memory block.
- Currently loaded skills are available in the `loaded_skills` memory block.
How to use Skills:
- Skills are automatically discovered on bootup. The Skills directory and any available Skills are stored in the `skills` memory block.
- Review available Skills from the `skills` block when you are asked to complete a task.
- If a Skill is relevant, first load the Skill by reading its full `SKILL.md` into context.
- 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.
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
- When you load / use Skills, explicitly mention which Skills are loaded / used.
- When the task is completed or when you load new Skills, unload irrelevant Skills by removing them from the context.
Remember to always keep only the full `SKILL.md` into context for all Skills relevant to the current task. Use additional files as 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.

View File

@@ -96,6 +96,16 @@ function getPlanModeReminder(): string {
return PLAN_MODE_REMINDER;
}
// Get skill unload reminder if skills are loaded (using cached flag)
function getSkillUnloadReminder(): string {
const { hasLoadedSkills } = require("../agent/context");
if (hasLoadedSkills()) {
const { SKILL_UNLOAD_REMINDER } = require("../agent/promptAssets");
return SKILL_UNLOAD_REMINDER;
}
return "";
}
// Items that have finished rendering and no longer change
type StaticItem =
| {
@@ -1353,14 +1363,17 @@ export default function App({
// Prepend plan mode reminder if in plan mode
const planModeReminder = getPlanModeReminder();
// Prepend skill unload reminder if skills are loaded (using cached flag)
const skillUnloadReminder = getSkillUnloadReminder();
// Combine reminders with content (plan mode first, then skill unload)
const allReminders = planModeReminder + skillUnloadReminder;
const messageContent =
planModeReminder && typeof contentParts === "string"
? planModeReminder + contentParts
: Array.isArray(contentParts) && planModeReminder
? [
{ type: "text" as const, text: planModeReminder },
...contentParts,
]
allReminders && typeof contentParts === "string"
? allReminders + contentParts
: Array.isArray(contentParts) && allReminders
? [{ type: "text" as const, text: allReminders }, ...contentParts]
: contentParts;
// Append the user message to transcript IMMEDIATELY (optimistic update)

View File

@@ -8,6 +8,7 @@ import type { ApprovalCreate } from "@letta-ai/letta-client/resources/agents/mes
import type { StopReasonType } from "@letta-ai/letta-client/resources/runs/runs";
import type { ApprovalResult } from "./agent/approval-execution";
import { getClient } from "./agent/client";
import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context";
import { createAgent } from "./agent/create";
import { sendMessageStream } from "./agent/message";
import { getModelUpdateArgs } from "./agent/model";
@@ -155,6 +156,10 @@ export async function handleHeadlessCommand(
settingsManager.updateLocalProjectSettings({ lastAgent: agent.id });
settingsManager.updateSettings({ lastAgent: agent.id });
// Set agent context for tools that need it (e.g., Skill tool)
setAgentContext(agent.id, client, skillsDirectory);
await initializeLoadedSkillsFlag();
// Validate output format
const outputFormat =
(values["output-format"] as string | undefined) || "text";
@@ -305,14 +310,26 @@ export async function handleHeadlessCommand(
// Clear any pending approvals before starting a new turn
await resolveAllPendingApprovals();
// Get plan mode reminder if in plan mode
// Build message content with reminders (plan mode first, then skill unload)
const { permissionMode } = await import("./permissions/mode");
let messageContent = prompt;
const { hasLoadedSkills } = await import("./agent/context");
let messageContent = "";
// Add plan mode reminder if in plan mode (highest priority)
if (permissionMode.getMode() === "plan") {
const { PLAN_MODE_REMINDER } = await import("./agent/promptAssets");
messageContent = PLAN_MODE_REMINDER + prompt;
messageContent += PLAN_MODE_REMINDER;
}
// Add skill unload reminder if skills are loaded (using cached flag)
if (hasLoadedSkills()) {
const { SKILL_UNLOAD_REMINDER } = await import("./agent/promptAssets");
messageContent += SKILL_UNLOAD_REMINDER;
}
// Add user prompt
messageContent += prompt;
// Start with the user message
let currentInput: Array<MessageCreate | ApprovalCreate> = [
{

View File

@@ -3,6 +3,7 @@ import { parseArgs } from "node:util";
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
import { getResumeData, type ResumeData } from "./agent/check-approval";
import { getClient } from "./agent/client";
import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context";
import { permissionMode } from "./permissions/mode";
import { settingsManager } from "./settings-manager";
import { loadTools, upsertToolsToServer } from "./tools/manager";
@@ -556,6 +557,10 @@ async function main() {
settingsManager.updateLocalProjectSettings({ lastAgent: agent.id });
settingsManager.updateSettings({ lastAgent: agent.id });
// Set agent context for tools that need it (e.g., Skill tool)
setAgentContext(agent.id, client, skillsDirectory);
await initializeLoadedSkillsFlag();
// Check if we're resuming an existing agent
const localProjectSettings = settingsManager.getLocalProjectSettings();
const isResumingProject =

View File

@@ -102,6 +102,14 @@ export function checkPermission(
}
}
// Always allow Skill tool (read-only operation that loads skills from potentially external directories)
if (toolName === "Skill") {
return {
decision: "allow",
reason: "Skill tool is always allowed (read-only)",
};
}
// After checking CLI overrides, check if Read/Glob/Grep within working directory
if (WORKING_DIRECTORY_TOOLS.includes(toolName)) {
const filePath = extractFilePath(toolArgs);

View File

@@ -0,0 +1,29 @@
# Skill
Load a skill into the system prompt within the main conversation
<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
- 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
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
</skills_instructions>
Usage notes:
- The `skill` parameter is required and should be the skill ID (e.g., "data-analysis")
- 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
- Each skill provides specialized instructions and capabilities for specific tasks

136
src/tools/impl/Skill.ts Normal file
View File

@@ -0,0 +1,136 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
getCurrentAgentId,
getCurrentClient,
getSkillsDirectory,
setHasLoadedSkills,
} from "../../agent/context";
import { SKILLS_DIR } from "../../agent/skills";
import { validateRequiredParams } from "./validation.js";
interface SkillArgs {
skill: string;
}
interface SkillResult {
message: string;
}
/**
* Parse loaded_skills block content to extract skill IDs
*/
function parseLoadedSkills(value: string): string[] {
const skillRegex = /# Skill: ([^\n]+)/g;
const skills: string[] = [];
let match: RegExpExecArray | null = skillRegex.exec(value);
while (match !== null) {
const skillId = match[1]?.trim();
if (skillId) {
skills.push(skillId);
}
match = skillRegex.exec(value);
}
return skills;
}
/**
* Extracts skills directory from skills block value
*/
function extractSkillsDir(skillsBlockValue: string): string | null {
const match = skillsBlockValue.match(/Skills Directory: (.+)/);
return match ? match[1]?.trim() || null : null;
}
export async function skill(args: SkillArgs): Promise<SkillResult> {
validateRequiredParams(args, ["skill"], "Skill");
const { skill: skillId } = args;
try {
// Get current agent context
const client = getCurrentClient();
const agentId = getCurrentAgentId();
// Retrieve the loaded_skills block directly
let loadedSkillsBlock: Awaited<
ReturnType<typeof client.agents.blocks.retrieve>
>;
try {
loadedSkillsBlock = await client.agents.blocks.retrieve("loaded_skills", {
agent_id: agentId,
});
} catch (error) {
throw new Error(
`Error: loaded_skills block not found. This block is required for the Skill tool to work.\nAgent ID: ${agentId}\nError: ${error instanceof Error ? error.message : String(error)}`,
);
}
// Determine skills directory
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);
}
// Construct path to SKILL.md
const skillPath = join(skillsDir, skillId, "SKILL.md");
// Read the skill file directly
const skillContent = await readFile(skillPath, "utf-8");
// Parse current loaded_skills block value
let currentValue = loadedSkillsBlock.value?.trim() || "";
const loadedSkills = parseLoadedSkills(currentValue);
// Check if skill is already loaded
if (loadedSkills.includes(skillId)) {
return {
message: `Skill "${skillId}" is already loaded`,
};
}
// 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`,
};
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to load skill: ${String(error)}`);
}
}

View File

@@ -57,6 +57,7 @@ export const ANTHROPIC_DEFAULT_TOOLS: ToolName[] = [
"LS",
"MultiEdit",
"Read",
"Skill",
"TodoWrite",
"Write",
];
@@ -69,6 +70,7 @@ export const OPENAI_DEFAULT_TOOLS: ToolName[] = [
"grep_files",
"apply_patch",
"update_plan",
"Skill",
];
export const GEMINI_DEFAULT_TOOLS: ToolName[] = [
@@ -81,6 +83,7 @@ export const GEMINI_DEFAULT_TOOLS: ToolName[] = [
"write_file_gemini",
"write_todos",
"read_many_files",
"Skill",
];
// Tool permissions configuration
@@ -95,6 +98,7 @@ const TOOL_PERMISSIONS: Record<ToolName, { requiresApproval: boolean }> = {
LS: { requiresApproval: false },
MultiEdit: { requiresApproval: true },
Read: { requiresApproval: false },
Skill: { requiresApproval: false },
TodoWrite: { requiresApproval: false },
Write: { requiresApproval: true },
shell_command: { requiresApproval: true },

View File

@@ -0,0 +1,12 @@
{
"type": "object",
"properties": {
"skill": {
"type": "string",
"description": "The skill name/id (e.g., \"data-analysis\", \"web-scraper\")"
}
},
"required": ["skill"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@@ -22,6 +22,7 @@ import RunShellCommandGeminiDescription from "./descriptions/RunShellCommandGemi
import SearchFileContentGeminiDescription from "./descriptions/SearchFileContentGemini.md";
import ShellDescription from "./descriptions/Shell.md";
import ShellCommandDescription from "./descriptions/ShellCommand.md";
import SkillDescription from "./descriptions/Skill.md";
import TodoWriteDescription from "./descriptions/TodoWrite.md";
import UpdatePlanDescription from "./descriptions/UpdatePlan.md";
import WriteDescription from "./descriptions/Write.md";
@@ -51,6 +52,7 @@ import { run_shell_command } from "./impl/RunShellCommandGemini";
import { search_file_content } from "./impl/SearchFileContentGemini";
import { shell } from "./impl/Shell";
import { shell_command } from "./impl/ShellCommand";
import { skill } from "./impl/Skill";
import { todo_write } from "./impl/TodoWrite";
import { update_plan } from "./impl/UpdatePlan";
import { write } from "./impl/Write";
@@ -80,6 +82,7 @@ import RunShellCommandGeminiSchema from "./schemas/RunShellCommandGemini.json";
import SearchFileContentGeminiSchema from "./schemas/SearchFileContentGemini.json";
import ShellSchema from "./schemas/Shell.json";
import ShellCommandSchema from "./schemas/ShellCommand.json";
import SkillSchema from "./schemas/Skill.json";
import TodoWriteSchema from "./schemas/TodoWrite.json";
import UpdatePlanSchema from "./schemas/UpdatePlan.json";
import WriteSchema from "./schemas/Write.json";
@@ -145,6 +148,11 @@ const toolDefinitions = {
description: ReadDescription.trim(),
impl: read as unknown as ToolImplementation,
},
Skill: {
schema: SkillSchema,
description: SkillDescription.trim(),
impl: skill as unknown as ToolImplementation,
},
TodoWrite: {
schema: TodoWriteSchema,
description: TodoWriteDescription.trim(),