Skip to content
2 changes: 1 addition & 1 deletion scripts/source-file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"src/cli/mods/local-mod-loader.test.ts": 1043,
"src/cli/reflection-transcript.test.ts": 1084,
"src/cli/subcommands/skills.ts": 1264,
"src/headless.ts": 5054,
"src/headless.ts": 5043,
"src/hooks/integration.test.ts": 1147,
"src/index.ts": 2775,
"src/mods/learning-harness.ts": 2434,
Expand Down
60 changes: 60 additions & 0 deletions src/agent/ephemeral-conversation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildEphemeralConversationCreateBody,
createLocalEphemeralConversation,
} from "@/agent/ephemeral-conversation";
import {
configureBackendMode,
configureEphemeralLocalBackend,
} from "@/backend";

describe("ephemeral conversation creation", () => {
afterEach(() => {
configureBackendMode("api");
});

test("builds execution state without agent memory or tags", async () => {
const body = await buildEphemeralConversationCreateBody({
model: "gpt-5.6-luna",
systemPromptCustom: "isolated prompt",
});

expect(body.model).toBe("openai/gpt-5.6-luna");
expect(body.system).toBe("isolated prompt");
expect(body.context_window_limit).toBeGreaterThan(0);
expect(body).not.toHaveProperty("agent_id");
expect(body).not.toHaveProperty("tags");
expect(body).not.toHaveProperty("memory_blocks");
});

test("creates local execution state outside the persistent local store", async () => {
const storageDir = mkdtempSync(join(tmpdir(), "letta-local-persistent-"));
const originalStorageDir = process.env.LETTA_LOCAL_BACKEND_DIR;
process.env.LETTA_LOCAL_BACKEND_DIR = storageDir;

try {
configureBackendMode("local");
configureEphemeralLocalBackend();
const result = await createLocalEphemeralConversation({
model: "openai/gpt-5-mini",
systemPromptCustom: "isolated local prompt",
});

expect(result.agent.id).toStartWith("agent-local-");
expect(result.conversationId).toStartWith("local-conv-");
expect(existsSync(join(storageDir, "agents"))).toBe(false);
expect(existsSync(join(storageDir, "conversations"))).toBe(false);
expect(existsSync(join(storageDir, "memfs"))).toBe(false);
} finally {
if (originalStorageDir === undefined) {
delete process.env.LETTA_LOCAL_BACKEND_DIR;
} else {
process.env.LETTA_LOCAL_BACKEND_DIR = originalStorageDir;
}
rmSync(storageDir, { recursive: true, force: true });
}
});
});
115 changes: 115 additions & 0 deletions src/agent/ephemeral-conversation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
import { getModelContextWindow } from "@/agent/available-models";
import { buildCreateAgentRequest } from "@/agent/create-agent-request";
import { getModelUpdateArgs } from "@/agent/model";
import type { MemoryPromptMode } from "@/agent/prompt-assets";
import { resolveAndBuildSystemPrompt } from "@/agent/system-prompt-resolution";
import { getBackend } from "@/backend";
import {
createEphemeralConversation as createEphemeralConversationRequest,
type EphemeralConversationCreateBody,
} from "@/backend/api/ephemeral-conversations";

export interface CreateEphemeralConversationOptions {
model?: string;
systemPromptPreset?: string;
systemPromptCustom?: string;
memoryPromptMode?: MemoryPromptMode;
}

export async function buildEphemeralConversationCreateBody(
options: CreateEphemeralConversationOptions,
): Promise<EphemeralConversationCreateBody> {
const system = options.systemPromptCustom
? options.systemPromptCustom
: await resolveAndBuildSystemPrompt(
options.systemPromptPreset,
options.memoryPromptMode ?? "standard",
);
const request = await buildCreateAgentRequest({
model: options.model,
system,
memoryPromptMode: "standard",
enableMemfs: false,
isSubagent: true,
baseTools: [],
});
const modelSettings = options.model
? getModelUpdateArgs(options.model)
: undefined;
const contextWindow =
(modelSettings?.context_window as number | undefined) ??
(await getModelContextWindow(request.model));
return {
model: request.model,
system: request.system,
...(modelSettings ? { model_settings: modelSettings } : {}),
...(contextWindow ? { context_window_limit: contextWindow } : {}),
};
}

function projectEphemeralAgent(
conversationId: string,
body: EphemeralConversationCreateBody,
): AgentState {
return {
id: conversationId,
name: "Ephemeral conversation",
system: body.system,
tools: [],
memory: { blocks: [] },
llm_config: {
handle: body.model,
model: body.model,
context_window: body.context_window_limit ?? undefined,
model_settings: body.model_settings ?? {},
},
model_settings: body.model_settings ?? {},
message_buffer_autoclear: false,
} as unknown as AgentState;
}

export async function createEphemeralConversation(
options: CreateEphemeralConversationOptions,
): Promise<{ agent: AgentState; conversationId: string }> {
const body = await buildEphemeralConversationCreateBody(options);
const conversation = await createEphemeralConversationRequest(body);
return {
agent: projectEphemeralAgent(conversation.id, body),
conversationId: conversation.id,
};
}

export async function createLocalEphemeralConversation(
options: CreateEphemeralConversationOptions,
): Promise<{ agent: AgentState; conversationId: string }> {
const body = await buildEphemeralConversationCreateBody(options);
const backend = getBackend();
const internalAgent = await backend.createAgent({
agent_type: "letta_v1_agent",
name: "Ephemeral conversation",
model: body.model,
system: body.system,
memory_blocks: [],
tags: [],
tools: [],
include_base_tools: false,
include_base_tool_rules: false,
initial_message_sequence: [],
parallel_tool_calls: true,
hidden: true,
});
const conversation = await backend.createConversation({
agent_id: internalAgent.id,
model: body.model,
...(body.model_settings ? { model_settings: body.model_settings } : {}),
...(body.context_window_limit
? { context_window_limit: body.context_window_limit }
: {}),
});

return {
agent: internalAgent,
conversationId: conversation.id,
};
}
26 changes: 26 additions & 0 deletions src/backend/api/ephemeral-conversations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { apiRequest } from "./request";

export interface EphemeralConversationCreateBody {
[key: string]: unknown;
model: string;
system: string;
model_settings?: Record<string, unknown>;
context_window_limit?: number | null;
}

export interface EphemeralConversation {
id: string;
agent_id: null;
model: string;
context_window_limit: number | null;
}

