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

101
src/agent/clientSkills.ts Normal file
View File

@@ -0,0 +1,101 @@
import { join } from "node:path";
import type { MessageCreateParams as ConversationMessageCreateParams } from "@letta-ai/letta-client/resources/conversations/messages";
import { getSkillSources, getSkillsDirectory } from "./context";
import {
compareSkills,
discoverSkills,
SKILLS_DIR,
type Skill,
type SkillDiscoveryError,
type SkillSource,
} from "./skills";
export type ClientSkill = NonNullable<
ConversationMessageCreateParams["client_skills"]
>[number];
export interface BuildClientSkillsPayloadOptions {
agentId?: string;
skillsDirectory?: string | null;
skillSources?: SkillSource[];
discoverSkillsFn?: typeof discoverSkills;
logger?: (message: string) => void;
}
export interface BuildClientSkillsPayloadResult {
clientSkills: NonNullable<ConversationMessageCreateParams["client_skills"]>;
skillPathById: Record<string, string>;
errors: SkillDiscoveryError[];
}
function toClientSkill(skill: Skill): ClientSkill {
return {
name: skill.id,
description: skill.description,
location: skill.path,
};
}
function resolveSkillDiscoveryContext(
options: BuildClientSkillsPayloadOptions,
): {
skillsDirectory: string;
skillSources: SkillSource[];
} {
const skillsDirectory =
options.skillsDirectory ??
getSkillsDirectory() ??
join(process.cwd(), SKILLS_DIR);
const skillSources = options.skillSources ?? getSkillSources();
return { skillsDirectory, skillSources };
}
/**
* Build `client_skills` payload for conversations.messages.create.
*
* This discovers client-side skills using the same source selection rules as the
* Skill tool and headless startup flow, then converts them into the server-facing
* schema expected by the API. Ordering is deterministic by skill id.
*/
export async function buildClientSkillsPayload(
options: BuildClientSkillsPayloadOptions = {},
): Promise<BuildClientSkillsPayloadResult> {
const { skillsDirectory, skillSources } =
resolveSkillDiscoveryContext(options);
const discoverSkillsFn = options.discoverSkillsFn ?? discoverSkills;
try {
const discovery = await discoverSkillsFn(skillsDirectory, options.agentId, {
sources: skillSources,
});
const sortedSkills = [...discovery.skills].sort(compareSkills);
return {
clientSkills: sortedSkills.map(toClientSkill),
skillPathById: Object.fromEntries(
sortedSkills
.filter(
(skill) => typeof skill.path === "string" && skill.path.length > 0,
)
.map((skill) => [skill.id, skill.path]),
),
errors: discovery.errors,
};
} catch (error) {
const message =
error instanceof Error
? error.message
: `Unknown error: ${String(error)}`;
options.logger?.(`Failed to build client_skills payload: ${message}`);
return {
clientSkills: [],
skillPathById: {},
errors: [
{
path: skillsDirectory,
message,
},
],
};
}
}

View File

