fix: gate skills reminders in headless sessions (#987)
This commit is contained in:
@@ -8161,37 +8161,49 @@ ${SYSTEM_REMINDER_CLOSE}
|
|||||||
pushReminder(sessionContextReminder);
|
pushReminder(sessionContextReminder);
|
||||||
|
|
||||||
// Inject available skills as system-reminder (LET-7353)
|
// 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) {
|
const {
|
||||||
try {
|
discoverSkills: discover,
|
||||||
const { discoverSkills: discover, SKILLS_DIR: defaultDir } =
|
SKILLS_DIR: defaultDir,
|
||||||
await import("../agent/skills");
|
formatSkillsAsSystemReminder,
|
||||||
const { getSkillsDirectory, getNoSkills } = await import(
|
} = await import("../agent/skills");
|
||||||
"../agent/context"
|
const { getSkillsDirectory, getNoSkills } = await import(
|
||||||
);
|
"../agent/context"
|
||||||
const skillsDir =
|
);
|
||||||
getSkillsDirectory() || join(process.cwd(), defaultDir);
|
|
||||||
const { skills } = await discover(skillsDir, agentId, {
|
const previousSkillsReminder = discoveredSkillsRef.current
|
||||||
skipBundled: getNoSkills(),
|
? formatSkillsAsSystemReminder(discoveredSkillsRef.current)
|
||||||
});
|
: null;
|
||||||
discoveredSkillsRef.current = skills;
|
|
||||||
} catch {
|
let latestSkills = discoveredSkillsRef.current ?? [];
|
||||||
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 =
|
const needsSkillsReinject =
|
||||||
contextTrackerRef.current.pendingSkillsReinject;
|
contextTrackerRef.current.pendingSkillsReinject;
|
||||||
if (!hasInjectedSkillsRef.current || needsSkillsReinject) {
|
if (!hasInjectedSkillsRef.current || needsSkillsReinject) {
|
||||||
const { formatSkillsAsSystemReminder } = await import(
|
if (latestSkillsReminder) {
|
||||||
"../agent/skills"
|
pushReminder(latestSkillsReminder);
|
||||||
);
|
|
||||||
const skillsReminder = formatSkillsAsSystemReminder(
|
|
||||||
discoveredSkillsRef.current,
|
|
||||||
);
|
|
||||||
if (skillsReminder) {
|
|
||||||
pushReminder(skillsReminder);
|
|
||||||
}
|
}
|
||||||
hasInjectedSkillsRef.current = true;
|
hasInjectedSkillsRef.current = true;
|
||||||
contextTrackerRef.current.pendingSkillsReinject = false;
|
contextTrackerRef.current.pendingSkillsReinject = false;
|
||||||
|
|||||||
104
src/headless.ts
104
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_MAX_RETRIES = 1; // Only retry once, fail on 2nd 409
|
||||||
const CONVERSATION_BUSY_RETRY_DELAY_MS = 2500; // 2.5 seconds
|
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(
|
export async function handleHeadlessCommand(
|
||||||
argv: string[],
|
argv: string[],
|
||||||
model?: string,
|
model?: string,
|
||||||
@@ -2013,6 +2048,12 @@ async function runBidirectionalMode(
|
|||||||
|
|
||||||
// Track current operation for interrupt support
|
// Track current operation for interrupt support
|
||||||
let currentAbortController: AbortController | null = null;
|
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.
|
// Resolve pending approvals for this conversation before retrying user input.
|
||||||
const resolveAllPendingApprovals = async () => {
|
const resolveAllPendingApprovals = async () => {
|
||||||
@@ -2278,7 +2319,7 @@ async function runBidirectionalMode(
|
|||||||
|
|
||||||
let message: {
|
let message: {
|
||||||
type: string;
|
type: string;
|
||||||
message?: { role: string; content: string };
|
message?: { role: string; content: MessageCreate["content"] };
|
||||||
request_id?: string;
|
request_id?: string;
|
||||||
request?: { subtype: string };
|
request?: { subtype: string };
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
@@ -2417,7 +2458,7 @@ async function runBidirectionalMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle user messages
|
// Handle user messages
|
||||||
if (message.type === "user" && message.message?.content) {
|
if (message.type === "user" && message.message?.content !== undefined) {
|
||||||
const userContent = message.message.content;
|
const userContent = message.message.content;
|
||||||
|
|
||||||
// Create abort controller for this operation
|
// 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 sawStreamError = false; // Track if we emitted an error during streaming
|
||||||
let preStreamTransientRetries = 0;
|
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;
|
let enrichedContent = userContent;
|
||||||
if (typeof enrichedContent === "string") {
|
try {
|
||||||
try {
|
const {
|
||||||
const {
|
discoverSkills: discover,
|
||||||
discoverSkills: discover,
|
SKILLS_DIR: defaultDir,
|
||||||
SKILLS_DIR: defaultDir,
|
formatSkillsAsSystemReminder,
|
||||||
formatSkillsAsSystemReminder,
|
} = await import("./agent/skills");
|
||||||
} = await import("./agent/skills");
|
const { getSkillsDirectory } = await import("./agent/context");
|
||||||
const { getSkillsDirectory } = await import("./agent/context");
|
const { join } = await import("node:path");
|
||||||
const { join } = await import("node:path");
|
const skillsDir =
|
||||||
const skillsDir =
|
getSkillsDirectory() || join(process.cwd(), defaultDir);
|
||||||
getSkillsDirectory() || join(process.cwd(), defaultDir);
|
const { skills } = await discover(skillsDir, agent.id);
|
||||||
const { skills } = await discover(skillsDir, agent.id);
|
const latestSkillsReminder = formatSkillsAsSystemReminder(skills);
|
||||||
const skillsReminder = formatSkillsAsSystemReminder(skills);
|
|
||||||
if (skillsReminder) {
|
// Trigger reinjection when the available-skills block changed on disk.
|
||||||
enrichedContent = `${skillsReminder}\n\n${enrichedContent}`;
|
if (
|
||||||
}
|
cachedSkillsReminder !== null &&
|
||||||
} catch {
|
latestSkillsReminder !== cachedSkillsReminder
|
||||||
// Skills discovery failed, skip
|
) {
|
||||||
|
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
|
// Initial input is the user message
|
||||||
@@ -2792,6 +2851,9 @@ async function runBidirectionalMode(
|
|||||||
// Emit result
|
// Emit result
|
||||||
const durationMs = performance.now() - startTime;
|
const durationMs = performance.now() - startTime;
|
||||||
const lines = toLines(buffers);
|
const lines = toLines(buffers);
|
||||||
|
if (shouldReinjectSkillsAfterCompaction(lines)) {
|
||||||
|
pendingSkillsReinject = true;
|
||||||
|
}
|
||||||
const reversed = [...lines].reverse();
|
const reversed = [...lines].reverse();
|
||||||
const lastAssistant = reversed.find(
|
const lastAssistant = reversed.find(
|
||||||
(line) =>
|
(line) =>
|
||||||
|
|||||||
83
src/tests/headless/skills-reminder.test.ts
Normal file
83
src/tests/headless/skills-reminder.test.ts
Normal file
@@ -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",
|
||||||
|
"<skills>demo</skills>",
|
||||||
|
);
|
||||||
|
expect(result).toBe("<skills>demo</skills>\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<MessageCreate["content"], string>;
|
||||||
|
|
||||||
|
const result = prependSkillsReminderToContent(
|
||||||
|
multimodal as MessageCreate["content"],
|
||||||
|
"<skills>demo</skills>",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
if (!Array.isArray(result)) return;
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
text: "<skills>demo</skills>\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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user