feat: add startup status messages showing agent provenance (#147)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-02 00:10:45 -08:00
committed by GitHub
parent 709de8efec
commit 5df327755f
9 changed files with 236 additions and 16 deletions

View File

@@ -3,7 +3,10 @@
**/
import { join } from "node:path";
import type { AgentType } from "@letta-ai/letta-client/resources/agents/agents";
import type {
AgentState,
AgentType,
} from "@letta-ai/letta-client/resources/agents/agents";
import type {
BlockResponse,
CreateBlock,
@@ -22,6 +25,31 @@ import { SYSTEM_PROMPT, SYSTEM_PROMPTS } from "./promptAssets";
import { SLEEPTIME_MEMORY_PERSONA } from "./prompts/sleeptime";
import { discoverSkills, formatSkillsForMemory, SKILLS_DIR } from "./skills";
/**
* Describes where a memory block came from
*/
export interface BlockProvenance {
label: string;
source: "global" | "project" | "new";
}
/**
* Provenance info for an agent creation
*/
export interface AgentProvenance {
isNew: true;
freshBlocks: boolean;
blocks: BlockProvenance[];
}
/**
* Result from createAgent including provenance info
*/
export interface CreateAgentResult {
agent: AgentState;
provenance: AgentProvenance;
}
export async function createAgent(
name = "letta-cli-agent",
model?: string,
@@ -139,6 +167,10 @@ export async function createAgent(
// Retrieve existing blocks (both global and local) and match them with defaults
const existingBlocks = new Map<string, BlockResponse>();
// Track provenance: which blocks came from which source
const blockProvenance: BlockProvenance[] = [];
const globalBlockLabels = new Set<string>();
const projectBlockLabels = new Set<string>();
// Only load existing blocks if we're not forcing new blocks
if (!forceNewBlocks) {
@@ -150,6 +182,7 @@ export async function createAgent(
try {
const block = await client.blocks.retrieve(blockId);
existingBlocks.set(label, block);
globalBlockLabels.add(label);
} catch {
// Block no longer exists, will create new one
console.warn(
@@ -158,7 +191,7 @@ export async function createAgent(
}
}
// Load local blocks (style)
// Load local blocks (project, skills)
for (const [label, blockId] of Object.entries(localSharedBlockIds)) {
if (allowedBlockLabels && !allowedBlockLabels.has(label)) {
continue;
@@ -166,6 +199,7 @@ export async function createAgent(
try {
const block = await client.blocks.retrieve(blockId);
existingBlocks.set(label, block);
projectBlockLabels.add(label);
} catch {
// Block no longer exists, will create new one
console.warn(
@@ -182,8 +216,14 @@ export async function createAgent(
for (const defaultBlock of filteredMemoryBlocks) {
const existingBlock = existingBlocks.get(defaultBlock.label);
if (existingBlock?.id) {
// Reuse existing global shared block
// Reuse existing shared block
blockIds.push(existingBlock.id);
// Record provenance based on where it came from
if (globalBlockLabels.has(defaultBlock.label)) {
blockProvenance.push({ label: defaultBlock.label, source: "global" });
} else if (projectBlockLabels.has(defaultBlock.label)) {
blockProvenance.push({ label: defaultBlock.label, source: "project" });
}
} else {
// Need to create this block
blocksToCreate.push({
@@ -211,6 +251,9 @@ export async function createAgent(
} else {
newGlobalBlockIds[label] = createdBlock.id;
}
// Record as newly created
blockProvenance.push({ label, source: "new" });
} catch (error) {
console.error(`Failed to create block ${label}:`, error);
throw error;
@@ -310,5 +353,12 @@ export async function createAgent(
}
}
return fullAgent;
// Build provenance info
const provenance: AgentProvenance = {
isNew: true,
freshBlocks: forceNewBlocks,
blocks: blockProvenance,
};
return { agent: fullAgent, provenance };
}

View File

@@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApprovalResult } from "../agent/approval-execution";
import { getResumeData } from "../agent/check-approval";
import { getClient } from "../agent/client";
import type { AgentProvenance } from "../agent/create";
import { sendMessageStream } from "../agent/message";
import { linkToolsToAgent, unlinkToolsFromAgent } from "../agent/modify";
import { SessionStats } from "../agent/stats";
@@ -46,6 +47,7 @@ import { QuestionDialog } from "./components/QuestionDialog";
// import { ReasoningMessage } from "./components/ReasoningMessage";
import { ReasoningMessage } from "./components/ReasoningMessageRich";
import { SessionStats as SessionStatsComponent } from "./components/SessionStats";
import { StatusMessage } from "./components/StatusMessage";
import { SystemPromptSelector } from "./components/SystemPromptSelector";
// import { ToolCallMessage } from "./components/ToolCallMessage";
import { ToolCallMessage } from "./components/ToolCallMessageRich";
@@ -199,6 +201,74 @@ function getSkillUnloadReminder(): string {
return "";
}
// Generate status lines based on agent provenance
function generateStatusLines(
continueSession: boolean,
agentProvenance: AgentProvenance | null,
agentState?: AgentState | null,
): string[] {
const lines: string[] = [];
// For resumed agents
if (continueSession) {
lines.push(`Resumed existing agent (${agentState?.id})`);
// Show attached blocks if available
if (agentState?.memory?.blocks) {
const labels = agentState.memory.blocks
.map((b) => b.label)
.filter(Boolean)
.join(", ");
if (labels) {
lines.push(` → Memory blocks: ${labels}`);
}
}
lines.push(" → To create a new agent, use --new");
return lines;
}
// For new agents with provenance
if (agentProvenance) {
if (agentProvenance.freshBlocks) {
lines.push(`Created new agent (${agentState?.id})`);
const allLabels = agentProvenance.blocks.map((b) => b.label).join(", ");
if (allLabels) {
lines.push(` → Created new memory blocks: ${allLabels}`);
}
} else {
lines.push(`Created new agent (${agentState?.id})`);
// Group blocks by source
const globalBlocks = agentProvenance.blocks
.filter((b) => b.source === "global")
.map((b) => b.label);
const projectBlocks = agentProvenance.blocks
.filter((b) => b.source === "project")
.map((b) => b.label);
const newBlocks = agentProvenance.blocks
.filter((b) => b.source === "new")
.map((b) => b.label);
if (globalBlocks.length > 0) {
lines.push(
` → Reusing from global (~/.letta/): ${globalBlocks.join(", ")}`,
);
}
if (projectBlocks.length > 0) {
lines.push(
` → Reusing from project (.letta/): ${projectBlocks.join(", ")}`,
);
}
if (newBlocks.length > 0) {
lines.push(` → Created new blocks: ${newBlocks.join(", ")}`);
}
}
}
return lines;
}
// Items that have finished rendering and no longer change
type StaticItem =
| {
@@ -221,6 +291,7 @@ export default function App({
startupApprovals = [],
messageHistory = [],
tokenStreaming = true,
agentProvenance = null,
}: {
agentId: string;
agentState?: AgentState | null;
@@ -237,6 +308,7 @@ export default function App({
startupApprovals?: ApprovalRequest[];
messageHistory?: Message[];
tokenStreaming?: boolean;
agentProvenance?: AgentProvenance | null;
}) {
// Track current agent (can change when swapping)
const [agentId, setAgentId] = useState(initialAgentId);
@@ -386,7 +458,7 @@ export default function App({
const ln = b.byId.get(id);
if (!ln) continue;
// console.log(`[COMMIT] Checking ${id}: kind=${ln.kind}, phase=${(ln as any).phase}`);
if (ln.kind === "user" || ln.kind === "error") {
if (ln.kind === "user" || ln.kind === "error" || ln.kind === "status") {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
// console.log(`[COMMIT] Committed ${id} (${ln.kind})`);
@@ -511,6 +583,23 @@ export default function App({
}
// Use backfillBuffers to properly populate the transcript from history
backfillBuffers(buffersRef.current, messageHistory);
// Inject status line at the end of the backfilled history
const statusLines = generateStatusLines(
continueSession,
agentProvenance,
agentState,
);
if (statusLines.length > 0) {
const statusId = `status-${Date.now().toString(36)}`;
buffersRef.current.byId.set(statusId, {
kind: "status",
id: statusId,
lines: statusLines,
});
buffersRef.current.order.push(statusId);
}
refreshDerived();
commitEligibleLines(buffersRef.current);
}
@@ -522,6 +611,7 @@ export default function App({
continueSession,
columns,
agentState,
agentProvenance,
]);
// Fetch llmConfig when agent is ready
@@ -2715,13 +2805,32 @@ Plan file path: ${planFilePath}`;
},
},
]);
// Inject status line for fresh sessions
const statusLines = generateStatusLines(
continueSession,
agentProvenance,
agentState,
);
if (statusLines.length > 0) {
const statusId = `status-${Date.now().toString(36)}`;
buffersRef.current.byId.set(statusId, {
kind: "status",
id: statusId,
lines: statusLines,
});
buffersRef.current.order.push(statusId);
refreshDerived();
}
}
}, [
loadingState,
continueSession,
messageHistory.length,
columns,
agentProvenance,
agentState,
refreshDerived,
]);
return (
@@ -2745,9 +2854,11 @@ Plan file path: ${planFilePath}`;
<ToolCallMessage line={item} />
) : item.kind === "error" ? (
<ErrorMessage line={item} />
) : (
) : item.kind === "status" ? (
<StatusMessage line={item} />
) : item.kind === "command" ? (
<CommandMessage line={item} />
)}
) : null}
</Box>
)}
</Static>
@@ -2779,9 +2890,11 @@ Plan file path: ${planFilePath}`;
<ToolCallMessage line={ln} />
) : ln.kind === "error" ? (
<ErrorMessage line={ln} />
) : (
) : ln.kind === "status" ? (
<StatusMessage line={ln} />
) : ln.kind === "command" ? (
<CommandMessage line={ln} />
)}
) : null}
</Box>
))}
</Box>

View File

@@ -0,0 +1,41 @@
import { Box, Text } from "ink";
import { memo } from "react";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
type StatusLine = {
kind: "status";
id: string;
lines: string[];
};
/**
* StatusMessage - Displays multi-line status messages
*
* Used for agent provenance info at startup, showing:
* - Whether agent is resumed or newly created
* - Where memory blocks came from (global/project/new)
*
* Layout matches ErrorMessage with a left column icon (grey circle)
*/
export const StatusMessage = memo(({ line }: { line: StatusLine }) => {
const columns = useTerminalWidth();
const contentWidth = Math.max(0, columns - 2);
return (
<Box flexDirection="column">
{line.lines.map((text, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Static status lines never reorder
<Box key={idx} flexDirection="row">
<Box width={2} flexShrink={0}>
<Text dimColor>{idx === 0 ? "●" : " "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text dimColor>{text}</Text>
</Box>
</Box>
))}
</Box>
);
});
StatusMessage.displayName = "StatusMessage";

View File

@@ -44,6 +44,11 @@ export type Line =
output: string;
phase?: "running" | "finished";
success?: boolean;
}
| {
kind: "status";
id: string;
lines: string[]; // Multi-line status message with arrow formatting
};
// Top-level state object for all streaming events

View File

@@ -142,7 +142,7 @@ export async function handleHeadlessCommand(
// Priority 2: Check if --new flag was passed (skip all resume logic)
if (!agent && forceNew) {
const updateArgs = getModelUpdateArgs(model);
agent = await createAgent(
const result = await createAgent(
undefined,
model,
undefined,
@@ -155,6 +155,7 @@ export async function handleHeadlessCommand(
initBlocks,
baseTools,
);
agent = result.agent;
}
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
@@ -186,7 +187,7 @@ export async function handleHeadlessCommand(
// Priority 5: Create a new agent
if (!agent) {
const updateArgs = getModelUpdateArgs(model);
agent = await createAgent(
const result = await createAgent(
undefined,
model,
undefined,
@@ -199,6 +200,7 @@ export async function handleHeadlessCommand(
undefined,
undefined,
);
agent = result.agent;
}
// Save agent ID to both project and global settings

View File

@@ -4,6 +4,7 @@ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"
import { getResumeData, type ResumeData } from "./agent/check-approval";
import { getClient } from "./agent/client";
import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context";
import type { AgentProvenance } from "./agent/create";
import { permissionMode } from "./permissions/mode";
import { settingsManager } from "./settings-manager";
import { loadTools, upsertToolsToServer } from "./tools/manager";
@@ -431,6 +432,8 @@ async function main() {
const [agentState, setAgentState] = useState<AgentState | null>(null);
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
const [isResumingSession, setIsResumingSession] = useState(false);
const [agentProvenance, setAgentProvenance] =
useState<AgentProvenance | null>(null);
useEffect(() => {
async function init() {
@@ -557,7 +560,7 @@ async function main() {
if (!agent && forceNew) {
// Create new agent (reuses global blocks unless --fresh-blocks passed)
const updateArgs = getModelUpdateArgs(model);
agent = await createAgent(
const result = await createAgent(
undefined,
model,
undefined,
@@ -570,6 +573,8 @@ async function main() {
initBlocks,
baseTools,
);
agent = result.agent;
setAgentProvenance(result.provenance);
}
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
@@ -606,7 +611,7 @@ async function main() {
// Priority 5: Create a new agent
if (!agent) {
const updateArgs = getModelUpdateArgs(model);
agent = await createAgent(
const result = await createAgent(
undefined,
model,
undefined,
@@ -619,6 +624,8 @@ async function main() {
undefined,
undefined,
);
agent = result.agent;
setAgentProvenance(result.provenance);
}
// Ensure local project settings are loaded before updating
@@ -705,6 +712,7 @@ async function main() {
startupApprovals: resumeData?.pendingApprovals ?? [],
messageHistory: resumeData?.messageHistory ?? [],
tokenStreaming: settings.tokenStreaming,
agentProvenance,
});
}
@@ -717,6 +725,7 @@ async function main() {
startupApprovals: resumeData?.pendingApprovals ?? [],
messageHistory: resumeData?.messageHistory ?? [],
tokenStreaming: settings.tokenStreaming,
agentProvenance,
});
}

View File

@@ -54,7 +54,7 @@ describeOrSkip("createAgent init-blocks filtering", () => {
test(
"only requested memory blocks are created/registered",
async () => {
const agent = await createAgent(
const { agent } = await createAgent(
"init-blocks-test",
undefined,
"openai/text-embedding-3-small",

View File

@@ -14,7 +14,7 @@ async function main() {
}
console.log("🧠 Creating test agent...");
const agent = await createAgent("smoke-agent", "openai/gpt-4.1");
const { agent } = await createAgent("smoke-agent", "openai/gpt-4.1");
console.log(`✅ Agent created: ${agent.id}`);
console.log("💬 Sending test message...");

View File

@@ -12,7 +12,7 @@ async function main() {
// Create agent
console.log("\nCreating test agent...");
const agent = await createAgent("image-test-agent");
const { agent } = await createAgent("image-test-agent");
console.log("Agent created:", agent.id);
// Read image