diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3edaebb..3cd5edc 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -8161,37 +8161,49 @@ ${SYSTEM_REMINDER_CLOSE} pushReminder(sessionContextReminder); // Inject available skills as system-reminder (LET-7353) - // Lazy-discover on first message, reinject after compaction + // Discover each turn so on-disk skill changes can trigger reinjection. { - if (!discoveredSkillsRef.current) { - try { - const { discoverSkills: discover, SKILLS_DIR: defaultDir } = - await import("../agent/skills"); - const { getSkillsDirectory, getNoSkills } = await import( - "../agent/context" - ); - const skillsDir = - getSkillsDirectory() || join(process.cwd(), defaultDir); - const { skills } = await discover(skillsDir, agentId, { - skipBundled: getNoSkills(), - }); - discoveredSkillsRef.current = skills; - } catch { - discoveredSkillsRef.current = []; - } + const { + discoverSkills: discover, + SKILLS_DIR: defaultDir, + formatSkillsAsSystemReminder, + } = await import("../agent/skills"); + const { getSkillsDirectory, getNoSkills } = await import( + "../agent/context" + ); + + const previousSkillsReminder = discoveredSkillsRef.current + ? formatSkillsAsSystemReminder(discoveredSkillsRef.current) + : null; + + let latestSkills = discoveredSkillsRef.current ?? []; + try { + const skillsDir = + getSkillsDirectory() || join(process.cwd(), defaultDir); + const { skills } = await discover(skillsDir, agentId, { + skipBundled: getNoSkills(), + }); + latestSkills = skills; + } catch { + // Keep the previous snapshot when discovery fails. + } + + discoveredSkillsRef.current = latestSkills; + const latestSkillsReminder = formatSkillsAsSystemReminder( + discoveredSkillsRef.current, + ); + if ( + previousSkillsReminder !== null && + previousSkillsReminder !== latestSkillsReminder + ) { + contextTrackerRef.current.pendingSkillsReinject = true; } const needsSkillsReinject = contextTrackerRef.current.pendingSkillsReinject; if (!hasInjectedSkillsRef.current || needsSkillsReinject) { - const { formatSkillsAsSystemReminder } = await import( - "../agent/skills" - ); - const skillsReminder = formatSkillsAsSystemReminder( - discoveredSkillsRef.current, - ); - if (skillsReminder) { - pushReminder(skillsReminder); + if (latestSkillsReminder) { + pushReminder(latestSkillsReminder); } hasInjectedSkillsRef.current = true; contextTrackerRef.current.pendingSkillsReinject = false; diff --git a/src/headless.ts b/src/headless.ts index 6d6ee04..8e032c4 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -78,6 +78,41 @@ const LLM_API_ERROR_MAX_RETRIES = 3; const CONVERSATION_BUSY_MAX_RETRIES = 1; // Only retry once, fail on 2nd 409 const CONVERSATION_BUSY_RETRY_DELAY_MS = 2500; // 2.5 seconds +export function prependSkillsReminderToContent( + content: MessageCreate["content"], + skillsReminder: string, +): MessageCreate["content"] { + if (!skillsReminder) { + return content; + } + + if (typeof content === "string") { + return `${skillsReminder}\n\n${content}`; + } + + if (Array.isArray(content)) { + return [ + { + type: "text", + text: `${skillsReminder}\n\n`, + }, + ...content, + ] as MessageCreate["content"]; + } + + return content; +} + +export function shouldReinjectSkillsAfterCompaction(lines: Line[]): boolean { + return lines.some( + (line) => + line.kind === "event" && + line.eventType === "compaction" && + line.phase === "finished" && + (line.summary !== undefined || line.stats !== undefined), + ); +} + export async function handleHeadlessCommand( argv: string[], model?: string, @@ -2013,6 +2048,12 @@ async function runBidirectionalMode( // Track current operation for interrupt support let currentAbortController: AbortController | null = null; + // Skills reminder lifecycle in bidirectional mode: + // - Inject once on first user turn + // - Reinject only after compaction completion or skills diff + let hasInjectedSkillsReminder = false; + let pendingSkillsReinject = false; + let cachedSkillsReminder: string | null = null; // Resolve pending approvals for this conversation before retrying user input. const resolveAllPendingApprovals = async () => { @@ -2278,7 +2319,7 @@ async function runBidirectionalMode( let message: { type: string; - message?: { role: string; content: string }; + message?: { role: string; content: MessageCreate["content"] }; request_id?: string; request?: { subtype: string }; session_id?: string; @@ -2417,7 +2458,7 @@ async function runBidirectionalMode( } // Handle user messages - if (message.type === "user" && message.message?.content) { + if (message.type === "user" && message.message?.content !== undefined) { const userContent = message.message.content; // Create abort controller for this operation @@ -2431,27 +2472,45 @@ async function runBidirectionalMode( let sawStreamError = false; // Track if we emitted an error during streaming let preStreamTransientRetries = 0; - // Inject available skills as system-reminder for bidirectional mode (LET-7353) + // Inject available skills as system-reminder for bidirectional mode (LET-7353). + // Discover each turn so skill file changes are naturally picked up. let enrichedContent = userContent; - if (typeof enrichedContent === "string") { - try { - const { - discoverSkills: discover, - SKILLS_DIR: defaultDir, - formatSkillsAsSystemReminder, - } = await import("./agent/skills"); - const { getSkillsDirectory } = await import("./agent/context"); - const { join } = await import("node:path"); - const skillsDir = - getSkillsDirectory() || join(process.cwd(), defaultDir); - const { skills } = await discover(skillsDir, agent.id); - const skillsReminder = formatSkillsAsSystemReminder(skills); - if (skillsReminder) { - enrichedContent = `${skillsReminder}\n\n${enrichedContent}`; - } - } catch { - // Skills discovery failed, skip + try { + const { + discoverSkills: discover, + SKILLS_DIR: defaultDir, + formatSkillsAsSystemReminder, + } = await import("./agent/skills"); + const { getSkillsDirectory } = await import("./agent/context"); + const { join } = await import("node:path"); + const skillsDir = + getSkillsDirectory() || join(process.cwd(), defaultDir); + const { skills } = await discover(skillsDir, agent.id); + const latestSkillsReminder = formatSkillsAsSystemReminder(skills); + + // Trigger reinjection when the available-skills block changed on disk. + if ( + cachedSkillsReminder !== null && + latestSkillsReminder !== cachedSkillsReminder + ) { + pendingSkillsReinject = true; } + cachedSkillsReminder = latestSkillsReminder; + + const shouldInjectSkillsReminder = + !hasInjectedSkillsReminder || pendingSkillsReinject; + if (shouldInjectSkillsReminder && latestSkillsReminder) { + enrichedContent = prependSkillsReminderToContent( + enrichedContent, + latestSkillsReminder, + ); + } + if (shouldInjectSkillsReminder) { + hasInjectedSkillsReminder = true; + pendingSkillsReinject = false; + } + } catch { + // Skills discovery failed, skip } // Initial input is the user message @@ -2792,6 +2851,9 @@ async function runBidirectionalMode( // Emit result const durationMs = performance.now() - startTime; const lines = toLines(buffers); + if (shouldReinjectSkillsAfterCompaction(lines)) { + pendingSkillsReinject = true; + } const reversed = [...lines].reverse(); const lastAssistant = reversed.find( (line) => diff --git a/src/tests/headless/skills-reminder.test.ts b/src/tests/headless/skills-reminder.test.ts new file mode 100644 index 0000000..b804429 --- /dev/null +++ b/src/tests/headless/skills-reminder.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; +import type { Line } from "../../cli/helpers/accumulator"; +import { + prependSkillsReminderToContent, + shouldReinjectSkillsAfterCompaction, +} from "../../headless"; + +describe("headless skills reminder helpers", () => { + test("prepends reminder to string user content", () => { + const result = prependSkillsReminderToContent( + "hello", + "demo", + ); + expect(result).toBe("demo\n\nhello"); + }); + + test("prepends reminder as a text part for multimodal user content", () => { + const multimodal = [ + { type: "text", text: "what is in this image?" }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "abc" }, + }, + ] as unknown as Exclude; + + const result = prependSkillsReminderToContent( + multimodal as MessageCreate["content"], + "demo", + ); + + expect(Array.isArray(result)).toBe(true); + if (!Array.isArray(result)) return; + expect(result[0]).toEqual({ + type: "text", + text: "demo\n\n", + }); + expect(result[1]).toEqual(multimodal[0]); + expect(result[2]).toEqual(multimodal[1]); + }); + + test("does not reinject on compaction start event", () => { + const lines: Line[] = [ + { + kind: "event", + id: "evt-1", + eventType: "compaction", + eventData: {}, + phase: "running", + }, + ]; + expect(shouldReinjectSkillsAfterCompaction(lines)).toBe(false); + }); + + test("reinjection triggers after compaction completion", () => { + const withSummary: Line[] = [ + { + kind: "event", + id: "evt-2", + eventType: "compaction", + eventData: {}, + phase: "finished", + summary: "Compacted old messages", + }, + ]; + expect(shouldReinjectSkillsAfterCompaction(withSummary)).toBe(true); + + const withStatsOnly: Line[] = [ + { + kind: "event", + id: "evt-3", + eventType: "compaction", + eventData: {}, + phase: "finished", + stats: { + contextTokensBefore: 12000, + contextTokensAfter: 7000, + }, + }, + ]; + expect(shouldReinjectSkillsAfterCompaction(withStatsOnly)).toBe(true); + }); +});