feat: add client side skills (#1320)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
101
src/agent/clientSkills.ts
Normal file
101
src/agent/clientSkills.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8182,9 +8182,8 @@ export default function App({
|
||||
cmd.finish(outputLines.join("\n"), true);
|
||||
|
||||
// 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.pendingSkillsReinject = true;
|
||||
} catch (error) {
|
||||
let errorOutput: string;
|
||||
|
||||
|
||||
@@ -73,13 +73,16 @@ export function createCommandRunner({
|
||||
onCommandFinished,
|
||||
}: RunnerDeps) {
|
||||
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 = {
|
||||
id,
|
||||
input,
|
||||
update: null!,
|
||||
finish: null!,
|
||||
fail: null!,
|
||||
update: uninitialized,
|
||||
finish: uninitialized,
|
||||
fail: uninitialized,
|
||||
};
|
||||
|
||||
const update = (updateData: CommandUpdate) => {
|
||||
|
||||
@@ -454,7 +454,6 @@ function extractTextPart(v: unknown): string {
|
||||
function markCompactionCompleted(ctx?: ContextTracker): void {
|
||||
if (!ctx) return;
|
||||
ctx.pendingCompaction = true;
|
||||
ctx.pendingSkillsReinject = true;
|
||||
ctx.pendingReflectionTrigger = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ export type ContextTracker = {
|
||||
currentTurnId: number;
|
||||
/** Set when a compaction event is seen; consumed by the next usage_statistics push */
|
||||
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 */
|
||||
pendingReflectionTrigger: boolean;
|
||||
};
|
||||
@@ -28,7 +26,6 @@ export function createContextTracker(): ContextTracker {
|
||||
contextTokensHistory: [],
|
||||
currentTurnId: 0, // simple in-memory counter for now
|
||||
pendingCompaction: false,
|
||||
pendingSkillsReinject: false,
|
||||
pendingReflectionTrigger: false,
|
||||
};
|
||||
}
|
||||
@@ -38,6 +35,5 @@ export function resetContextHistory(ct: ContextTracker): void {
|
||||
ct.lastContextTokens = 0;
|
||||
ct.contextTokensHistory = [];
|
||||
ct.pendingCompaction = false;
|
||||
ct.pendingSkillsReinject = false;
|
||||
ct.pendingReflectionTrigger = false;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./agent/approval-recovery";
|
||||
import { handleBootstrapSessionState } from "./agent/bootstrapHandler";
|
||||
import { getClient } from "./agent/client";
|
||||
import { buildClientSkillsPayload } from "./agent/clientSkills";
|
||||
import { setAgentContext, setConversationId } from "./agent/context";
|
||||
import { createAgent } from "./agent/create";
|
||||
import { handleListMessages } from "./agent/listMessagesHandler";
|
||||
@@ -1459,13 +1460,22 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
// Pre-load specific skills' full content (used by subagents with skills: field)
|
||||
if (preLoadSkillsRaw) {
|
||||
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
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const loadedContents: string[] = [];
|
||||
for (const skillId of skillIds) {
|
||||
const skillPath = sharedReminderState.skillPathById[skillId];
|
||||
const skillPath = skillPathById[skillId];
|
||||
if (!skillPath) continue;
|
||||
try {
|
||||
const content = await readFileAsync(skillPath, "utf-8");
|
||||
|
||||
@@ -7,7 +7,6 @@ export type SharedReminderMode =
|
||||
export type SharedReminderId =
|
||||
| "session-context"
|
||||
| "agent-info"
|
||||
| "skills"
|
||||
| "permission-mode"
|
||||
| "plan-mode"
|
||||
| "reflection-step-count"
|
||||
@@ -40,11 +39,6 @@ export const SHARED_REMINDER_CATALOG: ReadonlyArray<SharedReminderDefinition> =
|
||||
"subagent",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "skills",
|
||||
description: "Available skills system reminder (with reinjection)",
|
||||
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
|
||||
},
|
||||
{
|
||||
id: "permission-mode",
|
||||
description: "Permission mode reminder",
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { join } from "node:path";
|
||||
import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents";
|
||||
import { getSkillsDirectory } from "../agent/context";
|
||||
import {
|
||||
discoverSkills,
|
||||
formatSkillsAsSystemReminder,
|
||||
SKILLS_DIR,
|
||||
type SkillSource,
|
||||
} from "../agent/skills";
|
||||
import type { SkillSource } from "../agent/skills";
|
||||
import { buildAgentInfo } from "../cli/helpers/agentInfo";
|
||||
import {
|
||||
buildCompactionMemoryReminder,
|
||||
@@ -101,52 +94,6 @@ async function buildSessionContextReminder(
|
||||
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(
|
||||
context: SharedReminderContext,
|
||||
): Promise<string | null> {
|
||||
@@ -398,7 +345,6 @@ export const sharedReminderProviders: Record<
|
||||
> = {
|
||||
"agent-info": buildAgentInfoReminder,
|
||||
"session-context": buildSessionContextReminder,
|
||||
skills: buildSkillsReminder,
|
||||
"permission-mode": buildPermissionModeReminder,
|
||||
"plan-mode": buildPlanModeReminder,
|
||||
"reflection-step-count": buildReflectionStepReminder,
|
||||
|
||||
@@ -22,12 +22,8 @@ export interface ToolsetChangeReminder {
|
||||
export interface SharedReminderState {
|
||||
hasSentAgentInfo: boolean;
|
||||
hasSentSessionContext: boolean;
|
||||
hasInjectedSkillsReminder: boolean;
|
||||
cachedSkillsReminder: string | null;
|
||||
skillPathById: Record<string, string>;
|
||||
lastNotifiedPermissionMode: PermissionMode | null;
|
||||
turnCount: number;
|
||||
pendingSkillsReinject: boolean;
|
||||
pendingReflectionTrigger: boolean;
|
||||
pendingAutoInitReminder: boolean;
|
||||
pendingCommandIoReminders: CommandIoReminder[];
|
||||
@@ -40,12 +36,8 @@ export function createSharedReminderState(): SharedReminderState {
|
||||
return {
|
||||
hasSentAgentInfo: false,
|
||||
hasSentSessionContext: false,
|
||||
hasInjectedSkillsReminder: false,
|
||||
cachedSkillsReminder: null,
|
||||
skillPathById: {},
|
||||
lastNotifiedPermissionMode: null,
|
||||
turnCount: 0,
|
||||
pendingSkillsReinject: false,
|
||||
pendingReflectionTrigger: false,
|
||||
pendingAutoInitReminder: false,
|
||||
pendingCommandIoReminders: [],
|
||||
@@ -63,10 +55,6 @@ export function syncReminderStateFromContextTracker(
|
||||
state: SharedReminderState,
|
||||
contextTracker: ContextTracker,
|
||||
): void {
|
||||
if (contextTracker.pendingSkillsReinject) {
|
||||
state.pendingSkillsReinject = true;
|
||||
contextTracker.pendingSkillsReinject = false;
|
||||
}
|
||||
if (contextTracker.pendingReflectionTrigger) {
|
||||
state.pendingReflectionTrigger = true;
|
||||
contextTracker.pendingReflectionTrigger = false;
|
||||
|
||||
88
src/tests/agent/clientSkills.test.ts
Normal file
88
src/tests/agent/clientSkills.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
src/tests/agent/message-client-skills.test.ts
Normal file
35
src/tests/agent/message-client-skills.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -107,5 +107,24 @@ describe.skipIf(process.platform === "win32")(
|
||||
result.errors.some((error) => error.path.includes("broken-link")),
|
||||
).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",
|
||||
]);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -66,4 +66,53 @@ describe("Skills formatting (system reminder)", () => {
|
||||
expect(result).toContain("project-skill (project)");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,6 @@ describe("accumulator usage statistics", () => {
|
||||
);
|
||||
|
||||
expect(tracker.pendingCompaction).toBe(true);
|
||||
expect(tracker.pendingSkillsReinject).toBe(true);
|
||||
expect(tracker.pendingReflectionTrigger).toBe(true);
|
||||
});
|
||||
|
||||
@@ -126,7 +125,6 @@ describe("accumulator usage statistics", () => {
|
||||
);
|
||||
|
||||
expect(tracker.pendingCompaction).toBe(true);
|
||||
expect(tracker.pendingSkillsReinject).toBe(true);
|
||||
expect(tracker.pendingReflectionTrigger).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ describe("contextTracker", () => {
|
||||
{ timestamp: 1, tokens: 111, turnId: 1, compacted: true },
|
||||
];
|
||||
tracker.pendingCompaction = true;
|
||||
tracker.pendingSkillsReinject = true;
|
||||
tracker.pendingReflectionTrigger = true;
|
||||
tracker.currentTurnId = 9;
|
||||
|
||||
@@ -21,7 +20,6 @@ describe("contextTracker", () => {
|
||||
expect(tracker.lastContextTokens).toBe(0);
|
||||
expect(tracker.contextTokensHistory).toEqual([]);
|
||||
expect(tracker.pendingCompaction).toBe(false);
|
||||
expect(tracker.pendingSkillsReinject).toBe(false);
|
||||
expect(tracker.pendingReflectionTrigger).toBe(false);
|
||||
expect(tracker.currentTurnId).toBe(9);
|
||||
});
|
||||
|
||||
19
src/tests/headless/client-skills-wiring.test.ts
Normal file
19
src/tests/headless/client-skills-wiring.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ How to invoke:
|
||||
- `skill: "ms-office-suite:pdf"` - invoke using fully qualified name
|
||||
|
||||
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
|
||||
- NEVER mention a skill without actually calling this tool
|
||||
- Do not invoke a skill that is already running
|
||||
|
||||
Reference in New Issue
Block a user