From 5df327755f5de0339d3f91e8bbcacf135115ec63 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Tue, 2 Dec 2025 00:10:45 -0800 Subject: [PATCH] feat: add startup status messages showing agent provenance (#147) Co-authored-by: Letta --- src/agent/create.ts | 58 ++++++++++++- src/cli/App.tsx | 123 +++++++++++++++++++++++++-- src/cli/components/StatusMessage.tsx | 41 +++++++++ src/cli/helpers/accumulator.ts | 5 ++ src/headless.ts | 6 +- src/index.ts | 13 ++- src/tests/agent/init-blocks.test.ts | 2 +- src/tests/message.smoke.ts | 2 +- src/tests/test-image-send.ts | 2 +- 9 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 src/cli/components/StatusMessage.tsx diff --git a/src/agent/create.ts b/src/agent/create.ts index a6e747b..c8d3156 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -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(); + // Track provenance: which blocks came from which source + const blockProvenance: BlockProvenance[] = []; + const globalBlockLabels = new Set(); + const projectBlockLabels = new Set(); // 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 }; } diff --git a/src/cli/App.tsx b/src/cli/App.tsx index ccc487f..a4ba98d 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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}`; ) : item.kind === "error" ? ( - ) : ( + ) : item.kind === "status" ? ( + + ) : item.kind === "command" ? ( - )} + ) : null} )} @@ -2779,9 +2890,11 @@ Plan file path: ${planFilePath}`; ) : ln.kind === "error" ? ( - ) : ( + ) : ln.kind === "status" ? ( + + ) : ln.kind === "command" ? ( - )} + ) : null} ))} diff --git a/src/cli/components/StatusMessage.tsx b/src/cli/components/StatusMessage.tsx new file mode 100644 index 0000000..9011dad --- /dev/null +++ b/src/cli/components/StatusMessage.tsx @@ -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 ( + + {line.lines.map((text, idx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Static status lines never reorder + + + {idx === 0 ? "●" : " "} + + + {text} + + + ))} + + ); +}); + +StatusMessage.displayName = "StatusMessage"; diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 7e6783c..34082dc 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -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 diff --git a/src/headless.ts b/src/headless.ts index 9ef27cd..fd29071 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 5deba36..fbb2d33 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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(null); const [resumeData, setResumeData] = useState(null); const [isResumingSession, setIsResumingSession] = useState(false); + const [agentProvenance, setAgentProvenance] = + useState(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, }); } diff --git a/src/tests/agent/init-blocks.test.ts b/src/tests/agent/init-blocks.test.ts index f8dfe16..3ee9bb5 100644 --- a/src/tests/agent/init-blocks.test.ts +++ b/src/tests/agent/init-blocks.test.ts @@ -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", diff --git a/src/tests/message.smoke.ts b/src/tests/message.smoke.ts index 8649768..8dedb2a 100755 --- a/src/tests/message.smoke.ts +++ b/src/tests/message.smoke.ts @@ -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..."); diff --git a/src/tests/test-image-send.ts b/src/tests/test-image-send.ts index e1d4ad1..402b50a 100644 --- a/src/tests/test-image-send.ts +++ b/src/tests/test-image-send.ts @@ -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