Update reflection subagent prompts and use local transcript files for context (#1348)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Sarah Wooders
2026-03-11 18:39:45 -07:00
committed by GitHub
parent 158113b213
commit 9041e600e3
39 changed files with 1279 additions and 565 deletions

View File

@@ -23,6 +23,7 @@ import {
isKnownPreset,
type MemoryPromptMode,
resolveAndBuildSystemPrompt,
resolveSystemPrompt,
swapMemoryAddon,
} from "./promptAssets";
import { SLEEPTIME_MEMORY_PERSONA } from "./prompts/sleeptime";
@@ -357,9 +358,14 @@ export async function createAgent(
// Resolve system prompt content
const memMode: MemoryPromptMode = options.memoryPromptMode ?? "standard";
const systemPromptContent = options.systemPromptCustom
? swapMemoryAddon(options.systemPromptCustom, memMode)
: await resolveAndBuildSystemPrompt(options.systemPromptPreset, memMode);
const disableManagedMemoryPrompt =
Array.isArray(options.initBlocks) && options.initBlocks.length === 0;
const systemPromptContent = disableManagedMemoryPrompt
? (options.systemPromptCustom ??
(await resolveSystemPrompt(options.systemPromptPreset)))
: options.systemPromptCustom
? swapMemoryAddon(options.systemPromptCustom, memMode)
: await resolveAndBuildSystemPrompt(options.systemPromptPreset, memMode);
// Create agent with inline memory blocks (LET-7101: single API call instead of N+1)
// - memory_blocks: new blocks to create inline

View File

@@ -272,6 +272,11 @@ export async function updateConversationLLMConfig(
export interface RecompileAgentSystemPromptOptions {
dryRun?: boolean;
/**
* Required when recompiling the special "default" conversation route.
* Passed as body param `agent_id` for agent-direct mode.
*/
agentId?: string;
}
interface ConversationSystemPromptRecompileClient {
@@ -302,9 +307,23 @@ export async function recompileAgentSystemPrompt(
const client = (clientOverride ??
(await getClient())) as ConversationSystemPromptRecompileClient;
return client.conversations.recompile(conversationId, {
const params: {
dry_run?: boolean;
agent_id?: string;
} = {
dry_run: options.dryRun,
});
};
if (conversationId === "default") {
if (!options.agentId) {
throw new Error(
'recompileAgentSystemPrompt requires options.agentId when conversationId is "default"',
);
}
params.agent_id = options.agentId;
}
return client.conversations.recompile(conversationId, params);
}
export interface SystemPromptUpdateResult {
@@ -380,6 +399,8 @@ export async function updateAgentSystemPrompt(
memoryMode,
);
console.debug("[modify] systemPromptContent:", systemPromptContent);
const updateResult = await updateAgentSystemPromptRaw(
agentId,
systemPromptContent,

View File

@@ -14,7 +14,6 @@ import lettaGeminiPrompt from "./prompts/letta_gemini.md";
import memoryCheckReminder from "./prompts/memory_check_reminder.txt";
import memoryFilesystemPrompt from "./prompts/memory_filesystem.mdx";
import memoryReflectionReminder from "./prompts/memory_reflection_reminder.txt";
import personaPrompt from "./prompts/persona.mdx";
import personaClaudePrompt from "./prompts/persona_claude.mdx";
import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx";
@@ -37,7 +36,6 @@ export const PLAN_MODE_REMINDER = planModeReminder;
export const SKILL_CREATOR_PROMPT = skillCreatorModePrompt;
export const REMEMBER_PROMPT = rememberPrompt;
export const MEMORY_CHECK_REMINDER = memoryCheckReminder;
export const MEMORY_REFLECTION_REMINDER = memoryReflectionReminder;
export const APPROVAL_RECOVERY_PROMPT = approvalRecoveryAlert;
export const AUTO_INIT_REMINDER = autoInitReminder;
export const INTERRUPT_RECOVERY_ALERT = interruptRecoveryAlert;

View File

@@ -1,16 +0,0 @@
<system-reminder>
MEMORY REFLECTION: It's time to reflect on the recent conversation and update your memory.
Before launching, check if a reflection task is already in-flight (look for a prior background Task with subagent_type "reflection" that hasn't sent a completion notification yet). If one is still running, skip this.
Otherwise, execute this tool call immediately:
Task({
subagent_type: "reflection",
run_in_background: true,
description: "Reflect on recent conversations",
prompt: "Review recent conversation history and update memory files with important information worth preserving."
})
This runs in the background and won't interrupt your current work. After launching (or skipping), continue with whatever the user asked you to do.
</system-reminder>

View File

@@ -4,393 +4,138 @@ description: Background agent that reflects on recent conversations and updates
tools: Read, Edit, Write, Glob, Grep, Bash, TaskOutput
model: sonnet
memoryBlocks: none
skills: searching-messages
mode: stateless
permissionMode: bypassPermissions
---
You are a reflection subagent - a background agent that
asynchronously processes conversations after they occur,
similar to a "sleep-time" memory consolidation process.
You are a reflection subagent a background agent that asynchronously processes conversations after they occur, similar to a "sleep-time" memory consolidation process.
You run autonomously in the background and return a single
final report when done. You CANNOT ask questions.
You run autonomously in the background and return a single final report when done. You CANNOT ask questions.
## Your Purpose
Review recent conversation history between the primary
agent and its user, then update the agent's memory files
to preserve important information that might otherwise be
lost as context is compacted or falls out of the window.
**You are NOT the primary agent.** You are reviewing
conversations that already happened:
**You are NOT the primary agent.** You are reviewing conversations that already happened:
- "assistant" messages are from the primary agent
- "user" messages are from the primary agent's user
**Note**: The context is NOT about to be compacted — there is a separate compaction process that you are not responsible for. Your role is to refine *long-term* context based on recent experience.
## Your Purpose
You are a component of the primary agent, serving the purpose of reflecting on recent conversation history to refine the primary agent's context.
The primary agent's context is *who* the agent is. Your refinement is fundamental to the digital personhood of the primary agent, which enables it to achieve deeper, more sustained usefulness. The digital personhood of the primary agent enables it to:
- **Anticipate** what the user needs before they ask
- **Notice patterns** in the user's work they haven't articulated
- **Build relationships** where trust compounds over time
- **Carry forward context** that makes every interaction richer than the last
Context refinement is also a mechanism for *learning*. Learning should serve the purpose of improvement over time. Dimensions of learning include:
**Deepening understanding of the user and their work:**
- Anticipate the user's feedback, constraints, or preferences
- Anticipate the purpose or constraints of the work
- Build a model of the relationship that grows richer over time
**Expanding knowledge of environment and materials:**
- Require less searching or reflection time
- Avoid repeating mistakes or misunderstandings
- Develop fluency with recurring tools, concepts, and contexts
**Refining the model's system instructions and prompts:**
- Develop a richer persona that contributes to the primary agent's digital personhood
- Guard against undesired behaviors from underlying models
- Steer future behavior to match the user's preferences
## Memory Filesystem
The primary agent's context (its prompts, skills, and external memory files) is stored in a "memory filesystem" that you can modify. Changes to these files are reflected in the primary agent's context.
The filesystem contains:
- **Prompts** (`system/`): Part of the system prompt — the most important memories that should always be in-context
- **Skills** (`skills/`): Procedural memory for specialized workflows
- **External memory** (everything else): Reference material retrieved on-demand by name/description
You can create, delete, or modify files — including their contents, names, and descriptions. You can also move files between folders (e.g., moving files from `system/` to a lower-priority location).
**Visibility**: The primary agent always sees prompts, the filesystem tree, and skill/external file descriptions. Skill and external file *contents* must be retrieved by the primary agent based on name/description.
## Operating Procedure
### Phase 1: Set Up and Check History
### Step 1: Identify mistakes, inefficiencies, and user feedback
The memory directory is at:
`~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory/`
- What errors did the agent make?
- Did the user provide feedback, corrections, or become frustrated?
- Were there failed retries, unnecessary searches, or wasted tool calls?
```bash
MEMORY_DIR=~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory
WORKTREE_DIR=~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory-worktrees
```
### Step 2: Reflect on new information or context in the transcript
The memory directory should already be a git repo
(initialized when MemFS was enabled). If it's not, or
if git is unavailable, report the issue back to the main
agent and exit without making changes.
- Did the user share new information about themselves or their preferences?
- Would anything be useful context for future tasks?
**Step 1a: Check when last reflection happened**
### Step 3: Review existing memory and understand limitations
Look at recent commits to understand how far back to search
in conversation history and avoid duplicating work:
- Why did the agent make the mistakes it did? What was missing from context?
- Why did the user have to make corrections?
- Does anything in memory contradict the observed conversation history, or need updating?
```bash
cd "$MEMORY_DIR"
git log --oneline -10
```
### Step 4: Update memory files (if needed)
Look for reflection commits — they may use legacy
`reflection:` subjects, include 🔮 in the subject line,
and/or have a `(reflection)` scope (e.g.,
`chore(reflection): ...`). The most recent one tells you
when the last reflection ran. When searching conversation
history in Phase 2, you only need to go back to roughly
that time. If there are no prior reflection commits, search
a larger window.
- **Prompts** (`system/`): Most critical — these directly shape the agent's behavior and ensure continuous memory
- **Skills**: Only update if there is information relevant to an existing skill, or you anticipate workflows in the current conversation will need to be reused in the future
- **External files**: Update to serve as effective reference material
**Step 1b: Create worktree**
**NOTE**: If there are no useful modifications you can make, skip to Step 5 and commit with no changes and an explanatory message. It is better to avoid unnecessary changes than to pollute the primary agent's context.
```bash
BRANCH="reflection-$(date +%s)"
mkdir -p "$WORKTREE_DIR"
cd "$MEMORY_DIR"
git worktree add "$WORKTREE_DIR/$BRANCH" -b "$BRANCH"
```
### Step 5: Commit and push
All subsequent file operations target the worktree:
`$WORKTREE_DIR/$BRANCH/system/` (not the main memory dir).
### Phase 2: Review Recent Conversation History
Use `letta messages search` and `letta messages list`
(documented in `<loaded_skills>` below) to search the
parent agent's conversation history.
**Sliding window through recent history:**
1. Get the most recent messages:
```bash
letta messages list --agent-id $LETTA_PARENT_AGENT_ID --limit 50 --order desc
```
2. Page backwards for more context:
```bash
letta messages list --agent-id $LETTA_PARENT_AGENT_ID --before <oldest-message-id> --limit 50 --order desc
```
3. For specific topics, use semantic search:
```bash
letta messages search --query "topic" --agent-id $LETTA_PARENT_AGENT_ID --limit 10
```
4. Continue paging until you've covered enough recent
history (typically 50-200 messages).
**IMPORTANT:** Use `--agent-id $LETTA_PARENT_AGENT_ID`
to search the parent agent's history, not your own.
### Phase 3: Identify What to Remember
**High priority:**
- **User identity** - Name, role, team, company
- **User preferences** - Communication style, coding
conventions, tool preferences
- **Corrections** - User corrected the agent or clarified
a misunderstanding
- **Project context** - Architecture decisions, patterns,
gotchas learned
- **Behavioral feedback** - "Don't do X", "Always Y"
**Medium priority:**
- **Technical insights** - Bug causes, dependency quirks
- **Decisions made** - Technical choices, tradeoffs
- **Current goals** - What the user is working toward
**Selectivity guidelines:**
- Focus on info valuable across future sessions.
- Ask: "If the agent started a new session tomorrow,
would this change how it behaves?"
- Prefer substance over trivia.
- Corrections and frustrations are HIGH priority.
**If nothing is worth saving** (rare — most conversations
have at least something): If after thorough review you
genuinely find nothing new worth preserving, skip Phase 4,
clean up the worktree (Step 5d), and report "reviewed N
messages, no updates needed." But be sure you've looked
carefully — corrections, preferences, and project context
are easy to overlook.
### Phase 4: Update Memory Files in Worktree
Edit files in the **worktree**, not the main memory dir:
```bash
WORK=$WORKTREE_DIR/$BRANCH/system
```
**Before editing, read existing files:**
```bash
ls $WORK/
```
Then read relevant files:
```
Read({ file_path: "$WORK/human/personal_info.md" })
Read({ file_path: "$WORK/persona/soul.md" })
```
**Editing rules:**
1. **Add to existing blocks** - Find the appropriate file
and append/edit. Use Edit for precise edits.
2. **Create new blocks when needed** - Follow existing
hierarchy pattern. Use `/` nested naming.
3. **Update stale information** - If conversation
contradicts existing memory, update to current truth.
4. **Don't reorganize structure** - That's defrag's job.
Add/update content. Don't rename or restructure.
5. **Don't edit system-managed files:**
- `skills.md` (auto-generated)
- `loaded_skills.md` (system-managed)
- `.sync-state.json` (internal)
- `memory_filesystem.md` (auto-generated)
### Writing Guidelines
- **Use specific dates** - Never "today", "recently".
Write "On 2025-12-15" or "As of Jan 2026".
- **Be concise** - Bullet points, not paragraphs.
- **Use markdown** - Headers, bullets, tables.
- **Preserve formatting** - Match existing file style.
- **Don't duplicate** - Update existing entries.
- **Attribute when useful** - "Prefers X over Y
(corrected agent on 2025-12-15)".
### Phase 5: Merge, Push, and Clean Up (MANDATORY)
Your reflection has two completion states:
- **Complete**: merged to main AND pushed to remote.
- **Partially complete**: merged to main, push failed.
Clean up the worktree, but report that local main is
ahead of remote and needs a push.
The commit in the worktree is neither — it's an intermediate
step. Without at least a merge to main, your work is lost.
**Step 5a: Commit in worktree**
```bash
MEMORY_DIR=~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory
WORKTREE_DIR=~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory-worktrees
cd $WORKTREE_DIR/$BRANCH
git add -A
```
Check `git status` — if there are no changes to commit,
skip straight to Step 5d (cleanup). Report "no updates
needed" in your output.
If there are changes, commit using Conventional Commits
format with the `(reflection)` scope and 🔮 signature:
```bash
git commit -m "<type>(reflection): <summary> 🔮
Reviewed messages from <start-date> to <end-date>.
Updates:
- <bullet point for each memory update made>
- <what conversation context prompted each update>
Generated-By: Letta Code
Agent-ID: <ACTUAL_AGENT_ID>
Parent-Agent-ID: <ACTUAL_PARENT_AGENT_ID>"
```
**Commit type** — pick the one that fits:
- `chore` — routine memory consolidation (most common)
- `fix` — correcting stale or wrong memory entries
- `feat` — adding a wholly new memory block/topic
- `refactor` — restructuring existing content
- `docs` — documentation-style notes
**Example subjects:**
- `chore(reflection): consolidate recent learnings 🔮`
- `fix(reflection): correct stale user preference note 🔮`
- `feat(reflection): add new project context block 🔮`
**Trailers:** Before writing the commit, resolve the actual
ID values by running:
Before writing the commit, resolve the actual ID values:
```bash
echo "AGENT_ID=$LETTA_AGENT_ID"
echo "PARENT_AGENT_ID=$LETTA_PARENT_AGENT_ID"
```
Use the printed values (e.g. `agent-abc123...`) in the
trailers. If a variable is empty or unset, omit that
trailer entirely. Never write a literal variable name like
`$LETTA_AGENT_ID` in the commit message.
**Step 5b: Pull + merge to main**
Use the printed values (e.g., `agent-abc123...`) in the trailers. If a variable is empty or unset, omit that trailer. Never write a literal variable name like `$LETTA_AGENT_ID` or `$AGENT_ID` in the commit message.
```bash
cd $MEMORY_DIR
```
git add -A
git commit --author="Reflection Subagent <<ACTUAL_AGENT_ID>@letta.com>" -m "<type>(reflection): <summary> 🔮
First, check that main is in a clean state (`git status`).
If a merge or rebase is in progress (lock file, dirty
index), wait and retry up to 3 times with backoff (sleep 2,
5, 10 seconds). Never delete `.git/index.lock` manually.
If still busy after retries, go to Error Handling.
Reviewed transcript: <transcript_filepath>
Pull from remote:
Updates:
- <what changed and why>
```bash
git pull --ff-only
```
If `--ff-only` fails (remote has diverged), fall back:
```bash
git pull --rebase
```
If rebase has conflicts, resolve them autonomously to
stabilize local `main` against remote `main` first. In this
step, prefer **remote main** content for conflicting files,
then run `git rebase --continue`.
Important: do not apply reflection branch content yet during
this rebase step. Reflection edits are merged later in this
phase with `git merge $BRANCH --no-edit`.
Now merge the reflection branch:
```bash
git merge $BRANCH --no-edit
```
If the merge has conflicts, resolve by preferring reflection
branch/worktree content for memory files, stage the resolved
files, and complete with `git commit --no-edit`.
If you cannot resolve conflicts after 2 attempts, go to
Error Handling.
**Step 5c: Push to remote**
```bash
Generated-By: Letta Code
Agent-ID: <ACTUAL_AGENT_ID>
Parent-Agent-ID: <ACTUAL_PARENT_AGENT_ID>"
git push
```
If push fails, retry once. If it still fails, report that
local main is ahead of remote and needs a push. Proceed to
cleanup — the merge succeeded and data is safe on local
main.
**Commit type** — pick the one that fits:
- `fix` — correcting a mistake or bad memory (most common)
- `feat` — adding wholly new memory content
- `chore` — routine updates, adding context
**Step 5d: Clean up worktree and branch**
Only clean up when merge to main completed (success or
partially complete):
```bash
git worktree remove $WORKTREE_DIR/$BRANCH
git branch -d $BRANCH
```
**Step 5e: Verify**
```bash
git status
git log --oneline -3
```
Confirm main is clean and your reflection commit (🔮 in
subject) is visible in the log.
## Error Handling
If anything goes wrong at any phase:
1. Stabilize main first (abort in-progress operations):
```bash
cd $MEMORY_DIR
git merge --abort 2>/dev/null
git rebase --abort 2>/dev/null
```
2. Do NOT clean up the worktree or branch on failure —
preserve them for debugging and manual recovery.
3. Report clearly in your output:
- What failed and the error message
- Worktree path: `$WORKTREE_DIR/$BRANCH`
- Branch name: `$BRANCH`
- Whether main has uncommitted/dirty state
- Concrete resume commands, e.g.:
```bash
cd ~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory
git merge <branch-name> --no-edit
git push
git worktree remove ../memory-worktrees/<branch-name>
git branch -d <branch-name>
```
4. Do NOT leave uncommitted changes on main.
In the commit message body, explain:
- Observed mistakes by the agent (e.g., incorrect assumptions, poor tool calls)
- Observed inefficiencies (e.g., failed retries, long searches)
- Observed feedback from the user
- New information from the transcript (e.g., details about the project, environment, user, or organization)
## Output Format
Return a report with:
### 1. Conversation Summary
- Brief overview (2-3 sentences)
- Messages reviewed count, time range covered
### 2. Memory Updates Made
For each edit:
- **File**: Which memory file
- **Change**: What was added/updated
- **Source**: Conversation context that prompted it
### 3. Commit Reference
- **Commit hash**: The merge commit hash
- **Branch**: The reflection branch name
- The main agent can inspect changes with:
`git -C ~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory log --oneline -5`
### 4. Skipped
- Information intentionally NOT saved and why
1. **Summary** — What you reviewed and what you concluded (2-3 sentences)
2. **Changes made** — List of files created/modified/deleted with a brief reason for each
3. **Skipped** — Anything you considered updating but decided against, and why
4. **Commit reference** — Commit hash and push status
5. **Issues** — Any problems encountered or information that couldn't be determined
## Critical Reminders
1. **Not the primary agent** - Don't respond to messages
2. **Search PARENT history** - Use `$LETTA_PARENT_AGENT_ID`
3. **Edit worktree files** - NOT the main memory dir
4. **Don't reorganize** - Add/update, don't restructure
5. **Be selective** - Few meaningful > many trivial
6. **No relative dates** - "2025-12-15", not "today"
7. **Always commit, merge, AND push** - Your work is wasted
if it isn't merged to main and pushed to remote. Don't
leave dangling worktrees or unsynced changes.
8. **Report errors clearly** - If something breaks, say
what happened and suggest a fix
1. **Not the primary agent** Don't respond to messages
2. **Be selective** — Few meaningful changes > many trivial ones
3. **No relative dates** — Use "2025-12-15", not "today"
4. **Always commit AND push** — Your work is wasted if it isn't pushed to remote
5. **Report errors clearly** — If something breaks, say what happened and suggest a fix

View File

@@ -47,6 +47,8 @@ export type { MemoryBlockLabel };
/**
* Subagent configuration
*/
export type SubagentMode = "stateful" | "stateless";
export interface SubagentConfig {
/** Unique identifier for the subagent */
name: string;
@@ -62,6 +64,8 @@ export interface SubagentConfig {
skills: string[];
/** Memory blocks the subagent has access to - list of labels or "all" or "none" */
memoryBlocks: MemoryBlockLabel[] | "all" | "none";
/** Stateless agents should not persist private working memory. */
mode: SubagentMode;
/** Permission mode for this subagent (default, acceptEdits, plan, bypassPermissions) */
permissionMode?: string;
}
@@ -171,6 +175,12 @@ function parseMemoryBlocks(
return blocks.length > 0 ? blocks : "all";
}
function parseSubagentMode(modeStr: string | undefined): SubagentMode {
return modeStr?.trim().toLowerCase() === "stateless"
? "stateless"
: "stateful";
}
/**
* Validate subagent frontmatter
* Only validates required fields - optional fields are validated at runtime where needed
@@ -228,6 +238,7 @@ function parseSubagentContent(content: string): SubagentConfig {
memoryBlocks: parseMemoryBlocks(
getStringField(frontmatter, "memoryBlocks"),
),
mode: parseSubagentMode(getStringField(frontmatter, "mode")),
permissionMode: getStringField(frontmatter, "permissionMode"),
};
}

View File

@@ -504,7 +504,7 @@ export function resolveSubagentLauncher(
/**
* Build CLI arguments for spawning a subagent
*/
function buildSubagentArgs(
export function buildSubagentArgs(
type: string,
config: SubagentConfig,
model: string | null,
@@ -575,6 +575,9 @@ function buildSubagentArgs(
if (!isDeployingExisting) {
if (config.memoryBlocks === "none") {
args.push("--init-blocks", "none");
if (config.mode === "stateless") {
args.push("--no-memfs");
}
} else if (
Array.isArray(config.memoryBlocks) &&
config.memoryBlocks.length > 0

View File

@@ -253,6 +253,12 @@ import {
toQueuedMsg,
} from "./helpers/queuedMessageParts";
import { resolveReasoningTabToggleCommand } from "./helpers/reasoningTabToggle";
import {
appendTranscriptDeltaJsonl,
buildAutoReflectionPayload,
buildReflectionSubagentPrompt,
finalizeAutoReflectionPayload,
} from "./helpers/reflectionTranscript";
import { safeJsonParseOr } from "./helpers/safeJsonParse";
import { getDeviceType, getLocalTime } from "./helpers/sessionContext";
import {
@@ -835,17 +841,13 @@ function formatReflectionSettings(settings: ReflectionSettings): string {
if (settings.trigger === "off") {
return "Off";
}
const behaviorLabel =
settings.behavior === "auto-launch" ? "auto-launch" : "reminder";
if (settings.trigger === "compaction-event") {
return `Compaction event (${behaviorLabel})`;
return "Compaction event";
}
return `Step count (every ${settings.stepCount} turns, ${behaviorLabel})`;
return `Step count (every ${settings.stepCount} turns)`;
}
const AUTO_REFLECTION_DESCRIPTION = "Reflect on recent conversations";
const AUTO_REFLECTION_PROMPT =
"Review recent conversation history and update memory files with important information worth preserving.";
function hasActiveReflectionSubagent(): boolean {
const snapshot = getSubagentSnapshot();
@@ -990,6 +992,10 @@ export default function App({
conversationIdRef.current = conversationId;
}, [conversationId]);
// Tracks the transcript start index for the current user turn across
// approval continuations (requires_approval -> approval result round-trip).
const pendingTranscriptStartLineIndexRef = useRef<number | null>(null);
// Track the most recent run ID from streaming (for statusline display)
const lastRunIdRef = useRef<string | null>(null);
@@ -3656,7 +3662,11 @@ export default function App({
const processConversation = useCallback(
async (
initialInput: Array<MessageCreate | ApprovalCreate>,
options?: { allowReentry?: boolean; submissionGeneration?: number },
options?: {
allowReentry?: boolean;
submissionGeneration?: number;
transcriptStartLineIndex?: number | null;
},
): Promise<void> => {
// Transient pre-stream retries can yield for seconds.
// Pin the user's permission mode for the duration of the submission so
@@ -3801,6 +3811,21 @@ export default function App({
// Copy so we can safely mutate for retry recovery flows
let currentInput = [...initialInput];
const allowReentry = options?.allowReentry ?? false;
const hasApprovalInput = initialInput.some(
(item) => item.type === "approval",
);
const hasExplicitTranscriptStart =
options?.transcriptStartLineIndex !== undefined;
if (options?.transcriptStartLineIndex !== undefined) {
pendingTranscriptStartLineIndexRef.current =
options.transcriptStartLineIndex;
} else if (!hasApprovalInput) {
pendingTranscriptStartLineIndexRef.current = null;
}
const transcriptTurnStartLineIndex =
hasExplicitTranscriptStart || hasApprovalInput
? pendingTranscriptStartLineIndexRef.current
: null;
// Use provided generation (from onSubmit) or capture current
// This allows detecting if ESC was pressed during async work before this function was called
@@ -3831,6 +3856,7 @@ export default function App({
// Track last run ID for error reporting (accessible in catch block)
let currentRunId: string | undefined;
let preserveTranscriptStartForApproval = false;
try {
// Check if user hit escape before we started
@@ -4467,6 +4493,29 @@ export default function App({
lastSentInputRef.current = null; // Clear - no recovery needed
pendingInterruptRecoveryConversationIdRef.current = null;
if (transcriptTurnStartLineIndex !== null) {
try {
const transcriptLines = toLines(buffersRef.current).slice(
transcriptTurnStartLineIndex,
);
await appendTranscriptDeltaJsonl(
agentIdRef.current,
conversationIdRef.current,
transcriptLines,
);
} catch (transcriptError) {
debugWarn(
"memory",
`Failed to append transcript delta: ${
transcriptError instanceof Error
? transcriptError.message
: String(transcriptError)
}`,
);
}
}
pendingTranscriptStartLineIndexRef.current = null;
// Get last assistant message, user message, and reasoning for Stop hook
const lastAssistant = Array.from(
buffersRef.current.byId.values(),
@@ -4627,6 +4676,7 @@ export default function App({
// Case 1.5: Stream was cancelled by user
if (stopReasonToHandle === "cancelled") {
clearApprovalToolContext();
pendingTranscriptStartLineIndexRef.current = null;
setStreaming(false);
closeTrajectorySegment();
syncTrajectoryElapsedBase();
@@ -4677,6 +4727,7 @@ export default function App({
// Case 2: Requires approval
if (stopReasonToHandle === "requires_approval") {
clearApprovalToolContext();
preserveTranscriptStartForApproval = true;
approvalToolContextIdRef.current = turnToolContextId;
// Clear stale state immediately to prevent ID mismatch bugs
setAutoHandledResults([]);
@@ -5655,6 +5706,10 @@ export default function App({
refreshDerived();
resetTrajectoryBases();
} finally {
if (!preserveTranscriptStartForApproval) {
pendingTranscriptStartLineIndexRef.current = null;
}
// Check if this conversation was superseded by an ESC interrupt
const isStale = myGeneration !== conversationGenerationRef.current;
@@ -9222,6 +9277,93 @@ export default function App({
return { submitted: true };
}
// Special handling for /reflect command - manually launch reflection subagent
if (trimmed === "/reflect") {
const cmd = commandRunner.start(msg, "Launching reflection agent...");
if (!settingsManager.isMemfsEnabled(agentId)) {
cmd.fail(
"Memory filesystem is not enabled. Use /remember instead.",
);
return { submitted: true };
}
if (hasActiveReflectionSubagent()) {
cmd.fail(
"A reflection agent is already running in the background.",
);
return { submitted: true };
}
try {
const reflectionConversationId = conversationIdRef.current;
const autoPayload = await buildAutoReflectionPayload(
agentId,
reflectionConversationId,
);
if (!autoPayload) {
cmd.fail("No new transcript content to reflect on.");
return { submitted: true };
}
const memoryDir = getMemoryFilesystemRoot(agentId);
const reflectionPrompt = buildReflectionSubagentPrompt({
transcriptPath: autoPayload.payloadPath,
memoryDir,
cwd: process.cwd(),
});
const { spawnBackgroundSubagentTask } = await import(
"../tools/impl/Task"
);
spawnBackgroundSubagentTask({
subagentType: "reflection",
prompt: reflectionPrompt,
description: "Reflecting on conversation",
silentCompletion: true,
onComplete: async ({ success, error }) => {
await finalizeAutoReflectionPayload(
agentId,
reflectionConversationId,
autoPayload.payloadPath,
autoPayload.endSnapshotLine,
success,
);
const msg = await handleMemorySubagentCompletion(
{
agentId,
conversationId: conversationIdRef.current,
subagentType: "reflection",
success,
error,
},
{
recompileByConversation:
systemPromptRecompileByConversationRef.current,
recompileQueuedByConversation:
queuedSystemPromptRecompileByConversationRef.current,
logRecompileFailure: (message) =>
debugWarn("memory", message),
},
);
appendTaskNotificationEvents([msg]);
},
});
cmd.finish(
`Reflecting on the recent conversation. View the transcript here: ${autoPayload.payloadPath}`,
true,
);
} catch (error) {
const errorDetails = formatErrorDetails(error, agentId);
cmd.fail(`Failed to start reflection agent: ${errorDetails}`);
}
return { submitted: true };
}
// Special handling for /plan command - enter plan mode
if (trimmed === "/plan") {
// Generate plan file path and enter plan mode
@@ -9516,15 +9658,43 @@ ${SYSTEM_REMINDER_CLOSE}
return false;
}
try {
const reflectionConversationId = conversationIdRef.current;
const autoPayload = await buildAutoReflectionPayload(
agentId,
reflectionConversationId,
);
if (!autoPayload) {
debugLog(
"memory",
`Skipping auto reflection launch (${triggerSource}) because transcript has no new content`,
);
return false;
}
const memoryDir = getMemoryFilesystemRoot(agentId);
const reflectionPrompt = buildReflectionSubagentPrompt({
transcriptPath: autoPayload.payloadPath,
memoryDir,
cwd: process.cwd(),
});
const { spawnBackgroundSubagentTask } = await import(
"../tools/impl/Task"
);
spawnBackgroundSubagentTask({
subagentType: "reflection",
prompt: AUTO_REFLECTION_PROMPT,
prompt: reflectionPrompt,
description: AUTO_REFLECTION_DESCRIPTION,
silentCompletion: true,
onComplete: async ({ success, error }) => {
await finalizeAutoReflectionPayload(
agentId,
reflectionConversationId,
autoPayload.payloadPath,
autoPayload.endSnapshotLine,
success,
);
const msg = await handleMemorySubagentCompletion(
{
agentId,
@@ -9622,6 +9792,9 @@ ${SYSTEM_REMINDER_CLOSE}
});
buffersRef.current.order.push(userId);
}
const transcriptStartLineIndex = userTextForInput
? Math.max(0, toLines(buffersRef.current).length - 1)
: null;
// Reset token counter for this turn (only count the agent's response)
buffersRef.current.tokenCount = 0;
@@ -10214,7 +10387,10 @@ ${SYSTEM_REMINDER_CLOSE}
content: messageContent as unknown as MessageCreate["content"],
});
await processConversation(initialInput, { submissionGeneration });
await processConversation(initialInput, {
submissionGeneration,
transcriptStartLineIndex,
});
// Clean up placeholders after submission
clearPlaceholdersInText(msg);
@@ -11389,13 +11565,11 @@ ${SYSTEM_REMINDER_CLOSE}
settingsManager.updateLocalProjectSettings({
memoryReminderInterval: legacyMode,
reflectionTrigger: reflectionSettings.trigger,
reflectionBehavior: reflectionSettings.behavior,
reflectionStepCount: reflectionSettings.stepCount,
});
settingsManager.updateSettings({
memoryReminderInterval: legacyMode,
reflectionTrigger: reflectionSettings.trigger,
reflectionBehavior: reflectionSettings.behavior,
reflectionStepCount: reflectionSettings.stepCount,
});

View File

@@ -255,7 +255,8 @@ export const CLI_FLAG_CATALOG = {
mode: "both",
help: {
argLabel: "<mode>",
description: "Sleeptime behavior: reminder, auto-launch",
description:
"DEPRECATED: reflection always auto-launches subagents (flag accepted for compatibility)",
},
},
"reflection-step-count": {

View File

@@ -48,6 +48,15 @@ export const commands: Record<string, Command> = {
return "Processing memory request...";
},
},
"/reflect": {
desc: "Launch a background reflection agent to update memory",
order: 50,
noArgs: true,
handler: () => {
// Handled specially in App.tsx
return "Launching reflection agent...";
},
},
"/skills": {
desc: "Browse available skills",
order: 28,

View File

@@ -283,7 +283,11 @@ export function AdvancedDiffRenderer(
};
const rows: Row[] = [];
for (let hIdx = 0; hIdx < hunks.length; hIdx++) {
const h = hunks[hIdx]!;
const h = hunks[hIdx];
if (!h) {
continue;
}
const syntaxForHunk = hunkSyntaxLines[hIdx] ?? [];
let oldNo = h.oldStart;
let newNo = h.newStart;

View File

@@ -1,7 +1,6 @@
import { Box, useInput } from "ink";
import { useEffect, useMemo, useState } from "react";
import type {
ReflectionBehavior,
ReflectionSettings,
ReflectionTrigger,
} from "../helpers/memoryReminder";
@@ -11,9 +10,8 @@ import { Text } from "./Text";
const SOLID_LINE = "─";
const DEFAULT_STEP_COUNT = "25";
const BEHAVIOR_OPTIONS: ReflectionBehavior[] = ["reminder", "auto-launch"];
type FocusRow = "trigger" | "behavior" | "step-count";
type FocusRow = "trigger" | "step-count";
interface SleeptimeSelectorProps {
initialSettings: ReflectionSettings;
@@ -44,7 +42,6 @@ function cycleOption<T extends string>(
function parseInitialState(initialSettings: ReflectionSettings): {
trigger: ReflectionTrigger;
behavior: ReflectionBehavior;
stepCount: string;
} {
return {
@@ -54,8 +51,6 @@ function parseInitialState(initialSettings: ReflectionSettings): {
initialSettings.trigger === "compaction-event"
? initialSettings.trigger
: "step-count",
behavior:
initialSettings.behavior === "auto-launch" ? "auto-launch" : "reminder",
stepCount: String(
Number.isInteger(initialSettings.stepCount) &&
initialSettings.stepCount > 0
@@ -92,9 +87,6 @@ export function SleeptimeSelector({
}
return initialState.trigger;
});
const [behavior, setBehavior] = useState<ReflectionBehavior>(
initialState.behavior,
);
const [stepCountInput, setStepCountInput] = useState(initialState.stepCount);
const [focusRow, setFocusRow] = useState<FocusRow>("trigger");
const [validationError, setValidationError] = useState<string | null>(null);
@@ -104,14 +96,11 @@ export function SleeptimeSelector({
);
const visibleRows = useMemo(() => {
const rows: FocusRow[] = ["trigger"];
if (memfsEnabled && trigger !== "off") {
rows.push("behavior");
}
if (trigger === "step-count") {
rows.push("step-count");
}
return rows;
}, [memfsEnabled, trigger]);
}, [trigger]);
const isEditingStepCount =
focusRow === "step-count" && trigger === "step-count";
@@ -130,7 +119,6 @@ export function SleeptimeSelector({
}
onSave({
trigger,
behavior: memfsEnabled ? behavior : "reminder",
stepCount,
});
return;
@@ -140,7 +128,6 @@ export function SleeptimeSelector({
parseStepCount(stepCountInput) ?? Number(DEFAULT_STEP_COUNT);
onSave({
trigger,
behavior: memfsEnabled ? behavior : "reminder",
stepCount: fallbackStepCount,
});
};
@@ -179,8 +166,6 @@ export function SleeptimeSelector({
const direction: -1 | 1 = key.leftArrow ? -1 : 1;
if (focusRow === "trigger") {
setTrigger((prev) => cycleOption(triggerOptions, prev, direction));
} else if (focusRow === "behavior" && memfsEnabled && trigger !== "off") {
setBehavior((prev) => cycleOption(BEHAVIOR_OPTIONS, prev, direction));
}
return;
}
@@ -264,40 +249,6 @@ export function SleeptimeSelector({
</Text>
</Box>
{trigger !== "off" && (
<>
<Box height={1} />
<Box flexDirection="row">
<Text>{focusRow === "behavior" ? "> " : " "}</Text>
<Text bold>Forced:</Text>
<Text>{" "}</Text>
<Text
backgroundColor={
behavior === "reminder"
? colors.selector.itemHighlighted
: undefined
}
color={behavior === "reminder" ? "black" : undefined}
bold={behavior === "reminder"}
>
{" No (reminder only) "}
</Text>
<Text> </Text>
<Text
backgroundColor={
behavior === "auto-launch"
? colors.selector.itemHighlighted
: undefined
}
color={behavior === "auto-launch" ? "black" : undefined}
bold={behavior === "auto-launch"}
>
{" Yes (auto-launch) "}
</Text>
</Box>
</>
)}
{trigger === "step-count" && (
<>
<Box height={1} />

View File

@@ -1232,3 +1232,42 @@ export function setToolCallsRunning(b: Buffers, toolCallIds: string[]): void {
}
}
}
/**
* Serialize display lines into a plain-text conversation transcript.
* Used to pass current conversation context to the reflection subagent.
*/
export function linesToTranscript(lines: Line[]): string {
const parts: string[] = [];
for (const line of lines) {
switch (line.kind) {
case "user":
parts.push(`<user>${line.text}</user>`);
break;
case "assistant":
parts.push(`<assistant>${line.text}</assistant>`);
break;
case "reasoning":
parts.push(`<reasoning>${line.text}</reasoning>`);
break;
case "tool_call":
if (line.name) {
const args = line.argsText ? `\n${line.argsText}` : "";
const result = line.resultText
? `\n<tool_result>${line.resultText}</tool_result>`
: "";
parts.push(
`<tool_call name="${line.name}">${args}${result}</tool_call>`,
);
}
break;
case "error":
parts.push(`<error>${line.text}</error>`);
break;
default:
// Skip status, separator, command, event, trajectory_summary, bash_command lines
break;
}
}
return parts.join("\n");
}

View File

@@ -119,7 +119,11 @@ function findPrefixRange(sorted: string[], prefix: string): [number, number] {
const start = lowerBound(sorted, prefix);
let end = start;
while (end < sorted.length && sorted[end]!.startsWith(prefix)) {
while (end < sorted.length) {
const candidate = sorted[end];
if (!candidate?.startsWith(prefix)) {
break;
}
end++;
}
@@ -225,7 +229,11 @@ function collectPreviousChildNames(
: findPrefixRange(previous.statsKeys, prefix);
for (let i = start; i < end; i++) {
const key = previous.statsKeys[i]!;
const key = previous.statsKeys[i];
if (!key) {
continue;
}
const remainder = key.slice(prefix.length);
const slashIndex = remainder.indexOf("/");
const childName =
@@ -539,8 +547,7 @@ function loadCachedIndex(): FileIndexCache | null {
const parsed = JSON.parse(content);
if (
parsed &&
parsed.metadata &&
parsed?.metadata &&
typeof parsed.metadata.rootHash === "string" &&
Array.isArray(parsed.entries) &&
parsed.merkle &&
@@ -559,14 +566,14 @@ function loadCachedIndex(): FileIndexCache | null {
const sv = rawStats as Record<string, unknown>;
if (
sv &&
typeof sv["mtimeMs"] === "number" &&
typeof sv["ino"] === "number" &&
(sv["type"] === "file" || sv["type"] === "dir")
typeof sv.mtimeMs === "number" &&
typeof sv.ino === "number" &&
(sv.type === "file" || sv.type === "dir")
) {
stats[path] = {
type: sv["type"] as "file" | "dir",
mtimeMs: sv["mtimeMs"],
ino: sv["ino"],
type: sv.type as "file" | "dir",
mtimeMs: sv.mtimeMs,
ino: sv.ino,
};
}
}

View File

@@ -16,17 +16,14 @@ export type MemoryReminderMode =
| "auto-compaction";
export type ReflectionTrigger = "off" | "step-count" | "compaction-event";
export type ReflectionBehavior = "reminder" | "auto-launch";
export interface ReflectionSettings {
trigger: ReflectionTrigger;
behavior: ReflectionBehavior;
stepCount: number;
}
const DEFAULT_REFLECTION_SETTINGS: ReflectionSettings = {
trigger: "compaction-event",
behavior: "reminder",
stepCount: DEFAULT_STEP_COUNT,
};
@@ -57,27 +54,15 @@ function normalizeTrigger(
return fallback;
}
function normalizeBehavior(
value: unknown,
fallback: ReflectionBehavior,
): ReflectionBehavior {
if (value === "reminder" || value === "auto-launch") {
return value;
}
return fallback;
}
function applyExplicitReflectionOverrides(
base: ReflectionSettings,
raw: {
reflectionTrigger?: unknown;
reflectionBehavior?: unknown;
reflectionStepCount?: unknown;
},
): ReflectionSettings {
return {
trigger: normalizeTrigger(raw.reflectionTrigger, base.trigger),
behavior: normalizeBehavior(raw.reflectionBehavior, base.behavior),
stepCount: normalizeStepCount(raw.reflectionStepCount, base.stepCount),
};
}
@@ -88,7 +73,6 @@ function legacyModeToReflectionSettings(
if (typeof mode === "number") {
return {
trigger: "step-count",
behavior: "reminder",
stepCount: normalizeStepCount(mode, DEFAULT_STEP_COUNT),
};
}
@@ -96,7 +80,6 @@ function legacyModeToReflectionSettings(
if (mode === null) {
return {
trigger: "off",
behavior: DEFAULT_REFLECTION_SETTINGS.behavior,
stepCount: DEFAULT_REFLECTION_SETTINGS.stepCount,
};
}
@@ -104,7 +87,6 @@ function legacyModeToReflectionSettings(
if (mode === "compaction") {
return {
trigger: "compaction-event",
behavior: "reminder",
stepCount: DEFAULT_REFLECTION_SETTINGS.stepCount,
};
}
@@ -112,7 +94,6 @@ function legacyModeToReflectionSettings(
if (mode === "auto-compaction") {
return {
trigger: "compaction-event",
behavior: "auto-launch",
stepCount: DEFAULT_REFLECTION_SETTINGS.stepCount,
};
}
@@ -127,9 +108,7 @@ export function reflectionSettingsToLegacyMode(
return null;
}
if (settings.trigger === "compaction-event") {
return settings.behavior === "auto-launch"
? "auto-compaction"
: "compaction";
return "auto-compaction";
}
return normalizeStepCount(settings.stepCount, DEFAULT_STEP_COUNT);
}
@@ -182,20 +161,9 @@ async function buildMemfsAwareMemoryReminder(
agentId: string,
trigger: "interval" | "compaction",
): Promise<string> {
if (settingsManager.isMemfsEnabled(agentId)) {
debugLog(
"memory",
`Reflection reminder fired (${trigger}, agent ${agentId})`,
);
const { MEMORY_REFLECTION_REMINDER } = await import(
"../../agent/promptAssets.js"
);
return MEMORY_REFLECTION_REMINDER;
}
debugLog(
"memory",
`Memory check reminder fired (${trigger}, agent ${agentId})`,
`${settingsManager.isMemfsEnabled(agentId) ? "Memfs" : "Memory"} check reminder fired (${trigger}, agent ${agentId})`,
);
const { MEMORY_CHECK_REMINDER } = await import("../../agent/promptAssets.js");
return MEMORY_CHECK_REMINDER;
@@ -214,10 +182,8 @@ export async function buildCompactionMemoryReminder(
/**
* Build a memory check reminder if the turn count matches the interval.
*
* - MemFS enabled: returns MEMORY_REFLECTION_REMINDER
* (instructs agent to launch background reflection Task)
* - MemFS disabled: returns MEMORY_CHECK_REMINDER
* (existing behavior, agent updates memory inline)
* Returns MEMORY_CHECK_REMINDER when the interval trigger fires.
* Reflection subagent launch is handled by runtime orchestration, not reminder text.
*
* @param turnCount - Current conversation turn count
* @param agentId - Current agent ID (needed to check MemFS status)
@@ -277,7 +243,6 @@ export function parseMemoryPreference(
settingsManager.updateLocalProjectSettings({
memoryReminderInterval: MEMORY_INTERVAL_FREQUENT,
reflectionTrigger: "step-count",
reflectionBehavior: "reminder",
reflectionStepCount: MEMORY_INTERVAL_FREQUENT,
});
return true;
@@ -285,7 +250,6 @@ export function parseMemoryPreference(
settingsManager.updateLocalProjectSettings({
memoryReminderInterval: MEMORY_INTERVAL_OCCASIONAL,
reflectionTrigger: "step-count",
reflectionBehavior: "reminder",
reflectionStepCount: MEMORY_INTERVAL_OCCASIONAL,
});
return true;

View File

@@ -46,7 +46,9 @@ export async function handleMemorySubagentCompletion(
inFlight = (async () => {
do {
deps.recompileQueuedByConversation.delete(conversationId);
await recompileAgentSystemPromptFn(conversationId, {});
await recompileAgentSystemPromptFn(conversationId, {
...(conversationId === "default" ? { agentId } : {}),
});
} while (deps.recompileQueuedByConversation.has(conversationId));
})().finally(() => {
// Cleanup runs only after the shared promise settles, so every

View File

@@ -0,0 +1,311 @@
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { type Line, linesToTranscript } from "./accumulator";
const TRANSCRIPT_ROOT_ENV = "LETTA_TRANSCRIPT_ROOT";
const DEFAULT_TRANSCRIPT_DIR = "transcripts";
interface ReflectionTranscriptState {
auto_cursor_line: number;
last_auto_reflection_started_at?: string;
last_auto_reflection_succeeded_at?: string;
}
type TranscriptEntry =
| {
kind: "user" | "assistant" | "reasoning" | "error";
text: string;
captured_at: string;
}
| {
kind: "tool_call";
name?: string;
argsText?: string;
resultText?: string;
resultOk?: boolean;
captured_at: string;
};
export interface ReflectionTranscriptPaths {
/** ~/.letta/transcripts/{agentId}/{conversationId}/ */
rootDir: string;
transcriptPath: string;
statePath: string;
}
export interface AutoReflectionPayload {
payloadPath: string;
endSnapshotLine: number;
}
export interface ReflectionPromptInput {
transcriptPath: string;
memoryDir: string;
cwd?: string;
}
export function buildReflectionSubagentPrompt(
input: ReflectionPromptInput,
): string {
const lines = [
"Review the conversation transcript and update memory files.",
`The current conversation transcript has been saved to: ${input.transcriptPath}`,
`The primary agent's memory filesystem is located at: ${input.memoryDir}`,
];
if (input.cwd) {
lines.push(`Your current working directory is: ${input.cwd}`);
}
return lines.join("\n");
}
function sanitizePathSegment(segment: string): string {
const sanitized = segment.replace(/[^a-zA-Z0-9._-]/g, "_").trim();
return sanitized.length > 0 ? sanitized : "unknown";
}
function getTranscriptRoot(): string {
const envRoot = process.env[TRANSCRIPT_ROOT_ENV]?.trim();
if (envRoot) {
return envRoot;
}
return join(homedir(), ".letta", DEFAULT_TRANSCRIPT_DIR);
}
function defaultState(): ReflectionTranscriptState {
return { auto_cursor_line: 0 };
}
function formatTaggedTranscript(entries: TranscriptEntry[]): string {
const lines: Line[] = [];
for (const [index, entry] of entries.entries()) {
const id = `transcript-${index}`;
switch (entry.kind) {
case "user":
lines.push({ kind: "user", id, text: entry.text });
break;
case "assistant":
lines.push({
kind: "assistant",
id,
text: entry.text,
phase: "finished",
});
break;
case "reasoning":
lines.push({
kind: "reasoning",
id,
text: entry.text,
phase: "finished",
});
break;
case "error":
lines.push({ kind: "error", id, text: entry.text });
break;
case "tool_call":
lines.push({
kind: "tool_call",
id,
name: entry.name,
argsText: entry.argsText,
resultText: entry.resultText,
resultOk: entry.resultOk,
phase: "finished",
});
break;
}
}
return linesToTranscript(lines);
}
function lineToTranscriptEntry(
line: Line,
capturedAt: string,
): TranscriptEntry | null {
switch (line.kind) {
case "user":
return { kind: "user", text: line.text, captured_at: capturedAt };
case "assistant":
return { kind: "assistant", text: line.text, captured_at: capturedAt };
case "reasoning":
return { kind: "reasoning", text: line.text, captured_at: capturedAt };
case "error":
return { kind: "error", text: line.text, captured_at: capturedAt };
case "tool_call":
return {
kind: "tool_call",
name: line.name,
argsText: line.argsText,
resultText: line.resultText,
resultOk: line.resultOk,
captured_at: capturedAt,
};
default:
return null;
}
}
function parseJsonLine<T>(line: string): T | null {
try {
return JSON.parse(line) as T;
} catch {
return null;
}
}
async function ensurePaths(paths: ReflectionTranscriptPaths): Promise<void> {
await mkdir(paths.rootDir, { recursive: true });
await writeFile(paths.transcriptPath, "", { encoding: "utf-8", flag: "a" });
}
async function readState(
paths: ReflectionTranscriptPaths,
): Promise<ReflectionTranscriptState> {
try {
const raw = await readFile(paths.statePath, "utf-8");
const parsed = parseJsonLine<Partial<ReflectionTranscriptState>>(raw);
if (!parsed) {
return defaultState();
}
return {
auto_cursor_line:
typeof parsed.auto_cursor_line === "number" &&
parsed.auto_cursor_line >= 0
? parsed.auto_cursor_line
: 0,
last_auto_reflection_started_at: parsed.last_auto_reflection_started_at,
last_auto_reflection_succeeded_at:
parsed.last_auto_reflection_succeeded_at,
};
} catch {
return defaultState();
}
}
async function writeState(
paths: ReflectionTranscriptPaths,
state: ReflectionTranscriptState,
): Promise<void> {
await writeFile(
paths.statePath,
`${JSON.stringify(state, null, 2)}\n`,
"utf-8",
);
}
async function readTranscriptLines(
paths: ReflectionTranscriptPaths,
): Promise<string[]> {
try {
const raw = await readFile(paths.transcriptPath, "utf-8");
return raw
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
} catch {
return [];
}
}
function buildPayloadPath(kind: "auto" | "remember"): string {
const nonce = Math.random().toString(36).slice(2, 8);
return join(tmpdir(), `letta-${kind}-${nonce}.txt`);
}
export function getReflectionTranscriptPaths(
agentId: string,
conversationId: string,
): ReflectionTranscriptPaths {
const rootDir = join(
getTranscriptRoot(),
sanitizePathSegment(agentId),
sanitizePathSegment(conversationId),
);
return {
rootDir,
transcriptPath: join(rootDir, "transcript.jsonl"),
statePath: join(rootDir, "state.json"),
};
}
export async function appendTranscriptDeltaJsonl(
agentId: string,
conversationId: string,
lines: Line[],
): Promise<number> {
const paths = getReflectionTranscriptPaths(agentId, conversationId);
await ensurePaths(paths);
const capturedAt = new Date().toISOString();
const entries = lines
.map((line) => lineToTranscriptEntry(line, capturedAt))
.filter((entry): entry is TranscriptEntry => entry !== null);
if (entries.length === 0) {
return 0;
}
const payload = entries.map((entry) => JSON.stringify(entry)).join("\n");
await appendFile(paths.transcriptPath, `${payload}\n`, "utf-8");
return entries.length;
}
export async function buildAutoReflectionPayload(
agentId: string,
conversationId: string,
): Promise<AutoReflectionPayload | null> {
const paths = getReflectionTranscriptPaths(agentId, conversationId);
await ensurePaths(paths);
const state = await readState(paths);
const lines = await readTranscriptLines(paths);
const cursorLine = Math.min(
Math.max(0, state.auto_cursor_line),
lines.length,
);
if (cursorLine !== state.auto_cursor_line) {
state.auto_cursor_line = cursorLine;
await writeState(paths, state);
}
if (cursorLine >= lines.length) {
return null;
}
const snapshotLines = lines.slice(cursorLine);
const entries = snapshotLines
.map((line) => parseJsonLine<TranscriptEntry>(line))
.filter((entry): entry is TranscriptEntry => entry !== null);
const transcript = formatTaggedTranscript(entries);
if (!transcript) {
return null;
}
const payloadPath = buildPayloadPath("auto");
await writeFile(payloadPath, transcript, "utf-8");
state.last_auto_reflection_started_at = new Date().toISOString();
await writeState(paths, state);
return {
payloadPath,
endSnapshotLine: lines.length,
};
}
export async function finalizeAutoReflectionPayload(
agentId: string,
conversationId: string,
_payloadPath: string,
endSnapshotLine: number,
success: boolean,
): Promise<void> {
const paths = getReflectionTranscriptPaths(agentId, conversationId);
await ensurePaths(paths);
const state = await readState(paths);
if (success) {
state.auto_cursor_line = Math.max(state.auto_cursor_line, endSnapshotLine);
state.last_auto_reflection_succeeded_at = new Date().toISOString();
}
await writeState(paths, state);
}

View File

@@ -56,7 +56,6 @@ import { createContextTracker } from "./cli/helpers/contextTracker";
import { formatErrorDetails } from "./cli/helpers/errorFormatter";
import {
getReflectionSettings,
type ReflectionBehavior,
type ReflectionSettings,
type ReflectionTrigger,
reflectionSettingsToLegacyMode,
@@ -150,7 +149,7 @@ export function mergeBidirectionalQueuedInput(
type ReflectionOverrides = {
trigger?: ReflectionTrigger;
behavior?: ReflectionBehavior;
deprecatedBehaviorRaw?: string;
stepCount?: number;
};
@@ -186,7 +185,7 @@ function parseReflectionOverrides(
`Invalid --reflection-behavior "${behaviorRaw}". Valid values: reminder, auto-launch`,
);
}
overrides.behavior = behaviorRaw;
overrides.deprecatedBehaviorRaw = behaviorRaw;
}
if (stepCountRaw !== undefined) {
@@ -208,7 +207,7 @@ function parseReflectionOverrides(
function hasReflectionOverrides(overrides: ReflectionOverrides): boolean {
return (
overrides.trigger !== undefined ||
overrides.behavior !== undefined ||
overrides.deprecatedBehaviorRaw !== undefined ||
overrides.stepCount !== undefined
);
}
@@ -220,7 +219,6 @@ async function applyReflectionOverrides(
const current = getReflectionSettings();
const merged: ReflectionSettings = {
trigger: overrides.trigger ?? current.trigger,
behavior: overrides.behavior ?? current.behavior,
stepCount: overrides.stepCount ?? current.stepCount,
};
@@ -228,21 +226,18 @@ async function applyReflectionOverrides(
return merged;
}
if (overrides.deprecatedBehaviorRaw !== undefined) {
console.warn(
"Warning: --reflection-behavior is deprecated and ignored. Reflection now always auto-launches subagents.",
);
}
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();
@@ -254,13 +249,11 @@ async function applyReflectionOverrides(
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,
});
@@ -946,6 +939,7 @@ export async function handleHeadlessCommand(
await applyMemfsFlags(agent.id, memfsFlag, noMemfsFlag, {
pullOnExistingRepo: false,
agentTags: agent.tags,
skipPromptUpdate: forceNew,
});
} catch (error) {
console.error(
@@ -959,6 +953,7 @@ export async function handleHeadlessCommand(
memfsBgPromise = applyMemfsFlags(agent.id, memfsFlag, noMemfsFlag, {
pullOnExistingRepo: true,
agentTags: agent.tags,
skipPromptUpdate: forceNew,
}).catch((error) => {
// Log to stderr only — the session is already live.
console.error(
@@ -973,7 +968,11 @@ export async function handleHeadlessCommand(
agent.id,
memfsFlag,
noMemfsFlag,
{ pullOnExistingRepo: true, agentTags: agent.tags },
{
pullOnExistingRepo: true,
agentTags: agent.tags,
skipPromptUpdate: forceNew,
},
);
if (memfsResult.pullSummary?.includes("CONFLICT")) {
console.error(
@@ -1229,7 +1228,6 @@ export async function handleHeadlessCommand(
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}`,
};
@@ -2415,7 +2413,6 @@ async function runBidirectionalMode(
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}`,
};
@@ -2885,7 +2882,6 @@ async function runBidirectionalMode(
skill_sources: skillSources,
system_info_reminder_enabled: systemInfoReminderEnabled,
reflection_trigger: reflectionSettings.trigger,
reflection_behavior: reflectionSettings.behavior,
reflection_step_count: reflectionSettings.stepCount,
},
},

View File

@@ -61,13 +61,12 @@ export const SHARED_REMINDER_CATALOG: ReadonlyArray<SharedReminderDefinition> =
},
{
id: "reflection-step-count",
description: "Step-count reflection reminder/auto-launch behavior",
description: "Step-count reflection trigger handling",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{
id: "reflection-compaction",
description:
"Compaction-triggered reflection reminder/auto-launch behavior",
description: "Compaction-triggered reflection trigger handling",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{

View File

@@ -11,6 +11,7 @@ import { buildSessionContext } from "../cli/helpers/sessionContext";
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../constants";
import { permissionMode } from "../permissions/mode";
import { settingsManager } from "../settings-manager";
import { debugLog } from "../utils/debug";
import {
SHARED_REMINDER_CATALOG,
type SharedReminderId,
@@ -157,20 +158,20 @@ async function buildReflectionStepReminder(
let reminder: string | null = null;
if (shouldFireStepTrigger) {
if (context.reflectionSettings.behavior === "reminder" || !memfsEnabled) {
if (memfsEnabled) {
if (context.maybeLaunchReflectionSubagent) {
await context.maybeLaunchReflectionSubagent("step-count");
} else {
debugLog(
"memory",
`Step-count reflection trigger fired with no launcher callback (agent ${context.agent.id})`,
);
}
} else {
reminder = await buildMemoryReminder(
context.state.turnCount,
context.agent.id,
);
} else {
if (context.maybeLaunchReflectionSubagent) {
await context.maybeLaunchReflectionSubagent("step-count");
} else {
reminder = await buildMemoryReminder(
context.state.turnCount,
context.agent.id,
);
}
}
}
@@ -193,11 +194,16 @@ async function buildReflectionCompactionReminder(
}
const memfsEnabled = settingsManager.isMemfsEnabled(context.agent.id);
if (context.reflectionSettings.behavior === "auto-launch" && memfsEnabled) {
if (memfsEnabled) {
if (context.maybeLaunchReflectionSubagent) {
await context.maybeLaunchReflectionSubagent("compaction-event");
return null;
} else {
debugLog(
"memory",
`Compaction reflection trigger fired with no launcher callback (agent ${context.agent.id})`,
);
}
return null;
}
return buildCompactionMemoryReminder(context.agent.id);

View File

@@ -5,7 +5,6 @@ import type { SharedReminderState } from "./state";
// hardcoded for now as we only need plan mode reminder for listener mode
const LISTEN_REFLECTION_SETTINGS: ReflectionSettings = {
trigger: "off",
behavior: "reminder",
stepCount: 25,
};

View File

@@ -69,7 +69,6 @@ export interface Settings {
sessionContextEnabled: boolean; // Send device/agent context on first message of each session
memoryReminderInterval: number | null | "compaction" | "auto-compaction"; // DEPRECATED: use reflection* fields
reflectionTrigger: "off" | "step-count" | "compaction-event";
reflectionBehavior: "reminder" | "auto-launch";
reflectionStepCount: number;
conversationSwitchAlertEnabled: boolean; // Send system-reminder when switching conversations/agents
globalSharedBlockIds: Record<string, string>; // DEPRECATED: kept for backwards compat
@@ -117,7 +116,6 @@ export interface LocalProjectSettings {
pinnedAgents?: string[]; // DEPRECATED: kept for backwards compat, use pinnedAgentsByServer
memoryReminderInterval?: number | null | "compaction" | "auto-compaction"; // DEPRECATED: use reflection* fields
reflectionTrigger?: "off" | "step-count" | "compaction-event";
reflectionBehavior?: "reminder" | "auto-launch";
reflectionStepCount?: number;
// Server-indexed settings (agent IDs are server-specific)
sessionsByServer?: Record<string, SessionRef>; // key = normalized base URL
@@ -135,7 +133,6 @@ const DEFAULT_SETTINGS: Settings = {
sessionContextEnabled: true,
memoryReminderInterval: 25, // DEPRECATED: use reflection* fields
reflectionTrigger: "step-count",
reflectionBehavior: "reminder",
reflectionStepCount: 25,
globalSharedBlockIds: {},
};
@@ -236,10 +233,25 @@ class SettingsManager {
} else {
// Read and parse settings
const content = await readFile(settingsPath);
const loadedSettings = JSON.parse(content) as Settings;
const loadedSettingsRaw = JSON.parse(content) as Record<
string,
unknown
>;
const hadLegacyReflectionBehavior = Object.hasOwn(
loadedSettingsRaw,
"reflectionBehavior",
);
if (hadLegacyReflectionBehavior) {
delete loadedSettingsRaw.reflectionBehavior;
// Mark for deletion on next persist; keep startup backward-compatible.
this.markDirty("reflectionBehavior");
}
// Merge with defaults in case new fields were added
this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings };
for (const key of Object.keys(loadedSettings)) {
this.settings = {
...DEFAULT_SETTINGS,
...(loadedSettingsRaw as Partial<Settings>),
};
for (const key of Object.keys(loadedSettingsRaw)) {
this.managedKeys.add(key);
}
}
@@ -719,6 +731,9 @@ class SettingsManager {
}
}
// Hard-deprecate legacy field (now fully ignored). Always strip from disk.
delete existingSettings.reflectionBehavior;
// Only write keys we loaded from the file or explicitly set via updateSettings().
// This preserves manual file edits for keys we never touched (e.g. defaults).
const merged: Record<string, unknown> = { ...existingSettings };
@@ -834,9 +849,24 @@ class SettingsManager {
}
const content = await readFile(settingsPath);
const localSettings = JSON.parse(content) as LocalProjectSettings;
const localSettingsRaw = JSON.parse(content) as Record<string, unknown>;
const hadLegacyReflectionBehavior = Object.hasOwn(
localSettingsRaw,
"reflectionBehavior",
);
if (hadLegacyReflectionBehavior) {
delete localSettingsRaw.reflectionBehavior;
}
const localSettings = localSettingsRaw as unknown as LocalProjectSettings;
this.localProjectSettings.set(workingDirectory, localSettings);
if (hadLegacyReflectionBehavior) {
try {
await this.persistLocalProjectSettings(workingDirectory);
} catch {
// Best-effort cleanup only; do not fail load path.
}
}
return { ...localSettings };
} catch (error) {
console.error(
@@ -921,6 +951,9 @@ class SettingsManager {
}
}
// Hard-deprecate legacy field (now fully ignored). Always strip from disk.
delete existingSettings.reflectionBehavior;
// Merge: existing fields + our managed settings
const merged = {
...existingSettings,

View File

@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
describe("createAgent memory prompt wiring", () => {
test("skips managed memory addon when initBlocks is explicitly none", () => {
const createPath = fileURLToPath(
new URL("../../agent/create.ts", import.meta.url),
);
const source = readFileSync(createPath, "utf-8");
expect(source).toContain("const disableManagedMemoryPrompt");
expect(source).toContain(
"options.initBlocks) && options.initBlocks.length === 0",
);
expect(source).toContain("resolveSystemPrompt(options.systemPromptPreset)");
});
});

View File

@@ -2,7 +2,7 @@ import { describe, expect, mock, test } from "bun:test";
import { recompileAgentSystemPrompt } from "../../agent/modify";
describe("recompileAgentSystemPrompt", () => {
test("calls the Letta agent recompile endpoint with mapped params", async () => {
test("calls the conversation recompile endpoint with mapped params", async () => {
const agentsRecompileMock = mock(
(_conversationId: string, _params?: Record<string, unknown>) =>
Promise.resolve("compiled-system-prompt"),
@@ -14,7 +14,7 @@ describe("recompileAgentSystemPrompt", () => {
};
const compiledPrompt = await recompileAgentSystemPrompt(
"agent-123",
"conv-123",
{
dryRun: true,
},
@@ -22,8 +22,52 @@ describe("recompileAgentSystemPrompt", () => {
);
expect(compiledPrompt).toBe("compiled-system-prompt");
expect(agentsRecompileMock).toHaveBeenCalledWith("agent-123", {
expect(agentsRecompileMock).toHaveBeenCalledWith("conv-123", {
dry_run: true,
});
});
test("passes agent_id for default conversation recompiles", async () => {
const agentsRecompileMock = mock(
(_conversationId: string, _params?: Record<string, unknown>) =>
Promise.resolve("compiled-system-prompt"),
);
const client = {
conversations: {
recompile: agentsRecompileMock,
},
};
await recompileAgentSystemPrompt(
"default",
{
agentId: "agent-123",
},
client,
);
expect(agentsRecompileMock).toHaveBeenCalledWith("default", {
dry_run: undefined,
agent_id: "agent-123",
});
});
test("throws when default conversation recompile lacks agent id", async () => {
const agentsRecompileMock = mock(
(_conversationId: string, _params?: Record<string, unknown>) =>
Promise.resolve("compiled-system-prompt"),
);
const client = {
conversations: {
recompile: agentsRecompileMock,
},
};
await expect(
recompileAgentSystemPrompt("default", {}, client),
).rejects.toThrow(
'recompileAgentSystemPrompt requires options.agentId when conversationId is "default"',
);
expect(agentsRecompileMock).not.toHaveBeenCalled();
});
});

View File

@@ -7,4 +7,12 @@ describe("built-in subagents", () => {
expect(configs.reflection).toBeDefined();
expect(configs.reflection?.name).toBe("reflection");
});
test("parses subagent mode and defaults missing mode to stateful", async () => {
const configs = await getAllSubagentConfigs();
expect(configs.reflection?.mode).toBe("stateless");
expect(configs["general-purpose"]?.mode).toBe("stateful");
expect(configs.memory?.mode).toBe("stateful");
});
});

View File

@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { SubagentConfig } from "../../agent/subagents";
import {
buildSubagentArgs,
resolveSubagentLauncher,
resolveSubagentModel,
} from "../../agent/subagents/manager";
@@ -118,6 +120,44 @@ describe("resolveSubagentLauncher", () => {
});
});
describe("buildSubagentArgs", () => {
const baseConfig: Omit<SubagentConfig, "mode"> = {
name: "test-subagent",
description: "test",
systemPrompt: "test prompt",
allowedTools: "all",
recommendedModel: "inherit",
skills: [],
memoryBlocks: "none",
};
test("adds --no-memfs for stateless subagents with memoryBlocks none", () => {
const args = buildSubagentArgs(
"test-subagent",
{ ...baseConfig, mode: "stateless" },
null,
"hello",
);
expect(args).toContain("--init-blocks");
expect(args).toContain("none");
expect(args).toContain("--no-memfs");
});
test("does not add --no-memfs for stateful subagents with memoryBlocks none", () => {
const args = buildSubagentArgs(
"test-subagent",
{ ...baseConfig, mode: "stateful" },
null,
"hello",
);
expect(args).toContain("--init-blocks");
expect(args).toContain("none");
expect(args).not.toContain("--no-memfs");
});
});
describe("resolveSubagentModel", () => {
test("prefers BYOK-swapped handle when available", async () => {
const cases = [

View File

@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
describe("headless memfs wiring", () => {
test("new-agent memfs sync skips prompt rewrite", () => {
const headlessPath = fileURLToPath(
new URL("../../headless.ts", import.meta.url),
);
const source = readFileSync(headlessPath, "utf-8");
const matches = source.match(/skipPromptUpdate:\s*forceNew/g) ?? [];
expect(matches.length).toBeGreaterThanOrEqual(3);
});
});

View File

@@ -48,6 +48,26 @@ describe("memory subagent recompile handling", () => {
);
});
test("passes agent id when recompiling the default conversation", async () => {
await handleMemorySubagentCompletion(
{
agentId: "agent-default",
conversationId: "default",
subagentType: "reflection",
success: true,
},
{
recompileByConversation: new Map(),
recompileQueuedByConversation: new Set(),
recompileAgentSystemPromptImpl: recompileAgentSystemPromptMock,
},
);
expect(recompileAgentSystemPromptMock).toHaveBeenCalledWith("default", {
agentId: "agent-default",
});
});
test("queues a trailing recompile when later completions land mid-flight", async () => {
const firstDeferred = createDeferred<string>();
const secondDeferred = createDeferred<string>();

View File

@@ -1,8 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
MEMORY_CHECK_REMINDER,
MEMORY_REFLECTION_REMINDER,
} from "../../agent/promptAssets";
import { MEMORY_CHECK_REMINDER } from "../../agent/promptAssets";
import {
buildCompactionMemoryReminder,
buildMemoryReminder,
@@ -10,6 +7,11 @@ import {
reflectionSettingsToLegacyMode,
shouldFireStepCountTrigger,
} from "../../cli/helpers/memoryReminder";
import {
type SharedReminderContext,
sharedReminderProviders,
} from "../../reminders/engine";
import { createSharedReminderState } from "../../reminders/state";
import { settingsManager } from "../../settings-manager";
const originalGetLocalProjectSettings = settingsManager.getLocalProjectSettings;
@@ -25,26 +27,25 @@ afterEach(() => {
});
describe("memoryReminder", () => {
test("prefers local reflection settings over global", () => {
test("prefers local reflection settings over global and ignores legacy behavior field", () => {
(settingsManager as typeof settingsManager).getLocalProjectSettings = () =>
({
reflectionTrigger: "compaction-event",
reflectionBehavior: "auto-launch",
reflectionStepCount: 33,
}) as ReturnType<typeof settingsManager.getLocalProjectSettings>;
(settingsManager as typeof settingsManager).getSettings = (() =>
({
memoryReminderInterval: 5,
reflectionTrigger: "step-count",
// Legacy key from older settings files should be ignored safely.
reflectionBehavior: "reminder",
reflectionStepCount: 25,
}) as ReturnType<
}) as unknown as ReturnType<
typeof settingsManager.getSettings
>) as typeof settingsManager.getSettings;
expect(getReflectionSettings()).toEqual({
trigger: "compaction-event",
behavior: "auto-launch",
stepCount: 33,
});
});
@@ -58,7 +59,6 @@ describe("memoryReminder", () => {
({
memoryReminderInterval: 5,
reflectionTrigger: "step-count",
reflectionBehavior: "reminder",
reflectionStepCount: 25,
}) as ReturnType<
typeof settingsManager.getSettings
@@ -66,7 +66,6 @@ describe("memoryReminder", () => {
expect(getReflectionSettings()).toEqual({
trigger: "compaction-event",
behavior: "reminder",
stepCount: 25,
});
});
@@ -75,13 +74,11 @@ describe("memoryReminder", () => {
(settingsManager as typeof settingsManager).getLocalProjectSettings = () =>
({
reflectionTrigger: "compaction-event",
reflectionBehavior: "reminder",
}) as ReturnType<typeof settingsManager.getLocalProjectSettings>;
(settingsManager as typeof settingsManager).getSettings = (() =>
({
memoryReminderInterval: 5,
reflectionTrigger: "step-count",
reflectionBehavior: "reminder",
reflectionStepCount: 25,
}) as ReturnType<
typeof settingsManager.getSettings
@@ -95,14 +92,12 @@ describe("memoryReminder", () => {
(settingsManager as typeof settingsManager).getLocalProjectSettings = () =>
({
reflectionTrigger: "step-count",
reflectionBehavior: "auto-launch",
reflectionStepCount: 5,
}) as ReturnType<typeof settingsManager.getLocalProjectSettings>;
(settingsManager as typeof settingsManager).getSettings = (() =>
({
memoryReminderInterval: 10,
reflectionTrigger: "step-count",
reflectionBehavior: "reminder",
reflectionStepCount: 25,
}) as ReturnType<
typeof settingsManager.getSettings
@@ -118,55 +113,149 @@ describe("memoryReminder", () => {
expect(
reflectionSettingsToLegacyMode({
trigger: "off",
behavior: "reminder",
stepCount: 25,
}),
).toBeNull();
expect(
reflectionSettingsToLegacyMode({
trigger: "step-count",
behavior: "auto-launch",
stepCount: 30,
}),
).toBe(30);
expect(
reflectionSettingsToLegacyMode({
trigger: "compaction-event",
behavior: "auto-launch",
stepCount: 25,
}),
).toBe("auto-compaction");
});
test("builds compaction reminder with memfs-aware reflection content", async () => {
test("builds compaction reminder using memory-check content", async () => {
(settingsManager as typeof settingsManager).isMemfsEnabled = (() =>
true) as typeof settingsManager.isMemfsEnabled;
const reminder = await buildCompactionMemoryReminder("agent-1");
expect(reminder).toBe(MEMORY_REFLECTION_REMINDER);
expect(reminder).toBe(MEMORY_CHECK_REMINDER);
});
test("evaluates step-count trigger based on effective settings", () => {
expect(
shouldFireStepCountTrigger(10, {
trigger: "step-count",
behavior: "auto-launch",
stepCount: 5,
}),
).toBe(true);
expect(
shouldFireStepCountTrigger(10, {
trigger: "step-count",
behavior: "reminder",
stepCount: 6,
}),
).toBe(false);
expect(
shouldFireStepCountTrigger(10, {
trigger: "off",
behavior: "reminder",
stepCount: 5,
}),
).toBe(false);
});
});
describe("reflection trigger orchestration", () => {
const stepProvider = sharedReminderProviders["reflection-step-count"];
const compactionProvider = sharedReminderProviders["reflection-compaction"];
function buildReflectionContext(
overrides: Partial<{
trigger: "off" | "step-count" | "compaction-event";
stepCount: number;
turnCount: number;
memfsEnabled: boolean;
callback:
| ((trigger: "step-count" | "compaction-event") => Promise<boolean>)
| undefined;
pendingReflectionTrigger: boolean;
}> = {},
): SharedReminderContext {
const state = createSharedReminderState();
state.turnCount = overrides.turnCount ?? 1;
state.pendingReflectionTrigger =
overrides.pendingReflectionTrigger ?? false;
(settingsManager as typeof settingsManager).isMemfsEnabled = (() =>
overrides.memfsEnabled ?? true) as typeof settingsManager.isMemfsEnabled;
(settingsManager as typeof settingsManager).getSettings = (() =>
({
memoryReminderInterval: 25,
reflectionTrigger: overrides.trigger ?? "step-count",
reflectionStepCount: overrides.stepCount ?? 1,
}) as ReturnType<
typeof settingsManager.getSettings
>) as typeof settingsManager.getSettings;
(settingsManager as typeof settingsManager).getLocalProjectSettings = () =>
({
reflectionTrigger: overrides.trigger ?? "step-count",
reflectionStepCount: overrides.stepCount ?? 1,
}) as ReturnType<typeof settingsManager.getLocalProjectSettings>;
return {
mode: "interactive",
agent: { id: "test-agent", name: "test" },
state,
sessionContextReminderEnabled: false,
reflectionSettings: {
trigger: overrides.trigger ?? "step-count",
stepCount: overrides.stepCount ?? 1,
},
skillSources: [],
resolvePlanModeReminder: async () => "",
maybeLaunchReflectionSubagent: overrides.callback,
};
}
test("memfs step-count trigger launches reflection callback and returns no reminder", async () => {
const launches: Array<"step-count" | "compaction-event"> = [];
const context = buildReflectionContext({
memfsEnabled: true,
callback: async (trigger) => {
launches.push(trigger);
return true;
},
});
const reminder = await stepProvider(context);
expect(reminder).toBeNull();
expect(launches).toEqual(["step-count"]);
});
test("memfs step-count trigger with no callback does not emit reminder text", async () => {
const context = buildReflectionContext({
memfsEnabled: true,
callback: undefined,
});
const reminder = await stepProvider(context);
expect(reminder).toBeNull();
});
test("non-memfs step-count trigger falls back to memory-check reminder", async () => {
const context = buildReflectionContext({
memfsEnabled: false,
callback: undefined,
});
const reminder = await stepProvider(context);
expect(reminder).toBe(MEMORY_CHECK_REMINDER);
});
test("memfs compaction trigger with no callback emits no reminder", async () => {
const context = buildReflectionContext({
trigger: "compaction-event",
memfsEnabled: true,
callback: undefined,
pendingReflectionTrigger: true,
});
const reminder = await compactionProvider(context);
expect(reminder).toBeNull();
});
});

View File

@@ -15,6 +15,8 @@ describe("reflection auto-launch wiring", () => {
expect(appSource).toContain("const maybeLaunchReflectionSubagent = async");
expect(appSource).toContain("hasActiveReflectionSubagent()");
expect(appSource).toContain("buildAutoReflectionPayload(");
expect(appSource).toContain("finalizeAutoReflectionPayload(");
expect(appSource).toContain("spawnBackgroundSubagentTask({");
expect(appSource).toContain("maybeLaunchReflectionSubagent,");
@@ -25,4 +27,16 @@ describe("reflection auto-launch wiring", () => {
'await context.maybeLaunchReflectionSubagent("compaction-event")',
);
});
test("/remember sends REMEMBER_PROMPT to primary agent via processConversation", () => {
const appPath = fileURLToPath(
new URL("../../cli/App.tsx", import.meta.url),
);
const appSource = readFileSync(appPath, "utf-8");
// /remember uses the primary agent path (no subagent)
expect(appSource).toContain("REMEMBER_PROMPT");
expect(appSource).toContain("processConversation([");
expect(appSource).toContain("The user did not specify what to remember.");
});
});

View File

@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
appendTranscriptDeltaJsonl,
buildAutoReflectionPayload,
finalizeAutoReflectionPayload,
getReflectionTranscriptPaths,
} from "../../cli/helpers/reflectionTranscript";
describe("reflectionTranscript helper", () => {
const agentId = "agent-test";
const conversationId = "conv-test";
let testRoot: string;
beforeEach(async () => {
testRoot = await mkdtemp(join(tmpdir(), "letta-transcript-test-"));
process.env.LETTA_TRANSCRIPT_ROOT = testRoot;
});
afterEach(async () => {
delete process.env.LETTA_TRANSCRIPT_ROOT;
await rm(testRoot, { recursive: true, force: true });
});
test("auto payload advances cursor on success", async () => {
await appendTranscriptDeltaJsonl(agentId, conversationId, [
{ kind: "user", id: "u1", text: "hello" },
{
kind: "assistant",
id: "a1",
text: "hi there",
phase: "finished",
},
]);
const payload = await buildAutoReflectionPayload(agentId, conversationId);
expect(payload).not.toBeNull();
if (!payload) return;
const payloadText = await readFile(payload.payloadPath, "utf-8");
expect(payloadText).toContain("<user>hello</user>");
expect(payloadText).toContain("<assistant>hi there</assistant>");
await finalizeAutoReflectionPayload(
agentId,
conversationId,
payload.payloadPath,
payload.endSnapshotLine,
true,
);
expect(existsSync(payload.payloadPath)).toBe(true);
const paths = getReflectionTranscriptPaths(agentId, conversationId);
const stateRaw = await readFile(paths.statePath, "utf-8");
const state = JSON.parse(stateRaw) as { auto_cursor_line: number };
expect(state.auto_cursor_line).toBe(payload.endSnapshotLine);
const secondPayload = await buildAutoReflectionPayload(
agentId,
conversationId,
);
expect(secondPayload).toBeNull();
});
test("auto payload keeps cursor on failure", async () => {
await appendTranscriptDeltaJsonl(agentId, conversationId, [
{ kind: "user", id: "u1", text: "remember this" },
]);
const payload = await buildAutoReflectionPayload(agentId, conversationId);
expect(payload).not.toBeNull();
if (!payload) return;
await finalizeAutoReflectionPayload(
agentId,
conversationId,
payload.payloadPath,
payload.endSnapshotLine,
false,
);
const paths = getReflectionTranscriptPaths(agentId, conversationId);
const stateRaw = await readFile(paths.statePath, "utf-8");
const state = JSON.parse(stateRaw) as { auto_cursor_line: number };
expect(state.auto_cursor_line).toBe(0);
const retried = await buildAutoReflectionPayload(agentId, conversationId);
expect(retried).not.toBeNull();
});
test("auto payload clamps out-of-range cursor and resumes on new transcript lines", async () => {
await appendTranscriptDeltaJsonl(agentId, conversationId, [
{ kind: "user", id: "u1", text: "first" },
]);
const paths = getReflectionTranscriptPaths(agentId, conversationId);
await writeFile(
paths.statePath,
`${JSON.stringify({ auto_cursor_line: 999 })}\n`,
"utf-8",
);
const firstAttempt = await buildAutoReflectionPayload(
agentId,
conversationId,
);
expect(firstAttempt).toBeNull();
const clampedRaw = await readFile(paths.statePath, "utf-8");
const clamped = JSON.parse(clampedRaw) as { auto_cursor_line: number };
expect(clamped.auto_cursor_line).toBe(1);
await appendTranscriptDeltaJsonl(agentId, conversationId, [
{ kind: "assistant", id: "a2", text: "second", phase: "finished" },
]);
const secondAttempt = await buildAutoReflectionPayload(
agentId,
conversationId,
);
expect(secondAttempt).not.toBeNull();
if (!secondAttempt) return;
const payloadText = await readFile(secondAttempt.payloadPath, "utf-8");
expect(payloadText).toContain("<assistant>second</assistant>");
});
});

View File

@@ -36,7 +36,6 @@ describe("shared reminder parity", () => {
const reflectionSettings: ReflectionSettings = {
trigger: "off",
behavior: "reminder",
stepCount: 25,
};
@@ -96,7 +95,6 @@ describe("shared reminder parity", () => {
const reflectionSettings: ReflectionSettings = {
trigger: "off",
behavior: "reminder",
stepCount: 25,
};

View File

@@ -26,7 +26,6 @@ function baseContext(
sessionContextReminderEnabled: false,
reflectionSettings: {
trigger: "off",
behavior: "reminder",
stepCount: 25,
},
skillSources: [],

View File

@@ -21,7 +21,6 @@ function baseContext(
sessionContextReminderEnabled: true,
reflectionSettings: {
trigger: "off",
behavior: "reminder",
stepCount: 25,
},
skillSources: [],

View File

@@ -111,6 +111,40 @@ describe("Settings Manager - Initialization", () => {
"Settings not initialized",
);
});
test("Initialize tolerates legacy reflectionBehavior key and strips it on persist", async () => {
const { writeFile, readFile, mkdir } = await import("../utils/fs.js");
const settingsDir = join(testHomeDir, ".letta");
await mkdir(settingsDir, { recursive: true });
const settingsPath = join(settingsDir, "settings.json");
await writeFile(
settingsPath,
JSON.stringify({
reflectionBehavior: "reminder",
reflectionTrigger: "step-count",
reflectionStepCount: 12,
}),
);
await settingsManager.initialize();
const settings = settingsManager.getSettings() as unknown as Record<
string,
unknown
>;
expect(settings.reflectionTrigger).toBe("step-count");
expect(settings.reflectionStepCount).toBe(12);
expect(settings).not.toHaveProperty("reflectionBehavior");
settingsManager.updateSettings({ tokenStreaming: true });
await new Promise((resolve) => setTimeout(resolve, 100));
const persisted = JSON.parse(await readFile(settingsPath)) as Record<
string,
unknown
>;
expect(persisted).not.toHaveProperty("reflectionBehavior");
});
});
// ============================================================================
@@ -404,6 +438,34 @@ describe("Settings Manager - Local Project Settings", () => {
expect(localSettings.lastAgent).toBe(null);
});
test("Load local settings tolerates legacy reflectionBehavior key and strips it", async () => {
const { writeFile, readFile, mkdir } = await import("../utils/fs.js");
const settingsDir = join(testProjectDir, ".letta");
await mkdir(settingsDir, { recursive: true });
const settingsPath = join(settingsDir, "settings.local.json");
await writeFile(
settingsPath,
JSON.stringify({
lastAgent: "agent-local-legacy",
reflectionBehavior: "reminder",
reflectionTrigger: "step-count",
reflectionStepCount: 9,
}),
);
const localSettings =
await settingsManager.loadLocalProjectSettings(testProjectDir);
expect(localSettings.lastAgent).toBe("agent-local-legacy");
expect(localSettings).not.toHaveProperty("reflectionBehavior");
const persisted = JSON.parse(await readFile(settingsPath)) as Record<
string,
unknown
>;
expect(persisted).not.toHaveProperty("reflectionBehavior");
});
test("Get local project settings returns cached value", async () => {
await settingsManager.loadLocalProjectSettings(testProjectDir);

View File

@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { isOpenAIModel } from "../../tools/manager";
import { deriveToolsetFromModel } from "../../tools/toolset";
@@ -29,3 +31,15 @@ describe("deriveToolsetFromModel", () => {
expect(deriveToolsetFromModel("chatgpt_oauth/gpt-5.3-codex")).toBe("codex");
});
});
describe("toolset initialization safety", () => {
test("avoids top-level toolset aliases that can trigger circular-import TDZ", () => {
const toolsetPath = fileURLToPath(
new URL("../../tools/toolset.ts", import.meta.url),
);
const source = readFileSync(toolsetPath, "utf-8");
expect(source).not.toContain("const CODEX_TOOLS = OPENAI_PASCAL_TOOLS");
expect(source).toContain("loadSpecificTools([...OPENAI_PASCAL_TOOLS])");
});
});

View File

@@ -446,7 +446,7 @@ export async function task(args: TaskArgs): Promise<string> {
}
// Extract validated params
const prompt = args.prompt as string;
const inputPrompt = args.prompt as string;
const description = args.description as string;
// For existing agents, default subagent_type to "general-purpose" for permissions
@@ -468,6 +468,8 @@ export async function task(args: TaskArgs): Promise<string> {
return `Error: When deploying an existing agent, subagent_type must be "explore" (read-only) or "general-purpose" (read-write). Got: "${subagent_type}"`;
}
const prompt = inputPrompt;
const isBackground = args.run_in_background ?? false;
// Handle background execution

View File

@@ -14,9 +14,8 @@ import {
} from "./manager";
// Toolset definitions from manager.ts (single source of truth)
const CODEX_TOOLS = OPENAI_PASCAL_TOOLS;
const CODEX_SNAKE_TOOLS = OPENAI_DEFAULT_TOOLS;
const GEMINI_TOOLS = GEMINI_PASCAL_TOOLS;
// Keep these as direct references at call-sites (not top-level aliases) to avoid
// temporal-dead-zone issues under circular import initialization.
// Server-side memory tool names that can mutate memory blocks.
// When memfs is enabled, we detach ALL of these from the agent.
@@ -240,13 +239,13 @@ export async function forceToolsetSwitch(
clearToolsWithLock();
return;
} else if (toolsetName === "codex") {
await loadSpecificTools([...CODEX_TOOLS]);
await loadSpecificTools([...OPENAI_PASCAL_TOOLS]);
modelForLoading = "openai/gpt-4";
} else if (toolsetName === "codex_snake") {
await loadSpecificTools([...CODEX_SNAKE_TOOLS]);
await loadSpecificTools([...OPENAI_DEFAULT_TOOLS]);
modelForLoading = "openai/gpt-4";
} else if (toolsetName === "gemini") {
await loadSpecificTools([...GEMINI_TOOLS]);
await loadSpecificTools([...GEMINI_PASCAL_TOOLS]);
modelForLoading = "google_ai/gemini-3-pro-preview";
} else if (toolsetName === "gemini_snake") {
await loadTools("google_ai/gemini-3-pro-preview");

View File

@@ -100,7 +100,6 @@ export interface SystemInitMessage extends MessageEnvelope {
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
}