From 6a2b2f63468234cb45fe7698f0cd3237a137a860 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 16 Feb 2026 23:01:03 -0800 Subject: [PATCH] feat: add skill source controls and headless reminder settings (#989) --- src/agent/context.ts | 25 ++- src/agent/skillSources.ts | 82 ++++++++++ src/agent/skills.ts | 43 +++-- src/cli/App.tsx | 13 +- src/cli/components/SkillsDialog.tsx | 4 +- src/headless.ts | 224 +++++++++++++++++++++++++- src/index.ts | 46 +++++- src/tests/agent/skill-sources.test.ts | 51 ++++++ src/types/protocol.ts | 5 + 9 files changed, 459 insertions(+), 34 deletions(-) create mode 100644 src/agent/skillSources.ts create mode 100644 src/tests/agent/skill-sources.test.ts diff --git a/src/agent/context.ts b/src/agent/context.ts index 8de4b18..c95c87d 100644 --- a/src/agent/context.ts +++ b/src/agent/context.ts @@ -3,10 +3,13 @@ * This allows tools to access the current agent ID without threading it through params. */ +import { ALL_SKILL_SOURCES } from "./skillSources"; +import type { SkillSource } from "./skills"; + interface AgentContext { agentId: string | null; skillsDirectory: string | null; - noSkills: boolean; + skillSources: SkillSource[]; conversationId: string | null; } @@ -24,7 +27,7 @@ function getContext(): AgentContext { global[CONTEXT_KEY] = { agentId: null, skillsDirectory: null, - noSkills: false, + skillSources: [...ALL_SKILL_SOURCES], conversationId: null, }; } @@ -37,16 +40,17 @@ const context = getContext(); * Set the current agent context * @param agentId - The agent ID * @param skillsDirectory - Optional skills directory path - * @param noSkills - Whether to skip bundled skills + * @param skillSources - Enabled skill sources for this session */ export function setAgentContext( agentId: string, skillsDirectory?: string, - noSkills?: boolean, + skillSources?: SkillSource[], ): void { context.agentId = agentId; context.skillsDirectory = skillsDirectory || null; - context.noSkills = noSkills ?? false; + context.skillSources = + skillSources !== undefined ? [...skillSources] : [...ALL_SKILL_SOURCES]; } /** @@ -76,10 +80,17 @@ export function getSkillsDirectory(): string | null { } /** - * Get whether bundled skills should be skipped + * Get enabled skill sources for discovery/injection. + */ +export function getSkillSources(): SkillSource[] { + return [...context.skillSources]; +} + +/** + * Backwards-compat helper: returns true when bundled skills are disabled. */ export function getNoSkills(): boolean { - return context.noSkills; + return !context.skillSources.includes("bundled"); } /** diff --git a/src/agent/skillSources.ts b/src/agent/skillSources.ts new file mode 100644 index 0000000..6bdcc36 --- /dev/null +++ b/src/agent/skillSources.ts @@ -0,0 +1,82 @@ +import type { SkillSource } from "./skills"; + +export const ALL_SKILL_SOURCES: SkillSource[] = [ + "bundled", + "global", + "agent", + "project", +]; + +export type SkillSourceSpecifier = SkillSource | "all"; + +export type SkillSourceSelectionInput = { + skillSourcesRaw?: string; + noSkills?: boolean; + noBundledSkills?: boolean; +}; + +const VALID_SKILL_SOURCE_SPECIFIERS: SkillSourceSpecifier[] = [ + "all", + ...ALL_SKILL_SOURCES, +]; + +function isSkillSource(value: string): value is SkillSource { + return ALL_SKILL_SOURCES.includes(value as SkillSource); +} + +function normalizeSkillSources(sources: SkillSource[]): SkillSource[] { + const sourceSet = new Set(sources); + return ALL_SKILL_SOURCES.filter((source) => sourceSet.has(source)); +} + +export function parseSkillSourcesList(skillSourcesRaw: string): SkillSource[] { + const tokens = skillSourcesRaw + .split(",") + .map((source) => source.trim()) + .filter((source) => source.length > 0); + + if (tokens.length === 0) { + throw new Error( + "--skill-sources must include at least one source (e.g. bundled,project)", + ); + } + + const sources: SkillSource[] = []; + for (const token of tokens) { + const source = token as SkillSourceSpecifier; + if (!VALID_SKILL_SOURCE_SPECIFIERS.includes(source)) { + throw new Error( + `Invalid skill source "${token}". Valid values: ${VALID_SKILL_SOURCE_SPECIFIERS.join(", ")}`, + ); + } + + if (source === "all") { + sources.push(...ALL_SKILL_SOURCES); + continue; + } + + if (isSkillSource(source)) { + sources.push(source); + } + } + + return normalizeSkillSources(sources); +} + +export function resolveSkillSourcesSelection( + input: SkillSourceSelectionInput, +): SkillSource[] { + if (input.noSkills) { + return []; + } + + const configuredSources = input.skillSourcesRaw + ? parseSkillSourcesList(input.skillSourcesRaw) + : [...ALL_SKILL_SOURCES]; + + const filteredSources = input.noBundledSkills + ? configuredSources.filter((source) => source !== "bundled") + : configuredSources; + + return normalizeSkillSources(filteredSources); +} diff --git a/src/agent/skills.ts b/src/agent/skills.ts index 29766fc..830c31e 100644 --- a/src/agent/skills.ts +++ b/src/agent/skills.ts @@ -13,6 +13,7 @@ import { readdir, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseFrontmatter } from "../utils/frontmatter"; +import { ALL_SKILL_SOURCES } from "./skillSources"; /** * Get the bundled skills directory path @@ -69,6 +70,11 @@ export interface SkillDiscoveryResult { errors: SkillDiscoveryError[]; } +export interface SkillDiscoveryOptions { + skipBundled?: boolean; + sources?: SkillSource[]; +} + /** * Represents an error that occurred during skill discovery */ @@ -167,13 +173,15 @@ async function discoverSkillsFromDir( export async function discoverSkills( projectSkillsPath: string = join(process.cwd(), SKILLS_DIR), agentId?: string, - options?: { skipBundled?: boolean }, + options?: SkillDiscoveryOptions, ): Promise { const allErrors: SkillDiscoveryError[] = []; const skillsById = new Map(); + const sourceSet = new Set(options?.sources ?? ALL_SKILL_SOURCES); + const includeSource = (source: SkillSource) => sourceSet.has(source); // 1. Start with bundled skills (lowest priority) - if (!options?.skipBundled) { + if (includeSource("bundled") && !options?.skipBundled) { const bundledSkills = await getBundledSkills(); for (const skill of bundledSkills) { skillsById.set(skill.id, skill); @@ -181,14 +189,19 @@ export async function discoverSkills( } // 2. Add global skills (override bundled) - const globalResult = await discoverSkillsFromDir(GLOBAL_SKILLS_DIR, "global"); - allErrors.push(...globalResult.errors); - for (const skill of globalResult.skills) { - skillsById.set(skill.id, skill); + if (includeSource("global")) { + const globalResult = await discoverSkillsFromDir( + GLOBAL_SKILLS_DIR, + "global", + ); + allErrors.push(...globalResult.errors); + for (const skill of globalResult.skills) { + skillsById.set(skill.id, skill); + } } // 3. Add agent skills if agentId provided (override global) - if (agentId) { + if (agentId && includeSource("agent")) { const agentSkillsDir = getAgentSkillsDir(agentId); const agentResult = await discoverSkillsFromDir(agentSkillsDir, "agent"); allErrors.push(...agentResult.errors); @@ -198,13 +211,15 @@ export async function discoverSkills( } // 4. Add project skills (override all - highest priority) - const projectResult = await discoverSkillsFromDir( - projectSkillsPath, - "project", - ); - allErrors.push(...projectResult.errors); - for (const skill of projectResult.skills) { - skillsById.set(skill.id, skill); + if (includeSource("project")) { + const projectResult = await discoverSkillsFromDir( + projectSkillsPath, + "project", + ); + allErrors.push(...projectResult.errors); + for (const skill of projectResult.skills) { + skillsById.set(skill.id, skill); + } } return { diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3cd5edc..65c783e 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -806,6 +806,7 @@ export default function App({ showCompactions = false, agentProvenance = null, releaseNotes = null, + sessionContextReminderEnabled = true, }: { agentId: string; agentState?: AgentState | null; @@ -825,6 +826,7 @@ export default function App({ showCompactions?: boolean; agentProvenance?: AgentProvenance | null; releaseNotes?: string | null; // Markdown release notes to display above header + sessionContextReminderEnabled?: boolean; }) { // Warm the model-access cache in the background so /model is fast on first open. useEffect(() => { @@ -8016,7 +8018,11 @@ ${SYSTEM_REMINDER_CLOSE}`; const sessionContextEnabled = settingsManager.getSetting( "sessionContextEnabled", ); - if (!hasSentSessionContextRef.current && sessionContextEnabled) { + if ( + !hasSentSessionContextRef.current && + sessionContextEnabled && + sessionContextReminderEnabled + ) { const { buildSessionContext } = await import( "./helpers/sessionContext" ); @@ -8168,7 +8174,7 @@ ${SYSTEM_REMINDER_CLOSE} SKILLS_DIR: defaultDir, formatSkillsAsSystemReminder, } = await import("../agent/skills"); - const { getSkillsDirectory, getNoSkills } = await import( + const { getSkillsDirectory, getSkillSources } = await import( "../agent/context" ); @@ -8181,7 +8187,7 @@ ${SYSTEM_REMINDER_CLOSE} const skillsDir = getSkillsDirectory() || join(process.cwd(), defaultDir); const { skills } = await discover(skillsDir, agentId, { - skipBundled: getNoSkills(), + sources: getSkillSources(), }); latestSkills = skills; } catch { @@ -8895,6 +8901,7 @@ ${SYSTEM_REMINDER_CLOSE} pendingRalphConfig, openTrajectorySegment, resetTrajectoryBases, + sessionContextReminderEnabled, appendTaskNotificationEvents, ], ); diff --git a/src/cli/components/SkillsDialog.tsx b/src/cli/components/SkillsDialog.tsx index 98a951a..9e6fd1c 100644 --- a/src/cli/components/SkillsDialog.tsx +++ b/src/cli/components/SkillsDialog.tsx @@ -52,14 +52,14 @@ export function SkillsDialog({ onClose, agentId }: SkillsDialogProps) { const { discoverSkills, SKILLS_DIR } = await import( "../../agent/skills" ); - const { getSkillsDirectory, getNoSkills } = await import( + const { getSkillsDirectory, getSkillSources } = await import( "../../agent/context" ); const { join } = await import("node:path"); const skillsDir = getSkillsDirectory() || join(process.cwd(), SKILLS_DIR); const result = await discoverSkills(skillsDir, agentId, { - skipBundled: getNoSkills(), + sources: getSkillSources(), }); setSkills(result.skills); } catch { diff --git a/src/headless.ts b/src/headless.ts index 8e032c4..b3508b1 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -21,9 +21,10 @@ import { getClient } from "./agent/client"; import { setAgentContext, setConversationId } from "./agent/context"; import { createAgent } from "./agent/create"; import { ISOLATED_BLOCK_LABELS } from "./agent/memory"; - import { sendMessageStream } from "./agent/message"; import { getModelUpdateArgs } from "./agent/model"; +import { resolveSkillSourcesSelection } from "./agent/skillSources"; +import type { SkillSource } from "./agent/skills"; import { SessionStats } from "./agent/stats"; import { createBuffers, @@ -33,6 +34,13 @@ import { } from "./cli/helpers/accumulator"; import { classifyApprovals } from "./cli/helpers/approvalClassification"; import { formatErrorDetails } from "./cli/helpers/errorFormatter"; +import { + getReflectionSettings, + type ReflectionBehavior, + type ReflectionSettings, + type ReflectionTrigger, + reflectionSettingsToLegacyMode, +} from "./cli/helpers/memoryReminder"; import { type DrainStreamHook, drainStreamWithResume, @@ -113,11 +121,128 @@ export function shouldReinjectSkillsAfterCompaction(lines: Line[]): boolean { ); } +type ReflectionOverrides = { + trigger?: ReflectionTrigger; + behavior?: ReflectionBehavior; + stepCount?: number; +}; + +function parseReflectionOverrides( + values: Record, +): ReflectionOverrides { + const triggerRaw = values["reflection-trigger"] as string | undefined; + const behaviorRaw = values["reflection-behavior"] as string | undefined; + const stepCountRaw = values["reflection-step-count"] as string | undefined; + + if (!triggerRaw && !behaviorRaw && !stepCountRaw) { + return {}; + } + + const overrides: ReflectionOverrides = {}; + + if (triggerRaw !== undefined) { + if ( + triggerRaw !== "off" && + triggerRaw !== "step-count" && + triggerRaw !== "compaction-event" + ) { + throw new Error( + `Invalid --reflection-trigger "${triggerRaw}". Valid values: off, step-count, compaction-event`, + ); + } + overrides.trigger = triggerRaw; + } + + if (behaviorRaw !== undefined) { + if (behaviorRaw !== "reminder" && behaviorRaw !== "auto-launch") { + throw new Error( + `Invalid --reflection-behavior "${behaviorRaw}". Valid values: reminder, auto-launch`, + ); + } + overrides.behavior = behaviorRaw; + } + + if (stepCountRaw !== undefined) { + const parsed = Number.parseInt(stepCountRaw, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error( + `Invalid --reflection-step-count "${stepCountRaw}". Expected a positive integer.`, + ); + } + overrides.stepCount = parsed; + } + + return overrides; +} + +function hasReflectionOverrides(overrides: ReflectionOverrides): boolean { + return ( + overrides.trigger !== undefined || + overrides.behavior !== undefined || + overrides.stepCount !== undefined + ); +} + +async function applyReflectionOverrides( + agentId: string, + overrides: ReflectionOverrides, +): Promise { + const current = getReflectionSettings(); + const merged: ReflectionSettings = { + trigger: overrides.trigger ?? current.trigger, + behavior: overrides.behavior ?? current.behavior, + stepCount: overrides.stepCount ?? current.stepCount, + }; + + if (!hasReflectionOverrides(overrides)) { + return merged; + } + + const memfsEnabled = settingsManager.isMemfsEnabled(agentId); + if (!memfsEnabled && merged.trigger === "compaction-event") { + throw new Error( + "--reflection-trigger compaction-event requires memfs enabled for this agent.", + ); + } + if ( + !memfsEnabled && + merged.trigger !== "off" && + merged.behavior === "auto-launch" + ) { + throw new Error( + "--reflection-behavior auto-launch requires memfs enabled for this agent.", + ); + } + + try { + settingsManager.getLocalProjectSettings(); + } catch { + await settingsManager.loadLocalProjectSettings(); + } + + const legacyMode = reflectionSettingsToLegacyMode(merged); + settingsManager.updateLocalProjectSettings({ + memoryReminderInterval: legacyMode, + reflectionTrigger: merged.trigger, + reflectionBehavior: merged.behavior, + reflectionStepCount: merged.stepCount, + }); + settingsManager.updateSettings({ + memoryReminderInterval: legacyMode, + reflectionTrigger: merged.trigger, + reflectionBehavior: merged.behavior, + reflectionStepCount: merged.stepCount, + }); + + return merged; +} + export async function handleHeadlessCommand( argv: string[], model?: string, - skillsDirectory?: string, - noSkills?: boolean, + skillsDirectoryOverride?: string, + skillSourcesOverride?: SkillSource[], + systemInfoReminderEnabledOverride?: boolean, ) { // Parse CLI args // Include all flags from index.ts to prevent them from being treated as positionals @@ -155,6 +280,7 @@ export async function handleHeadlessCommand( "permission-mode": { type: "string" }, yolo: { type: "boolean" }, skills: { type: "string" }, + "skill-sources": { type: "string" }, "pre-load-skills": { type: "string" }, "init-blocks": { type: "string" }, "base-tools": { type: "string" }, @@ -164,6 +290,11 @@ export async function handleHeadlessCommand( memfs: { type: "boolean" }, "no-memfs": { type: "boolean" }, "no-skills": { type: "boolean" }, + "no-bundled-skills": { type: "boolean" }, + "no-system-info-reminder": { type: "boolean" }, + "reflection-trigger": { type: "string" }, + "reflection-behavior": { type: "string" }, + "reflection-step-count": { type: "string" }, "max-turns": { type: "string" }, // Maximum number of agentic turns }, strict: false, @@ -299,6 +430,13 @@ export async function handleHeadlessCommand( const blockValueArgs = values["block-value"] as string[] | undefined; const initBlocksRaw = values["init-blocks"] as string | undefined; const baseToolsRaw = values["base-tools"] as string | undefined; + const skillsDirectory = + (values.skills as string | undefined) ?? skillsDirectoryOverride; + const noSkillsFlag = values["no-skills"] as boolean | undefined; + const noBundledSkillsFlag = values["no-bundled-skills"] as + | boolean + | undefined; + const skillSourcesRaw = values["skill-sources"] as string | undefined; const memfsFlag = values.memfs as boolean | undefined; const noMemfsFlag = values["no-memfs"] as boolean | undefined; const requestedMemoryPromptMode: "memfs" | "standard" | undefined = memfsFlag @@ -309,8 +447,38 @@ export async function handleHeadlessCommand( const shouldAutoEnableMemfsForNewAgent = !memfsFlag && !noMemfsFlag; const fromAfFile = values["from-af"] as string | undefined; const preLoadSkillsRaw = values["pre-load-skills"] as string | undefined; + const systemInfoReminderEnabled = + systemInfoReminderEnabledOverride ?? + !(values["no-system-info-reminder"] as boolean | undefined); + const reflectionOverrides = (() => { + try { + return parseReflectionOverrides(values); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); + process.exit(1); + } + })(); const maxTurnsRaw = values["max-turns"] as string | undefined; const tagsRaw = values.tags as string | undefined; + const resolvedSkillSources = (() => { + if (skillSourcesOverride) { + return skillSourcesOverride; + } + try { + return resolveSkillSourcesSelection({ + skillSourcesRaw, + noSkills: noSkillsFlag, + noBundledSkills: noBundledSkillsFlag, + }); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); + process.exit(1); + } + })(); // Parse and validate base tools let tags: string[] | undefined; @@ -339,6 +507,13 @@ export async function handleHeadlessCommand( maxTurns = parsed; } + if (preLoadSkillsRaw && resolvedSkillSources.length === 0) { + console.error( + "Error: --pre-load-skills cannot be used when all skill sources are disabled.", + ); + process.exit(1); + } + // Handle --conv {agent-id} shorthand: --conv agent-xyz → --agent agent-xyz --conv default if (specifiedConversationId?.startsWith("agent-")) { if (specifiedAgentId && specifiedAgentId !== specifiedConversationId) { @@ -748,6 +923,7 @@ export async function handleHeadlessCommand( // Determine which conversation to use let conversationId: string; + let effectiveReflectionSettings: ReflectionSettings; const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent"; @@ -773,6 +949,18 @@ export async function handleHeadlessCommand( process.exit(1); } + try { + effectiveReflectionSettings = await applyReflectionOverrides( + agent.id, + reflectionOverrides, + ); + } catch (error) { + console.error( + `Failed to apply sleeptime settings: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + // Determine which blocks to isolate for the conversation const isolatedBlockLabels: string[] = initBlocks === undefined @@ -894,7 +1082,7 @@ export async function handleHeadlessCommand( } // Set agent context for tools that need it (e.g., Skill tool, Task tool) - setAgentContext(agent.id, skillsDirectory, noSkills); + setAgentContext(agent.id, skillsDirectory, resolvedSkillSources); // Validate output format const outputFormat = @@ -929,6 +1117,9 @@ export async function handleHeadlessCommand( outputFormat, includePartialMessages, availableTools, + resolvedSkillSources, + systemInfoReminderEnabled, + effectiveReflectionSettings, ); return; } @@ -957,6 +1148,11 @@ export async function handleHeadlessCommand( permission_mode: "", slash_commands: [], memfs_enabled: settingsManager.isMemfsEnabled(agent.id), + skill_sources: resolvedSkillSources, + system_info_reminder_enabled: systemInfoReminderEnabled, + reflection_trigger: effectiveReflectionSettings.trigger, + reflection_behavior: effectiveReflectionSettings.behavior, + reflection_step_count: effectiveReflectionSettings.stepCount, uuid: `init-${agent.id}`, }; console.log(JSON.stringify(initEvent)); @@ -1160,7 +1356,7 @@ ${SYSTEM_REMINDER_CLOSE} try { const skillsDir = getSkillsDirectory() || join(process.cwd(), defaultDir); const { skills } = await discoverSkills(skillsDir, agent.id, { - skipBundled: noSkills, + sources: resolvedSkillSources, }); const skillsReminder = formatSkillsAsSystemReminder(skills); if (skillsReminder) { @@ -2027,6 +2223,9 @@ async function runBidirectionalMode( _outputFormat: string, includePartialMessages: boolean, availableTools: string[], + skillSources: SkillSource[], + systemInfoReminderEnabled: boolean, + reflectionSettings: ReflectionSettings, ): Promise { const sessionId = agent.id; const readline = await import("node:readline"); @@ -2042,6 +2241,11 @@ async function runBidirectionalMode( tools: availableTools, cwd: process.cwd(), memfs_enabled: settingsManager.isMemfsEnabled(agent.id), + skill_sources: skillSources, + system_info_reminder_enabled: systemInfoReminderEnabled, + reflection_trigger: reflectionSettings.trigger, + reflection_behavior: reflectionSettings.behavior, + reflection_step_count: reflectionSettings.stepCount, uuid: `init-${agent.id}`, }; console.log(JSON.stringify(initEvent)); @@ -2355,6 +2559,12 @@ async function runBidirectionalMode( agent_id: agent.id, model: agent.llm_config?.model, tools: availableTools, + memfs_enabled: settingsManager.isMemfsEnabled(agent.id), + skill_sources: skillSources, + system_info_reminder_enabled: systemInfoReminderEnabled, + reflection_trigger: reflectionSettings.trigger, + reflection_behavior: reflectionSettings.behavior, + reflection_step_count: reflectionSettings.stepCount, }, }, session_id: sessionId, @@ -2485,7 +2695,9 @@ async function runBidirectionalMode( const { join } = await import("node:path"); const skillsDir = getSkillsDirectory() || join(process.cwd(), defaultDir); - const { skills } = await discover(skillsDir, agent.id); + const { skills } = await discover(skillsDir, agent.id, { + sources: skillSources, + }); const latestSkillsReminder = formatSkillsAsSystemReminder(skills); // Trigger reinjection when the available-skills block changed on disk. diff --git a/src/index.ts b/src/index.ts index af4c1e5..390543a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { import type { AgentProvenance } from "./agent/create"; import { getLettaCodeHeaders } from "./agent/http-headers"; import { ISOLATED_BLOCK_LABELS } from "./agent/memory"; +import { resolveSkillSourcesSelection } from "./agent/skillSources"; import { LETTA_CLOUD_API_URL } from "./auth/oauth"; import { ConversationSelector } from "./cli/components/ConversationSelector"; import type { ApprovalRequest } from "./cli/helpers/stream"; @@ -78,10 +79,21 @@ OPTIONS Emit stream_event wrappers for each chunk (stream-json only) --from-agent Inject agent-to-agent system reminder (headless mode) --skills Custom path to skills directory (default: .skills in current directory) + --skill-sources Skill sources: all,bundled,global,agent,project (default: all) + --no-skills Disable all skill sources + --no-bundled-skills Disable bundled skills only --import Create agent from an AgentFile (.af) template Use @author/name to import from the agent registry --memfs Enable memory filesystem for this agent --no-memfs Disable memory filesystem for this agent + --no-system-info-reminder + Disable first-turn environment reminder (device/git/cwd context) + --reflection-trigger + Sleeptime trigger: off, step-count, compaction-event + --reflection-behavior + Sleeptime behavior: reminder, auto-launch + --reflection-step-count + Sleeptime step-count interval (positive integer) SUBCOMMANDS (JSON-only) letta memfs status --agent @@ -423,6 +435,7 @@ async function main(): Promise { "include-partial-messages": { type: "boolean" }, "from-agent": { type: "string" }, skills: { type: "string" }, + "skill-sources": { type: "string" }, "pre-load-skills": { type: "string" }, "from-af": { type: "string" }, import: { type: "string" }, @@ -431,6 +444,11 @@ async function main(): Promise { memfs: { type: "boolean" }, "no-memfs": { type: "boolean" }, "no-skills": { type: "boolean" }, + "no-bundled-skills": { type: "boolean" }, + "no-system-info-reminder": { type: "boolean" }, + "reflection-trigger": { type: "string" }, + "reflection-behavior": { type: "string" }, + "reflection-step-count": { type: "string" }, "max-turns": { type: "string" }, }, strict: true, @@ -559,6 +577,27 @@ async function main(): Promise { : undefined; const shouldAutoEnableMemfsForNewAgent = !memfsFlag && !noMemfsFlag; const noSkillsFlag = values["no-skills"] as boolean | undefined; + const noBundledSkillsFlag = values["no-bundled-skills"] as + | boolean + | undefined; + const skillSourcesRaw = values["skill-sources"] as string | undefined; + const noSystemInfoReminderFlag = values["no-system-info-reminder"] as + | boolean + | undefined; + const resolvedSkillSources = (() => { + try { + return resolveSkillSourcesSelection({ + skillSourcesRaw, + noSkills: noSkillsFlag, + noBundledSkills: noBundledSkillsFlag, + }); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); + process.exit(1); + } + })(); const fromAfFile = (values.import as string | undefined) ?? (values["from-af"] as string | undefined); @@ -956,7 +995,8 @@ async function main(): Promise { process.argv, specifiedModel, skillsDirectory, - noSkillsFlag, + resolvedSkillSources, + !noSystemInfoReminderFlag, ); return; } @@ -1724,7 +1764,7 @@ async function main(): Promise { } // Set agent context for tools that need it (e.g., Skill tool) - setAgentContext(agent.id, skillsDirectory, noSkillsFlag); + setAgentContext(agent.id, skillsDirectory, resolvedSkillSources); // Apply memfs flag if explicitly specified (memfs is opt-in via /memfs enable or --memfs) const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent"; @@ -2064,6 +2104,7 @@ async function main(): Promise { showCompactions: settings.showCompactions, agentProvenance, releaseNotes, + sessionContextReminderEnabled: !noSystemInfoReminderFlag, }); } @@ -2081,6 +2122,7 @@ async function main(): Promise { showCompactions: settings.showCompactions, agentProvenance, releaseNotes, + sessionContextReminderEnabled: !noSystemInfoReminderFlag, }); } diff --git a/src/tests/agent/skill-sources.test.ts b/src/tests/agent/skill-sources.test.ts new file mode 100644 index 0000000..5bf2cb9 --- /dev/null +++ b/src/tests/agent/skill-sources.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { + ALL_SKILL_SOURCES, + parseSkillSourcesList, + resolveSkillSourcesSelection, +} from "../../agent/skillSources"; + +describe("skill source selection", () => { + test("defaults to all sources", () => { + expect(resolveSkillSourcesSelection({})).toEqual(ALL_SKILL_SOURCES); + }); + + test("--no-skills disables all sources", () => { + expect( + resolveSkillSourcesSelection({ + noSkills: true, + }), + ).toEqual([]); + }); + + test("--no-bundled-skills removes bundled from default set", () => { + expect( + resolveSkillSourcesSelection({ + noBundledSkills: true, + }), + ).toEqual(["global", "agent", "project"]); + }); + + test("--skill-sources accepts explicit subsets and normalizes order", () => { + expect(parseSkillSourcesList("project,global")).toEqual([ + "global", + "project", + ]); + }); + + test("--skill-sources supports all keyword", () => { + expect(parseSkillSourcesList("all,project")).toEqual(ALL_SKILL_SOURCES); + }); + + test("throws for invalid source", () => { + expect(() => parseSkillSourcesList("project,unknown")).toThrow( + 'Invalid skill source "unknown"', + ); + }); + + test("throws for empty --skill-sources value", () => { + expect(() => parseSkillSourcesList(" , ")).toThrow( + "--skill-sources must include at least one source", + ); + }); +}); diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 31413d9..118b5bd 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -89,6 +89,11 @@ export interface SystemInitMessage extends MessageEnvelope { permission_mode: string; slash_commands: string[]; memfs_enabled?: boolean; + skill_sources?: Array<"bundled" | "global" | "agent" | "project">; + system_info_reminder_enabled?: boolean; + reflection_trigger?: "off" | "step-count" | "compaction-event"; + reflection_behavior?: "reminder" | "auto-launch"; + reflection_step_count?: number; // output_style omitted - Letta Code doesn't have output styles feature }