@@ -8,6 +8,7 @@ import type {
ApprovalCreate,
LettaStreamingResponse,
} from "@letta-ai/letta-client/resources/agents/messages";
import type { MessageCreateParams as ConversationMessageCreateParams } from "@letta-ai/letta-client/resources/conversations/messages";
import {
type ClientTool,
captureToolExecutionContext,
@@ -19,6 +20,7 @@ import {
normalizeOutgoingApprovalMessages,
} from "./approval-result-normalization";
import { getClient } from "./client";
import { buildClientSkillsPayload } from "./clientSkills";
const streamRequestStartTimes = new WeakMap<object, number>();
const streamToolContextIds = new WeakMap<object, string>();
@@ -60,6 +62,9 @@ export function buildConversationMessagesCreateRequestBody(
messages: Array<MessageCreate | ApprovalCreate>,
opts: SendMessageStreamOptions = { streamTokens: true, background: true },
clientTools: ClientTool[],
clientSkills: NonNullable<
ConversationMessageCreateParams["client_skills"]
> = [],
) {
const isDefaultConversation = conversationId === "default";
if (isDefaultConversation && !opts.agentId) {
@@ -77,6 +82,7 @@ export function buildConversationMessagesCreateRequestBody(
stream_tokens: opts.streamTokens ?? true,
include_pings: true,
background: opts.background ?? true,
client_skills: clientSkills,
client_tools: clientTools,
include_compaction_messages: true,
...(isDefaultConversation ? { agent_id: opts.agentId } : {}),
@@ -113,6 +119,10 @@ export async function sendMessageStream(
// This prevents sending messages with stale tools during a switch
await waitForToolsetReady();
const { clientTools, contextId } = captureToolExecutionContext();
const { clientSkills, errors: clientSkillDiscoveryErrors } =
await buildClientSkillsPayload({
agentId: opts.agentId,
});
const resolvedConversationId = conversationId;
const requestBody = buildConversationMessagesCreateRequestBody(
@@ -120,12 +130,30 @@ export async function sendMessageStream(
messages,
opts,
clientTools,
clientSkills,
);
if (process.env.DEBUG) {
console.log(
`[DEBUG] sendMessageStream: conversationId=${conversationId}, agentId=${opts.agentId ?? "(none)"}`,
);
const formattedSkills = clientSkills.map(
(skill) => `${skill.name} (${skill.location})`,
);
console.log(
`[DEBUG] sendMessageStream: client_skills (${clientSkills.length}) ${
formattedSkills.length > 0 ? formattedSkills.join(", ") : "(none)"
}`,
);
if (clientSkillDiscoveryErrors.length > 0) {
for (const error of clientSkillDiscoveryErrors) {
console.warn(
`[DEBUG] sendMessageStream: client_skills discovery error at ${error.path}: ${error.message}`,
);
}
}
}
const extraHeaders: Record<string, string> = {};

View File

@@ -132,4 +132,4 @@ assistant: Clients are marked as failed in the `connectToServer` function in src
</example>
# Skills
- /<skill-name> (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in system-reminder messages in the conversation - do not guess or use built-in CLI commands.
- /<skill-name> (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in the available skills context in the conversation - do not guess or use built-in CLI commands.

View File

@@ -114,4 +114,4 @@ Unless the user explicitly asks for a plan, asks a question about the code, is b
## Skills
- /<skill-name> (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in system-reminder messages in the conversation - do not guess or use built-in CLI commands.
- /<skill-name> (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in the available skills context in the conversation - do not guess or use built-in CLI commands.

View File

@@ -79,4 +79,4 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
- **Remembering Facts:** Use the memory tools available to you to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?
# Skills
- /<skill-name> (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in system-reminder messages in the conversation - do not guess or use built-in CLI commands.
- /<skill-name> (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in the available skills context in the conversation - do not guess or use built-in CLI commands.

View File

@@ -11,7 +11,7 @@ Your goal is to guide the user through a **focused, collaborative workflow** to
## 1. Invoke the creating-skills Skill (if available)
1. Check the available skills listed in system-reminder messages in the conversation.
1. Check the available skills listed in the current prompt context in the conversation.
2. If a `creating-skills` skill is available, invoke it using the `Skill` tool:
- Call the `Skill` tool with: `skill: "creating-skills"`
3. If invocation fails or the skill is not available, continue using your own judgment based on these instructions.

View File

@@ -17,4 +17,4 @@ When the user directly asks about Letta Code (eg 'can Letta Code do...', 'does L
When running in Letta Code, shell tools provide `AGENT_ID`: your current agent ID.
# Skills
- /<skill-name> (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in system-reminder messages in the conversation - do not guess or use built-in CLI commands.
- /<skill-name> (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in the available skills context in the conversation - do not guess or use built-in CLI commands.

View File

@@ -85,6 +85,14 @@ export interface SkillDiscoveryError {
message: string;
}
export function compareSkills(a: Skill, b: Skill): number {
return (
a.id.localeCompare(b.id) ||
a.source.localeCompare(b.source) ||
a.path.localeCompare(b.path)
);
}
/**
* Default directory name where project skills are stored
*/
@@ -223,7 +231,7 @@ export async function discoverSkills(
}
return {
skills: Array.from(skillsById.values()),
skills: Array.from(skillsById.values()).sort(compareSkills),
errors: allErrors,
};
}
@@ -406,7 +414,9 @@ export function formatSkillsAsSystemReminder(skills: Skill[]): string {
return "";
}
const lines = skills.map((s) => `- ${s.id} (${s.source}): ${s.description}`);
const lines = [...skills]
.sort(compareSkills)
.map((s) => `- ${s.id} (${s.source}): ${s.description}`);
return `<system-reminder>
The following skills are available for use with the Skill tool: