feat: add startup status messages showing agent provenance (#147)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
123
src/cli/App.tsx
123
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}`;
|
||||
<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>
|
||||
|
||||
41
src/cli/components/StatusMessage.tsx
Normal file
41
src/cli/components/StatusMessage.tsx
Normal 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";
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
13
src/index.ts
13
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<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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user