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

View File

@@ -132,4 +132,4 @@ assistant: Clients are marked as failed in the `connectToServer` function in src
</example> </example>
# Skills # 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 ## 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? - **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 # 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. 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: 2. If a `creating-skills` skill is available, invoke it using the `Skill` tool:
- Call the `Skill` tool with: `skill: "creating-skills"` - 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. 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. When running in Letta Code, shell tools provide `AGENT_ID`: your current agent ID.
# Skills # 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; 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 * Default directory name where project skills are stored
*/ */
@@ -223,7 +231,7 @@ export async function discoverSkills(
} }
return { return {
skills: Array.from(skillsById.values()), skills: Array.from(skillsById.values()).sort(compareSkills),
errors: allErrors, errors: allErrors,
}; };
} }
@@ -406,7 +414,9 @@ export function formatSkillsAsSystemReminder(skills: Skill[]): string {
return ""; 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> return `<system-reminder>
The following skills are available for use with the Skill tool: The following skills are available for use with the Skill tool:

View File

@@ -8182,9 +8182,8 @@ export default function App({
cmd.finish(outputLines.join("\n"), true); cmd.finish(outputLines.join("\n"), true);
// Manual /compact bypasses stream compaction events, so trigger // Manual /compact bypasses stream compaction events, so trigger
// post-compaction reminder/skills reinjection on the next user turn. // post-compaction reflection reminder/auto-launch on the next user turn.
contextTrackerRef.current.pendingReflectionTrigger = true; contextTrackerRef.current.pendingReflectionTrigger = true;
contextTrackerRef.current.pendingSkillsReinject = true;
} catch (error) { } catch (error) {
let errorOutput: string; let errorOutput: string;

View File

@@ -73,13 +73,16 @@ export function createCommandRunner({
onCommandFinished, onCommandFinished,
}: RunnerDeps) { }: RunnerDeps) {
function getHandle(id: string, input: string): CommandHandle { function getHandle(id: string, input: string): CommandHandle {
// biome-ignore lint/style/noNonNullAssertion: forward-reference pattern — overwritten synchronously below. null! preferred over no-ops to crash loudly if invariant breaks. const uninitialized = (): never => {
throw new Error("CommandHandle callback used before initialization");
};
const handle: CommandHandle = { const handle: CommandHandle = {
id, id,
input, input,
update: null!, update: uninitialized,
finish: null!, finish: uninitialized,
fail: null!, fail: uninitialized,
}; };
const update = (updateData: CommandUpdate) => { const update = (updateData: CommandUpdate) => {

View File

@@ -454,7 +454,6 @@ function extractTextPart(v: unknown): string {
function markCompactionCompleted(ctx?: ContextTracker): void { function markCompactionCompleted(ctx?: ContextTracker): void {
if (!ctx) return; if (!ctx) return;
ctx.pendingCompaction = true; ctx.pendingCompaction = true;
ctx.pendingSkillsReinject = true;
ctx.pendingReflectionTrigger = true; ctx.pendingReflectionTrigger = true;
} }

View File

@@ -16,8 +16,6 @@ export type ContextTracker = {
currentTurnId: number; currentTurnId: number;
/** Set when a compaction event is seen; consumed by the next usage_statistics push */ /** Set when a compaction event is seen; consumed by the next usage_statistics push */
pendingCompaction: boolean; pendingCompaction: boolean;
/** Set when compaction happens; consumed by the next user message to reinject skills reminder */
pendingSkillsReinject: boolean;
/** Set when compaction happens; consumed by the next user message to trigger memory reminder/spawn */ /** Set when compaction happens; consumed by the next user message to trigger memory reminder/spawn */
pendingReflectionTrigger: boolean; pendingReflectionTrigger: boolean;
}; };
@@ -28,7 +26,6 @@ export function createContextTracker(): ContextTracker {
contextTokensHistory: [], contextTokensHistory: [],
currentTurnId: 0, // simple in-memory counter for now currentTurnId: 0, // simple in-memory counter for now
pendingCompaction: false, pendingCompaction: false,
pendingSkillsReinject: false,
pendingReflectionTrigger: false, pendingReflectionTrigger: false,
}; };
} }
@@ -38,6 +35,5 @@ export function resetContextHistory(ct: ContextTracker): void {
ct.lastContextTokens = 0; ct.lastContextTokens = 0;
ct.contextTokensHistory = []; ct.contextTokensHistory = [];
ct.pendingCompaction = false; ct.pendingCompaction = false;
ct.pendingSkillsReinject = false;
ct.pendingReflectionTrigger = false; ct.pendingReflectionTrigger = false;
} }

View File

@@ -21,6 +21,7 @@ import {
} from "./agent/approval-recovery"; } from "./agent/approval-recovery";
import { handleBootstrapSessionState } from "./agent/bootstrapHandler"; import { handleBootstrapSessionState } from "./agent/bootstrapHandler";
import { getClient } from "./agent/client"; import { getClient } from "./agent/client";
import { buildClientSkillsPayload } from "./agent/clientSkills";
import { setAgentContext, setConversationId } from "./agent/context"; import { setAgentContext, setConversationId } from "./agent/context";
import { createAgent } from "./agent/create"; import { createAgent } from "./agent/create";
import { handleListMessages } from "./agent/listMessagesHandler"; import { handleListMessages } from "./agent/listMessagesHandler";
@@ -1459,13 +1460,22 @@ ${SYSTEM_REMINDER_CLOSE}
// Pre-load specific skills' full content (used by subagents with skills: field) // Pre-load specific skills' full content (used by subagents with skills: field)
if (preLoadSkillsRaw) { if (preLoadSkillsRaw) {
const { readFile: readFileAsync } = await import("node:fs/promises"); const { readFile: readFileAsync } = await import("node:fs/promises");
const { skillPathById } = await buildClientSkillsPayload({
agentId: agent.id,
skillSources: resolvedSkillSources,
logger: (message) => {
if (process.env.DEBUG) {
console.warn(`[DEBUG] ${message}`);
}
},
});
const skillIds = preLoadSkillsRaw const skillIds = preLoadSkillsRaw
.split(",") .split(",")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const loadedContents: string[] = []; const loadedContents: string[] = [];
for (const skillId of skillIds) { for (const skillId of skillIds) {
const skillPath = sharedReminderState.skillPathById[skillId]; const skillPath = skillPathById[skillId];
if (!skillPath) continue; if (!skillPath) continue;
try { try {
const content = await readFileAsync(skillPath, "utf-8"); const content = await readFileAsync(skillPath, "utf-8");

View File

@@ -7,7 +7,6 @@ export type SharedReminderMode =
export type SharedReminderId = export type SharedReminderId =
| "session-context" | "session-context"
| "agent-info" | "agent-info"
| "skills"
| "permission-mode" | "permission-mode"
| "plan-mode" | "plan-mode"
| "reflection-step-count" | "reflection-step-count"
@@ -40,11 +39,6 @@ export const SHARED_REMINDER_CATALOG: ReadonlyArray<SharedReminderDefinition> =
"subagent", "subagent",
], ],
}, },
{
id: "skills",
description: "Available skills system reminder (with reinjection)",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{ {
id: "permission-mode", id: "permission-mode",
description: "Permission mode reminder", description: "Permission mode reminder",

View File

@@ -1,12 +1,5 @@
import { join } from "node:path";
import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents";
import { getSkillsDirectory } from "../agent/context"; import type { SkillSource } from "../agent/skills";
import {
discoverSkills,
formatSkillsAsSystemReminder,
SKILLS_DIR,
type SkillSource,
} from "../agent/skills";
import { buildAgentInfo } from "../cli/helpers/agentInfo"; import { buildAgentInfo } from "../cli/helpers/agentInfo";
import { import {
buildCompactionMemoryReminder, buildCompactionMemoryReminder,
@@ -101,52 +94,6 @@ async function buildSessionContextReminder(
return reminder || null; return reminder || null;
} }
async function buildSkillsReminder(
context: SharedReminderContext,
): Promise<string | null> {
const previousSkillsReminder = context.state.cachedSkillsReminder;
// Keep a stable empty baseline so a later successful discovery can diff
// against "" and trigger reinjection, even after an earlier discovery failure.
let latestSkillsReminder = previousSkillsReminder ?? "";
try {
const skillsDir = getSkillsDirectory() || join(process.cwd(), SKILLS_DIR);
const { skills } = await discoverSkills(skillsDir, context.agent.id, {
sources: context.skillSources,
});
latestSkillsReminder = formatSkillsAsSystemReminder(skills);
context.state.skillPathById = Object.fromEntries(
skills
.filter(
(skill) => typeof skill.path === "string" && skill.path.length > 0,
)
.map((skill) => [skill.id, skill.path as string]),
);
} catch {
// Keep previous snapshot when discovery fails.
}
if (
previousSkillsReminder !== null &&
previousSkillsReminder !== latestSkillsReminder
) {
context.state.pendingSkillsReinject = true;
}
context.state.cachedSkillsReminder = latestSkillsReminder;
const shouldInject =
!context.state.hasInjectedSkillsReminder ||
context.state.pendingSkillsReinject;
if (!shouldInject) {
return null;
}
context.state.hasInjectedSkillsReminder = true;
context.state.pendingSkillsReinject = false;
return latestSkillsReminder || null;
}
async function buildPlanModeReminder( async function buildPlanModeReminder(
context: SharedReminderContext, context: SharedReminderContext,
): Promise<string | null> { ): Promise<string | null> {
@@ -398,7 +345,6 @@ export const sharedReminderProviders: Record<
> = { > = {
"agent-info": buildAgentInfoReminder, "agent-info": buildAgentInfoReminder,
"session-context": buildSessionContextReminder, "session-context": buildSessionContextReminder,
skills: buildSkillsReminder,
"permission-mode": buildPermissionModeReminder, "permission-mode": buildPermissionModeReminder,
"plan-mode": buildPlanModeReminder, "plan-mode": buildPlanModeReminder,
"reflection-step-count": buildReflectionStepReminder, "reflection-step-count": buildReflectionStepReminder,

View File

@@ -22,12 +22,8 @@ export interface ToolsetChangeReminder {
export interface SharedReminderState { export interface SharedReminderState {
hasSentAgentInfo: boolean; hasSentAgentInfo: boolean;
hasSentSessionContext: boolean; hasSentSessionContext: boolean;
hasInjectedSkillsReminder: boolean;
cachedSkillsReminder: string | null;
skillPathById: Record<string, string>;
lastNotifiedPermissionMode: PermissionMode | null; lastNotifiedPermissionMode: PermissionMode | null;
turnCount: number; turnCount: number;
pendingSkillsReinject: boolean;
pendingReflectionTrigger: boolean; pendingReflectionTrigger: boolean;
pendingAutoInitReminder: boolean; pendingAutoInitReminder: boolean;
pendingCommandIoReminders: CommandIoReminder[]; pendingCommandIoReminders: CommandIoReminder[];
@@ -40,12 +36,8 @@ export function createSharedReminderState(): SharedReminderState {
return { return {
hasSentAgentInfo: false, hasSentAgentInfo: false,
hasSentSessionContext: false, hasSentSessionContext: false,
hasInjectedSkillsReminder: false,
cachedSkillsReminder: null,
skillPathById: {},
lastNotifiedPermissionMode: null, lastNotifiedPermissionMode: null,
turnCount: 0, turnCount: 0,
pendingSkillsReinject: false,
pendingReflectionTrigger: false, pendingReflectionTrigger: false,
pendingAutoInitReminder: false, pendingAutoInitReminder: false,
pendingCommandIoReminders: [], pendingCommandIoReminders: [],
@@ -63,10 +55,6 @@ export function syncReminderStateFromContextTracker(
state: SharedReminderState, state: SharedReminderState,
contextTracker: ContextTracker, contextTracker: ContextTracker,
): void { ): void {
if (contextTracker.pendingSkillsReinject) {
state.pendingSkillsReinject = true;
contextTracker.pendingSkillsReinject = false;
}
if (contextTracker.pendingReflectionTrigger) { if (contextTracker.pendingReflectionTrigger) {
state.pendingReflectionTrigger = true; state.pendingReflectionTrigger = true;
contextTracker.pendingReflectionTrigger = false; contextTracker.pendingReflectionTrigger = false;

View 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);
});
});

View 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",
},
]);
});
});

View File

@@ -107,5 +107,24 @@ describe.skipIf(process.platform === "win32")(
result.errors.some((error) => error.path.includes("broken-link")), result.errors.some((error) => error.path.includes("broken-link")),
).toBe(true); ).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",
]);
});
}, },
); );

View File

@@ -66,4 +66,53 @@ describe("Skills formatting (system reminder)", () => {
expect(result).toContain("project-skill (project)"); expect(result).toContain("project-skill (project)");
expect(result).toContain("global-skill (global)"); 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);
});
}); });

View File

@@ -102,7 +102,6 @@ describe("accumulator usage statistics", () => {
); );
expect(tracker.pendingCompaction).toBe(true); expect(tracker.pendingCompaction).toBe(true);
expect(tracker.pendingSkillsReinject).toBe(true);
expect(tracker.pendingReflectionTrigger).toBe(true); expect(tracker.pendingReflectionTrigger).toBe(true);
}); });
@@ -126,7 +125,6 @@ describe("accumulator usage statistics", () => {
); );
expect(tracker.pendingCompaction).toBe(true); expect(tracker.pendingCompaction).toBe(true);
expect(tracker.pendingSkillsReinject).toBe(true);
expect(tracker.pendingReflectionTrigger).toBe(true); expect(tracker.pendingReflectionTrigger).toBe(true);
}); });

View File

@@ -12,7 +12,6 @@ describe("contextTracker", () => {
{ timestamp: 1, tokens: 111, turnId: 1, compacted: true }, { timestamp: 1, tokens: 111, turnId: 1, compacted: true },
]; ];
tracker.pendingCompaction = true; tracker.pendingCompaction = true;
tracker.pendingSkillsReinject = true;
tracker.pendingReflectionTrigger = true; tracker.pendingReflectionTrigger = true;
tracker.currentTurnId = 9; tracker.currentTurnId = 9;
@@ -21,7 +20,6 @@ describe("contextTracker", () => {
expect(tracker.lastContextTokens).toBe(0); expect(tracker.lastContextTokens).toBe(0);
expect(tracker.contextTokensHistory).toEqual([]); expect(tracker.contextTokensHistory).toEqual([]);
expect(tracker.pendingCompaction).toBe(false); expect(tracker.pendingCompaction).toBe(false);
expect(tracker.pendingSkillsReinject).toBe(false);
expect(tracker.pendingReflectionTrigger).toBe(false); expect(tracker.pendingReflectionTrigger).toBe(false);
expect(tracker.currentTurnId).toBe(9); expect(tracker.currentTurnId).toBe(9);
}); });

View File

@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
describe("headless client skills wiring", () => {
test("pre-load-skills resolves skill paths from client-skills helper", () => {
const headlessPath = fileURLToPath(
new URL("../../headless.ts", import.meta.url),
);
const source = readFileSync(headlessPath, "utf-8");
expect(source).toContain("buildClientSkillsPayload({");
expect(source).toContain(
"const { skillPathById } = await buildClientSkillsPayload",
);
expect(source).toContain("const skillPath = skillPathById[skillId]");
expect(source).not.toContain("sharedReminderState.skillPathById");
});
});

View File

@@ -1,54 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { SharedReminderContext } from "../../reminders/engine";
import { sharedReminderProviders } from "../../reminders/engine";
import { createSharedReminderState } from "../../reminders/state";
function buildContext(): SharedReminderContext {
return {
mode: "interactive",
agent: {
id: "agent-1",
name: "Agent 1",
description: null,
lastRunAt: null,
},
state: createSharedReminderState(),
sessionContextReminderEnabled: true,
reflectionSettings: {
trigger: "off",
behavior: "reminder",
stepCount: 25,
},
skillSources: ["bundled"],
resolvePlanModeReminder: () => "",
};
}
describe("shared skills reminder", () => {
test("recovers from discovery failure and reinjects after next successful discovery", async () => {
const provider = sharedReminderProviders.skills;
const context = buildContext();
const mutableProcess = process as typeof process & { cwd: () => string };
const originalCwd = mutableProcess.cwd;
try {
mutableProcess.cwd = () => {
throw new Error("cwd unavailable for test");
};
const first = await provider(context);
expect(first).toBeNull();
expect(context.state.hasInjectedSkillsReminder).toBe(true);
expect(context.state.cachedSkillsReminder).toBe("");
} finally {
mutableProcess.cwd = originalCwd;
}
const second = await provider(context);
expect(second).not.toBeNull();
expect(context.state.pendingSkillsReinject).toBe(false);
if (second) {
expect(second).toContain("<system-reminder>");
}
});
});

View File

@@ -15,7 +15,7 @@ How to invoke:
- `skill: "ms-office-suite:pdf"` - invoke using fully qualified name - `skill: "ms-office-suite:pdf"` - invoke using fully qualified name
Important: Important:
- Available skills are listed in system-reminder messages in the conversation - Available skills are included in your current prompt context in the conversation
- When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task - When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task
- NEVER mention a skill without actually calling this tool - NEVER mention a skill without actually calling this tool
- Do not invoke a skill that is already running - Do not invoke a skill that is already running