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:

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

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")),
).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("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.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);
});

View File

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

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
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