export async function createEphemeralConversation(
body: EphemeralConversationCreateBody,
): Promise<EphemeralConversation> {
return apiRequest<EphemeralConversation>(
"POST",
"/v1/conversations/ephemeral",
body,
);
}
34 changes: 29 additions & 5 deletions src/backend/backend.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { homedir } from "node:os";
import { mkdtempSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
import type { getClient } from "./api/client";
import type {
Expand Down Expand Up @@ -529,13 +531,16 @@ export function getLocalBackendStorageDir(homeDir = homedir()): string {
return getLocalBackendStorageDirFromPaths(homeDir);
}

function localBackendExecutionMode(): "deterministic" | "pi" {
return process.env.LETTA_LOCAL_BACKEND_EXECUTOR === "deterministic"
? "deterministic"
: "pi";
}

function createExperimentalLocalBackend(): Backend {
return new LocalBackend({
storageDir: getLocalBackendStorageDir(),
executionMode:
process.env.LETTA_LOCAL_BACKEND_EXECUTOR === "deterministic"
? "deterministic"
: "pi",
executionMode: localBackendExecutionMode(),
});
}

Expand Down Expand Up @@ -568,6 +573,25 @@ export function configureBackendMode(mode: BackendMode): void {
backend = createBackendForMode(mode);
}

export function configureEphemeralLocalBackend(): void {
if (resolveBackendMode() !== "local") {
throw new Error("Ephemeral local backend requires local backend mode");
}

const stateStorageDir = mkdtempSync(
join(tmpdir(), "letta-code-ephemeral-local-"),
);
backend = new LocalBackend({
storageDir: getLocalBackendStorageDir(),
stateStorageDir,
memfsEnabled: false,
executionMode: localBackendExecutionMode(),
});
process.once("exit", () => {
rmSync(stateStorageDir, { recursive: true, force: true });
});
}

export function isLocalBackendEnabled(): boolean {
return resolveBackendMode() === "local";
}
Expand Down
4 changes: 2 additions & 2 deletions src/backend/local/local-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {

export interface LocalBackendOptions {
storageDir: string;
stateStorageDir?: string;
defaultAgentId?: string;
executionMode?: LocalBackendExecutionMode;
executor?: HeadlessTurnExecutor;
Expand All @@ -79,7 +80,6 @@ export interface LocalBackendOptions {
memfsEnabled?: boolean;
modelsRuntime?: LocalPiModelsRuntime;
}

/**
* Hooks the harness installs (via {@link LocalBackend.setModEventHooks}) so
* mods can observe backend-internal lifecycle that only the local backend owns
Expand Down Expand Up @@ -269,7 +269,7 @@ export class LocalBackend extends HeadlessBackend {
new LocalPiModelsRuntime({ storageDir: options.storageDir });
const modelConfig = resolveLocalModelConfig(options.storageDir, runtime);
const storeOptions: LocalStoreOptions = {
storageDir: options.storageDir,
storageDir: options.stateStorageDir ?? options.storageDir,
seedDefaultAgent: false,
strictAgentAccess: true,
strictConversationAccess: true,
Expand Down
2 changes: 2 additions & 0 deletions src/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ describe("shared CLI arg schema", () => {

expect(headlessFlags).toContain("memfs-startup");
expect(headlessFlags).toContain("stateless");
expect(headlessFlags).toContain("ephemeral");
expect(headlessFlags).not.toContain("resume");
expect(interactiveFlags).toContain("resume");
expect(interactiveFlags).not.toContain("memfs-startup");
expect(interactiveFlags).not.toContain("stateless");
expect(interactiveFlags).not.toContain("ephemeral");
expect(headlessFlags).toContain("agent");
expect(interactiveFlags).toContain("agent");
expect(headlessFlags).toContain("no-mods");
Expand Down
7 changes: 7 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,13 @@ export const CLI_FLAG_CATALOG = {
mode: "both",
help: { description: "Enable memory filesystem for this agent" },
},
ephemeral: {
parser: { type: "boolean" },
mode: "headless",
help: {
description: "Run in a temporary conversation with no agent or memory",
},
},
stateless: {
parser: { type: "boolean" },
mode: "headless",
Expand Down
29 changes: 29 additions & 0 deletions src/cli/startup-flag-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,35 @@ describe("startup flag validation helpers", () => {
).toThrow("--stateless requires --agent");
});

test("ephemeral startup rejects agent-backed and memory-backed modes", () => {
const baseOptions = {
specifiedConversationId: null,
specifiedAgentId: null,
specifiedAgentName: null,
forceNewAgent: false,
forceNewConversation: false,
importFile: null,
stateless: false,
ephemeral: true,
isHeadless: true,
memfs: false,
memfsStartup: undefined,
};

expect(() =>
validatePrimaryStartupFlagConflicts(baseOptions),
).not.toThrow();
expect(() =>
validatePrimaryStartupFlagConflicts({
...baseOptions,
specifiedAgentId: "agent-123",
}),
).toThrow("--ephemeral cannot be used with --agent");
expect(() =>
validatePrimaryStartupFlagConflicts({ ...baseOptions, memfs: true }),
).toThrow("--ephemeral cannot be used with --stateless, --memfs");
});

test("primary startup validation preserves conversation conflict behavior", () => {
expect(() =>
validatePrimaryStartupFlagConflicts({
Expand Down
Loading
Loading