From f10cf11d4f45aca5f22c5be2e56390599a60d7d4 Mon Sep 17 00:00:00 2001 From: Letta Integration <300689746+letta-integration[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:41:59 +0000 Subject: [PATCH 1/6] feat(headless): run in agent-free ephemeral conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create explicit temporary conversations in Cloud instead of creating disposable agents, and eagerly delete them after one-shot headless runs while preserving the existing output envelope. See [trace](https://app.letta.com/chat/agent-57231da8-42f3-4523-b190-66c3eda21057?conversation=conv-d54c06b4-452d-4749-a088-2ab9094b362a). Resume conversation with `letta --conv conv-d54c06b4-452d-4749-a088-2ab9094b362a` 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Bob Co-Authored-By: Letta Code --- scripts/source-file-size-baseline.json | 2 +- src/agent/ephemeral-conversation.test.ts | 18 ++ src/agent/ephemeral-conversation.ts | 73 +++++++ src/backend/api/ephemeral-conversations.ts | 35 +++ src/cli/args.test.ts | 2 + src/cli/args.ts | 7 + src/cli/startup-flag-validation.test.ts | 29 +++ src/cli/startup-flag-validation.ts | 31 +++ src/headless-ephemeral-startup.ts | 67 ++++++ src/headless-reflection-settings.ts | 39 ++++ src/headless.ts | 204 +++++++++--------- .../headless-stream-json-format.test.ts | 2 + src/types/protocol.ts | 6 +- 13 files changed, 405 insertions(+), 110 deletions(-) create mode 100644 src/agent/ephemeral-conversation.test.ts create mode 100644 src/agent/ephemeral-conversation.ts create mode 100644 src/backend/api/ephemeral-conversations.ts create mode 100644 src/headless-ephemeral-startup.ts create mode 100644 src/headless-reflection-settings.ts diff --git a/scripts/source-file-size-baseline.json b/scripts/source-file-size-baseline.json index 7924d1103d..49180adf68 100644 --- a/scripts/source-file-size-baseline.json +++ b/scripts/source-file-size-baseline.json @@ -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": 5105, + "src/headless.ts": 5097, "src/hooks/integration.test.ts": 1147, "src/index.ts": 2773, "src/mods/learning-harness.ts": 2434, diff --git a/src/agent/ephemeral-conversation.test.ts b/src/agent/ephemeral-conversation.test.ts new file mode 100644 index 0000000000..846afea6fc --- /dev/null +++ b/src/agent/ephemeral-conversation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { buildEphemeralConversationCreateBody } from "@/agent/ephemeral-conversation"; + +describe("ephemeral conversation creation", () => { + 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"); + }); +}); diff --git a/src/agent/ephemeral-conversation.ts b/src/agent/ephemeral-conversation.ts new file mode 100644 index 0000000000..07ea2f44bd --- /dev/null +++ b/src/agent/ephemeral-conversation.ts @@ -0,0 +1,73 @@ +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 { + 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 { + 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 } : {}), + }; +} + +export async function createEphemeralConversation( + options: CreateEphemeralConversationOptions, +): Promise<{ agent: AgentState; conversationId: string }> { + const body = await buildEphemeralConversationCreateBody(options); + const conversation = await createEphemeralConversationRequest(body); + const agent = { + id: conversation.id, + name: "Ephemeral conversation", + system: body.system, + tools: [], + tags: [], + 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; + + return { agent, conversationId: conversation.id }; +} diff --git a/src/backend/api/ephemeral-conversations.ts b/src/backend/api/ephemeral-conversations.ts new file mode 100644 index 0000000000..bb695fae6b --- /dev/null +++ b/src/backend/api/ephemeral-conversations.ts @@ -0,0 +1,35 @@ +import { apiRequest } from "./request"; + +export interface EphemeralConversationCreateBody { + [key: string]: unknown; + model: string; + system: string; + model_settings?: Record; + context_window_limit?: number | null; +} + +export interface EphemeralConversation { + id: string; + agent_id: null; + model: string; + context_window_limit: number | null; +} + +export async function deleteEphemeralConversation( + conversationId: string, +): Promise { + await apiRequest( + "DELETE", + `/v1/conversations/${encodeURIComponent(conversationId)}`, + ); +} + +export async function createEphemeralConversation( + body: EphemeralConversationCreateBody, +): Promise { + return apiRequest( + "POST", + "/v1/conversations/ephemeral", + body, + ); +} diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index f328b2ddcd..4d29ed259f 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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"); diff --git a/src/cli/args.ts b/src/cli/args.ts index eb6d51d67d..281add92b6 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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", diff --git a/src/cli/startup-flag-validation.test.ts b/src/cli/startup-flag-validation.test.ts index 77b71b2393..384fe650a8 100644 --- a/src/cli/startup-flag-validation.test.ts +++ b/src/cli/startup-flag-validation.test.ts @@ -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({ diff --git a/src/cli/startup-flag-validation.ts b/src/cli/startup-flag-validation.ts index 24f8145ee6..15eaa9dcdd 100644 --- a/src/cli/startup-flag-validation.ts +++ b/src/cli/startup-flag-validation.ts @@ -71,6 +71,7 @@ interface PrimaryStartupFlagOptions { importFile: string | null | undefined; shouldResume?: boolean | null; stateless: boolean | null | undefined; + ephemeral?: boolean | null; isHeadless: boolean; memfs: boolean | null | undefined; memfsStartup: string | null | undefined; @@ -79,6 +80,36 @@ interface PrimaryStartupFlagOptions { export function validatePrimaryStartupFlagConflicts( options: PrimaryStartupFlagOptions, ): void { + validateFlagConflicts({ + guard: options.ephemeral, + checks: [ + { + when: !options.isHeadless, + message: "--ephemeral is only supported in headless mode", + }, + { + when: + options.specifiedAgentId || + options.specifiedAgentName || + options.specifiedConversationId, + message: + "--ephemeral cannot be used with --agent, --name, or --conversation", + }, + { + when: options.forceNewAgent || options.forceNewConversation, + message: "--ephemeral cannot be used with --new-agent or --new", + }, + { + when: options.stateless || options.memfs || options.memfsStartup, + message: + "--ephemeral cannot be used with --stateless, --memfs, or --memfs-startup", + }, + { + when: options.importFile || options.shouldResume, + message: "--ephemeral cannot be used with --import or --resume", + }, + ], + }); validateStatelessStartupOptions({ stateless: options.stateless, isHeadless: options.isHeadless, diff --git a/src/headless-ephemeral-startup.ts b/src/headless-ephemeral-startup.ts new file mode 100644 index 0000000000..0c68afeb2e --- /dev/null +++ b/src/headless-ephemeral-startup.ts @@ -0,0 +1,67 @@ +import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; +import { createEphemeralConversation } from "@/agent/ephemeral-conversation"; +import { deleteEphemeralConversation } from "@/backend/api/ephemeral-conversations"; +import { clearPersistedClientToolRules } from "@/tools/toolset"; +import { debugLog, debugWarn } from "@/utils/debug"; + +export async function createHeadlessEphemeralConversation(params: { + backendMode: string; + personality: string | null | undefined; + model: string | undefined; + systemPromptPreset: string | undefined; + systemPromptCustom: string | undefined; +}): Promise<{ agent: AgentState; conversationId: string }> { + if (params.backendMode !== "api") { + throw new Error("--ephemeral requires the Letta Cloud API backend"); + } + if (params.personality) { + throw new Error( + "--ephemeral cannot be used with --personality because it has no memory blocks", + ); + } + return createEphemeralConversation({ + model: params.model, + systemPromptPreset: params.systemPromptPreset, + systemPromptCustom: params.systemPromptCustom, + memoryPromptMode: "standard", + }); +} + +export async function cleanupHeadlessEphemeralConversation( + conversationId: string | null, +): Promise { + if (!conversationId) return; + try { + await deleteEphemeralConversation(conversationId); + } catch (error) { + debugWarn( + "headless cleanup", + `Failed to delete ephemeral conversation ${conversationId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export function clearHeadlessClientToolRules(agent: AgentState): void { + void clearPersistedClientToolRules(agent.id, agent) + .then((cleanup) => { + if (cleanup) { + const count = cleanup.removedToolNames.length; + const names = cleanup.removedToolNames.join(", "); + debugLog( + "headless startup", + `Cleared ${count} persisted client tool rule${count === 1 ? "" : "s"} for ${agent.id}${count > 0 ? `: ${names}` : ""}`, + ); + return; + } + debugLog( + "headless startup", + `No persisted client tool rules to clear for ${agent.id}`, + ); + }) + .catch((error) => { + debugWarn( + "headless startup", + `Failed to clear persisted client tool rules for ${agent.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); +} diff --git a/src/headless-reflection-settings.ts b/src/headless-reflection-settings.ts new file mode 100644 index 0000000000..1b82a9241c --- /dev/null +++ b/src/headless-reflection-settings.ts @@ -0,0 +1,39 @@ +import { + getReflectionSettings, + persistReflectionSettingsForAgent, + type ReflectionSettings, + type ReflectionTrigger, +} from "@/cli/helpers/memory-reminder"; +import { settingsManager } from "@/settings-manager"; + +export interface ReflectionOverrides { + trigger?: ReflectionTrigger; + stepCount?: number; +} + +export async function applyHeadlessReflectionOverrides( + agentId: string, + overrides: ReflectionOverrides, +): Promise { + const current = getReflectionSettings(agentId); + const merged: ReflectionSettings = { + ...current, + trigger: overrides.trigger ?? current.trigger, + stepCount: overrides.stepCount ?? current.stepCount, + }; + if (overrides.trigger === undefined && overrides.stepCount === undefined) { + return merged; + } + if (!settingsManager.isMemfsEnabled(agentId) && merged.trigger !== "off") { + throw new Error( + `--reflection-trigger ${merged.trigger} requires memfs enabled for this agent.`, + ); + } + try { + settingsManager.getLocalProjectSettings(); + } catch { + await settingsManager.loadLocalProjectSettings(); + } + await persistReflectionSettingsForAgent(agentId, merged); + return merged; +} diff --git a/src/headless.ts b/src/headless.ts index 1eda5476cc..34eda9365c 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -90,11 +90,9 @@ import { import { classifyApprovals } from "./cli/helpers/approval-classification"; import { createContextTracker } from "./cli/helpers/context-tracker"; import { formatErrorDetails } from "./cli/helpers/error-formatter"; -import { - getReflectionSettings, - persistReflectionSettingsForAgent, - type ReflectionSettings, - type ReflectionTrigger, +import type { + ReflectionSettings, + ReflectionTrigger, } from "./cli/helpers/memory-reminder"; import { maybeLaunchPostTurnReflection } from "./cli/helpers/post-turn-reflection"; import { @@ -115,6 +113,11 @@ import { } from "./cli/startup-flag-validation"; import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "./constants"; import { waitForEnvironmentAssistantMessage } from "./headless-environment-response"; +import { + cleanupHeadlessEphemeralConversation, + clearHeadlessClientToolRules, + createHeadlessEphemeralConversation, +} from "./headless-ephemeral-startup"; import { resolveHeadlessMemfsPolicy } from "./headless-memfs-policy"; import { createHeadlessModAdapter, @@ -122,6 +125,10 @@ import { emitHeadlessConversationClose, emitHeadlessConversationOpen, } from "./headless-mod-adapter"; +import { + applyHeadlessReflectionOverrides, + type ReflectionOverrides, +} from "./headless-reflection-settings"; import { emitLocalToolCalls, emitLocalToolReturns, @@ -160,10 +167,7 @@ import { registerExternalTools, setExternalToolExecutor, } from "./tools/manager"; -import { - clearPersistedClientToolRules, - prepareToolExecutionContextForScope, -} from "./tools/toolset"; +import { prepareToolExecutionContextForScope } from "./tools/toolset"; import type { BootstrapSessionStateRequest, CanUseToolControlRequest, @@ -377,11 +381,6 @@ export const __headlessTestUtils = { prepareHeadlessToolExecutionContext, }; -type ReflectionOverrides = { - trigger?: ReflectionTrigger; - stepCount?: number; -}; - function parseReflectionOverrides( values: ParsedCliArgs["values"], ): ReflectionOverrides { @@ -423,42 +422,6 @@ function parseReflectionOverrides( return overrides; } -function hasReflectionOverrides(overrides: ReflectionOverrides): boolean { - return overrides.trigger !== undefined || overrides.stepCount !== undefined; -} - -async function applyReflectionOverrides( - agentId: string, - overrides: ReflectionOverrides, -): Promise { - const current = getReflectionSettings(agentId); - const merged: ReflectionSettings = { - ...current, - trigger: overrides.trigger ?? current.trigger, - stepCount: overrides.stepCount ?? current.stepCount, - }; - if (!hasReflectionOverrides(overrides)) { - return merged; - } - - const memfsEnabled = settingsManager.isMemfsEnabled(agentId); - if (!memfsEnabled && merged.trigger !== "off") { - throw new Error( - `--reflection-trigger ${merged.trigger} requires memfs enabled for this agent.`, - ); - } - - try { - settingsManager.getLocalProjectSettings(); - } catch { - await settingsManager.loadLocalProjectSettings(); - } - - await persistReflectionSettingsForAgent(agentId, merged); - - return merged; -} - async function prepareHeadlessToolExecutionContext(params: { agentId: string; conversationId: string; @@ -891,6 +854,7 @@ export async function handleHeadlessCommand( // Resolve agent (same logic as interactive mode) let agent: AgentState | null = null; + let ephemeralConversationId: string | null = null; let autoEnableMemfsForFreshAgent = false; const startupBackendMode = backend.capabilities.localModelCatalog ? "local" @@ -900,6 +864,7 @@ export async function handleHeadlessCommand( let specifiedConversationId = values.conversation; let specifiedAgentIdFromAmbientBackendSwitch = false; const forceNew = values["new-agent"]; + const ephemeralFlag = values.ephemeral; const systemPromptPreset = values.system; const systemCustom = values["system-custom"]; const personalityInput = values.personality; @@ -915,12 +880,14 @@ export async function handleHeadlessCommand( // Fresh subagents are stateless by role. --stateless extends only the // MemFS-less session behavior to an existing --agent/--conversation launch; // it does not change that agent's model, prompt, tools, or sampling config. - const { isFreshStatelessSubagent, isStatelessSession } = - resolveHeadlessMemfsPolicy({ - statelessRequested: Boolean(statelessFlag), - isSubagentRole, - newAgentRequested: Boolean(forceNew), - }); + const memfsPolicy = resolveHeadlessMemfsPolicy({ + statelessRequested: Boolean(statelessFlag), + isSubagentRole, + newAgentRequested: Boolean(forceNew), + }); + const { isFreshStatelessSubagent } = memfsPolicy; + const isStatelessSession = + Boolean(ephemeralFlag) || memfsPolicy.isStatelessSession; if (isStatelessSession && backend.capabilities.localMemfs) { const { disableLocalBackendMemfsForProcess } = await import( "@/backend/local/paths" @@ -1097,6 +1064,7 @@ export async function handleHeadlessCommand( forceNewConversation, importFile: fromAfFile, stateless: statelessFlag, + ephemeral: ephemeralFlag, isHeadless: true, memfs: memfsFlag, memfsStartup: values["memfs-startup"], @@ -1109,6 +1077,14 @@ export async function handleHeadlessCommand( ); } + if (ephemeralFlag && (isBidirectionalMode || usesRemoteEnvironment)) { + return reportAndExitHeadless( + "headless_ephemeral_transport_unsupported", + "--ephemeral supports direct one-shot headless prompts only", + "headless_startup_flag_conflicts", + ); + } + // Validate --import flag (also accepts legacy --from-af) // Detect if it's a registry handle (e.g., @author/name) or a local file path let isRegistryImport = false; @@ -1296,6 +1272,28 @@ export async function handleHeadlessCommand( } } + if (!agent && ephemeralFlag) { + try { + const result = await createHeadlessEphemeralConversation({ + backendMode: startupBackendMode, + personality: personalityInput, + model, + systemPromptPreset, + systemPromptCustom: systemCustom, + }); + agent = result.agent; + ephemeralConversationId = result.conversationId; + } catch (error) { + await reportStartupErrorAndExit( + "headless_ephemeral_conversation_create_failed", + error, + "headless_startup_agent_create", + values["output-format"] || "text", + ); + throw error; + } + } + // Priority 3: Check if --new flag was passed (skip all resume logic) if (!agent && forceNew) { // Pre-determine memfs mode so the agent is created with the correct prompt. @@ -1416,14 +1414,18 @@ export async function handleHeadlessCommand( process.exit(1); } markMilestone("HEADLESS_AGENT_RESOLVED"); - telemetry.setCurrentAgentId(agent.id); - await replaceClientMcpServers( - agent.id, - settingsManager.getMcpServers(agent.id), - { stderr: "pipe" }, - ); + const publicAgentId = ephemeralFlag ? null : agent.id; + telemetry.setCurrentAgentId(publicAgentId); + if (!ephemeralFlag) { + await replaceClientMcpServers( + agent.id, + settingsManager.getMcpServers(agent.id), + { stderr: "pipe" }, + ); + } - const isResumingAgent = !!(specifiedAgentId || (!forceNew && !fromAfFile)); + const isResumingAgent = + !ephemeralFlag && !!(specifiedAgentId || (!forceNew && !fromAfFile)); // Refresh presets before applying optional model/system-prompt overrides. if (isResumingAgent) { @@ -1497,7 +1499,7 @@ export async function handleHeadlessCommand( let memfsBgPromise: Promise | undefined; // Init secrets cache — runs in parallel with memfs sync below. - const secretsAgentId = agent?.id; + const secretsAgentId = ephemeralFlag ? undefined : agent?.id; const secretsInitPromise = secretsAgentId ? import("@/utils/secrets-store").then(({ initSecretsFromServer }) => initSecretsFromServer(secretsAgentId, agent ?? undefined), @@ -1648,39 +1650,22 @@ export async function handleHeadlessCommand( }); } - const startupAgentId = agent.id; - void clearPersistedClientToolRules(startupAgentId, agent) - .then((cleanup) => { - if (cleanup) { - const count = cleanup.removedToolNames.length; - const names = cleanup.removedToolNames.join(", "); - debugLog( - "headless startup", - `Cleared ${count} persisted client tool rule${count === 1 ? "" : "s"} for ${startupAgentId}${count > 0 ? `: ${names}` : ""}`, - ); - return; - } - - debugLog( - "headless startup", - `No persisted client tool rules to clear for ${startupAgentId}`, - ); - }) - .catch((error) => { - debugWarn( - "headless startup", - `Failed to clear persisted client tool rules for ${startupAgentId}: ${error instanceof Error ? error.message : String(error)}`, - ); - }); + if (!ephemeralFlag) { + clearHeadlessClientToolRules(agent); + } try { - const resolvedReflectionSettings = await applyReflectionOverrides( - agent.id, - reflectionOverrides, - ); - effectiveReflectionSettings = isStatelessSession - ? { ...resolvedReflectionSettings, trigger: "off" } - : resolvedReflectionSettings; + if (ephemeralFlag) { + effectiveReflectionSettings = { trigger: "off", stepCount: 0 }; + } else { + const resolvedReflectionSettings = await applyHeadlessReflectionOverrides( + agent.id, + reflectionOverrides, + ); + effectiveReflectionSettings = isStatelessSession + ? { ...resolvedReflectionSettings, trigger: "off" } + : resolvedReflectionSettings; + } } catch (error) { console.error( `Failed to apply sleeptime settings: ${error instanceof Error ? error.message : String(error)}`, @@ -1688,7 +1673,10 @@ export async function handleHeadlessCommand( process.exit(1); } - if (specifiedConversationId) { + if (ephemeralConversationId) { + conversationId = ephemeralConversationId; + conversationOpenReason = "new"; + } else if (specifiedConversationId) { if (specifiedConversationId === "default") { // "default" is the agent's primary message history (no explicit conversation) // Don't validate - just use it directly @@ -1751,7 +1739,7 @@ export async function handleHeadlessCommand( // Save session (agent + conversation) to both project and global settings // Skip for subagents - they shouldn't pollute the LRU settings - if (shouldPersistSessionState()) { + if (!ephemeralFlag && shouldPersistSessionState()) { await settingsManager.loadLocalProjectSettings(); settingsManager.persistSession(agent.id, conversationId); } @@ -1908,6 +1896,8 @@ export async function handleHeadlessCommand( telemetry.trackSessionEnd(sessionStats.getSnapshot(), exitReason); await telemetry.flush(); } finally { + await cleanupHeadlessEphemeralConversation(ephemeralConversationId); + ephemeralConversationId = null; headlessModAdapter.dispose(); telemetry.setSessionStatsGetter(undefined); } @@ -1920,7 +1910,7 @@ export async function handleHeadlessCommand( type: "system", subtype: "init", session_id: sessionId, - agent_id: agent.id, + agent_id: publicAgentId, conversation_id: conversationId, model: agent.llm_config?.model ?? "", tools: availableTools, @@ -1948,8 +1938,10 @@ export async function handleHeadlessCommand( ) => { const { getResumeDataFromBackend } = await import("@/agent/check-approval"); while (true) { - // Re-fetch agent to get latest in-context messages (source of truth for backend) - const freshAgent = await backend.retrieveAgent(agent.id); + // Detached conversations have no server-side agent to retrieve. + const freshAgent = ephemeralFlag + ? agent + : await backend.retrieveAgent(agent.id); let resume: Awaited>; try { @@ -2191,7 +2183,7 @@ ${SYSTEM_REMINDER_CLOSE} ), num_turns: 0, result: unsupportedReason, - agent_id: agent.id, + agent_id: publicAgentId, conversation_id: conversationId, environment: responseEnvironment, usage: null, @@ -2212,7 +2204,7 @@ ${SYSTEM_REMINDER_CLOSE} duration_api_ms: Math.round(sessionStats.getSnapshot().totalApiMs), num_turns: 0, result: unsupportedReason, - agent_id: agent.id, + agent_id: publicAgentId, conversation_id: conversationId, environment: responseEnvironment, run_ids: [], @@ -2267,7 +2259,7 @@ ${SYSTEM_REMINDER_CLOSE} duration_api_ms: Math.round(stats.totalApiMs), num_turns: 1, result: resultText, - agent_id: agent.id, + agent_id: publicAgentId, conversation_id: conversationId, environment: responseEnvironment, usage: null, @@ -2291,7 +2283,7 @@ ${SYSTEM_REMINDER_CLOSE} duration_api_ms: Math.round(stats.totalApiMs), num_turns: 1, result: resultText, - agent_id: agent.id, + agent_id: publicAgentId, conversation_id: conversationId, environment: responseEnvironment, run_ids: [], @@ -3439,7 +3431,7 @@ ${SYSTEM_REMINDER_CLOSE} duration_api_ms: Math.round(stats.totalApiMs), num_turns: stats.usage.stepCount, result: resultText, - agent_id: agent.id, + agent_id: publicAgentId, conversation_id: conversationId, ...(fromAgentId ? { environment: { source: "same-environment" as const } } @@ -3474,7 +3466,7 @@ ${SYSTEM_REMINDER_CLOSE} duration_api_ms: Math.round(stats.totalApiMs), num_turns: stats.usage.stepCount, result: resultText, - agent_id: agent.id, + agent_id: publicAgentId, conversation_id: conversationId, ...(fromAgentId ? { environment: { source: "same-environment" as const } } diff --git a/src/integration-tests/headless-stream-json-format.test.ts b/src/integration-tests/headless-stream-json-format.test.ts index e2c4b49326..245b0b0792 100644 --- a/src/integration-tests/headless-stream-json-format.test.ts +++ b/src/integration-tests/headless-stream-json-format.test.ts @@ -185,6 +185,7 @@ describe("stream-json format", () => { expect(init.type).toBe("system"); expect(init.subtype).toBe("init"); expect(init.agent_id).toBeDefined(); + if (!init.agent_id) throw new Error("init agent_id not found"); expect(init.session_id).toBe(init.agent_id); // session_id should equal agent_id expect(init.model).toBeDefined(); expect(init.tools).toBeInstanceOf(Array); @@ -241,6 +242,7 @@ describe("stream-json format", () => { expect(result.subtype).toBe("success"); expect(result.session_id).toBeDefined(); expect(result.agent_id).toBeDefined(); + if (!result.agent_id) throw new Error("result agent_id not found"); expect(result.session_id).toBe(result.agent_id); expect(result.duration_ms).toBeGreaterThan(0); expect(result.uuid).toContain("result-"); diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 182424f87c..5bed27eacc 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -84,7 +84,7 @@ export interface MessageEnvelope { /** Monotonic per-session event sequence. Optional for backward compatibility. */ event_seq?: number; /** Agent that triggered this event. Used with default conversation scoping. */ - agent_id?: string; + agent_id?: string | null; /** Conversation that triggered this event. Used for conversation-scoped filtering. */ conversation_id?: string; } @@ -96,7 +96,7 @@ export interface MessageEnvelope { export interface SystemInitMessage extends MessageEnvelope { type: "system"; subtype: "init"; - agent_id: string; + agent_id: string | null; conversation_id: string; model: string; tools: string[]; @@ -322,7 +322,7 @@ export type UsageStatistics = LettaStreamingResponse.LettaUsageStatistics; export interface ResultMessage extends MessageEnvelope { type: "result"; subtype: ResultSubtype; - agent_id: string; + agent_id: string | null; conversation_id: string; duration_ms: number; duration_api_ms: number; From 66912460dd757e59dee8d81e310dafd0ecc64ac4 Mon Sep 17 00:00:00 2001 From: Letta Integration <300689746+letta-integration[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:46:08 +0000 Subject: [PATCH 2/6] fix(headless): remove synthetic tag state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detached conversations have no agent metadata, including tags. See [trace](https://app.letta.com/chat/agent-57231da8-42f3-4523-b190-66c3eda21057?conversation=conv-d54c06b4-452d-4749-a088-2ab9094b362a). Resume conversation with `letta --conv conv-d54c06b4-452d-4749-a088-2ab9094b362a` 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Bob Co-Authored-By: Letta Code --- src/agent/ephemeral-conversation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/agent/ephemeral-conversation.ts b/src/agent/ephemeral-conversation.ts index 07ea2f44bd..755bb7b9e9 100644 --- a/src/agent/ephemeral-conversation.ts +++ b/src/agent/ephemeral-conversation.ts @@ -57,7 +57,6 @@ export async function createEphemeralConversation( name: "Ephemeral conversation", system: body.system, tools: [], - tags: [], memory: { blocks: [] }, llm_config: { handle: body.model, From 63b51f431f5e68398b2e810efd798cc867c5e78d Mon Sep 17 00:00:00 2001 From: Letta Integration <300689746+letta-integration[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:54:03 +0000 Subject: [PATCH 3/6] refactor(headless): retain ephemeral conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove client-side deletion so agent-free conversations remain persisted after one-shot runs. See [trace](https://app.letta.com/chat/agent-57231da8-42f3-4523-b190-66c3eda21057?conversation=conv-d54c06b4-452d-4749-a088-2ab9094b362a). Resume conversation with `letta --conv conv-d54c06b4-452d-4749-a088-2ab9094b362a` 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Bob Co-Authored-By: Letta Code --- scripts/source-file-size-baseline.json | 2 +- src/backend/api/ephemeral-conversations.ts | 9 --------- src/headless-ephemeral-startup.ts | 15 --------------- src/headless.ts | 3 --- 4 files changed, 1 insertion(+), 28 deletions(-) diff --git a/scripts/source-file-size-baseline.json b/scripts/source-file-size-baseline.json index 49180adf68..e3c6b1d7c9 100644 --- a/scripts/source-file-size-baseline.json +++ b/scripts/source-file-size-baseline.json @@ -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": 5097, + "src/headless.ts": 5094, "src/hooks/integration.test.ts": 1147, "src/index.ts": 2773, "src/mods/learning-harness.ts": 2434, diff --git a/src/backend/api/ephemeral-conversations.ts b/src/backend/api/ephemeral-conversations.ts index bb695fae6b..79fe381b80 100644 --- a/src/backend/api/ephemeral-conversations.ts +++ b/src/backend/api/ephemeral-conversations.ts @@ -15,15 +15,6 @@ export interface EphemeralConversation { context_window_limit: number | null; } -export async function deleteEphemeralConversation( - conversationId: string, -): Promise { - await apiRequest( - "DELETE", - `/v1/conversations/${encodeURIComponent(conversationId)}`, - ); -} - export async function createEphemeralConversation( body: EphemeralConversationCreateBody, ): Promise { diff --git a/src/headless-ephemeral-startup.ts b/src/headless-ephemeral-startup.ts index 0c68afeb2e..bb1124c12a 100644 --- a/src/headless-ephemeral-startup.ts +++ b/src/headless-ephemeral-startup.ts @@ -1,6 +1,5 @@ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; import { createEphemeralConversation } from "@/agent/ephemeral-conversation"; -import { deleteEphemeralConversation } from "@/backend/api/ephemeral-conversations"; import { clearPersistedClientToolRules } from "@/tools/toolset"; import { debugLog, debugWarn } from "@/utils/debug"; @@ -27,20 +26,6 @@ export async function createHeadlessEphemeralConversation(params: { }); } -export async function cleanupHeadlessEphemeralConversation( - conversationId: string | null, -): Promise { - if (!conversationId) return; - try { - await deleteEphemeralConversation(conversationId); - } catch (error) { - debugWarn( - "headless cleanup", - `Failed to delete ephemeral conversation ${conversationId}: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - export function clearHeadlessClientToolRules(agent: AgentState): void { void clearPersistedClientToolRules(agent.id, agent) .then((cleanup) => { diff --git a/src/headless.ts b/src/headless.ts index 34eda9365c..05cde354d5 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -114,7 +114,6 @@ import { import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "./constants"; import { waitForEnvironmentAssistantMessage } from "./headless-environment-response"; import { - cleanupHeadlessEphemeralConversation, clearHeadlessClientToolRules, createHeadlessEphemeralConversation, } from "./headless-ephemeral-startup"; @@ -1896,8 +1895,6 @@ export async function handleHeadlessCommand( telemetry.trackSessionEnd(sessionStats.getSnapshot(), exitReason); await telemetry.flush(); } finally { - await cleanupHeadlessEphemeralConversation(ephemeralConversationId); - ephemeralConversationId = null; headlessModAdapter.dispose(); telemetry.setSessionStatsGetter(undefined); } From 8a457794d04b8afe5e644a77b79e9cd992b32016 Mon Sep 17 00:00:00 2001 From: Letta Integration <300689746+letta-integration[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:36:46 +0000 Subject: [PATCH 4/6] fix(headless): support authenticated ephemeral runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse saved Cloud authentication for ephemeral headless sessions and exercise both Cloud and isolated local execution through the real CLI. See [trace](https://app.letta.com/chat/agent-57231da8-42f3-4523-b190-66c3eda21057?conversation=conv-0b449d7b-185b-4e55-a1b0-632b09398c86). Resume conversation with `letta --conv conv-0b449d7b-185b-4e55-a1b0-632b09398c86` 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Bob --- src/headless.ts | 4 +- src/index.ts | 4 +- .../startup-flow.integration.test.ts | 137 +++++++++++++++++- 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/headless.ts b/src/headless.ts index 04eec82337..d7ff114d76 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1818,8 +1818,7 @@ export async function handleHeadlessCommand( let availableTools = agent.tools?.map((t) => t.name).filter((n): n is string => !!n) || []; - // Cache the agent from the initial fetch to avoid redundant agents.retrieve - // calls on every while-loop iteration. + // Cache the initial agent to avoid repeated retrievals in the turn loop. let cachedAgent: AgentState | null = null; // Capture the resolved model (conversation override → agent fallback) so // subsequent while-loop iterations can prepare the correct toolset without @@ -1830,6 +1829,7 @@ export async function handleHeadlessCommand( const initialToolContext = await prepareHeadlessToolExecutionContext({ agentId: agent.id, conversationId, + overrideModel: ephemeralFlag ? agent.llm_config?.model : undefined, cachedAgent: agent as AgentState, modContext: initialHeadlessModContext, modEvents: headlessModAdapter.events, diff --git a/src/index.ts b/src/index.ts index 1f1e32f0b4..43b29bba5e 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1122,10 +1122,10 @@ async function main(): Promise { const isUsingLocalBackend = isExperimentalLocalBackendEnabled(); if (!isUsingDevBackend && !isUsingLocalBackend) { - // Headless mode against Letta API requires an explicit LETTA_API_KEY env var. - // Stored interactive OAuth tokens are not accepted for automated/headless use. + // Ephemeral runs may reuse saved OAuth; other headless automation requires an env key. if ( isHeadless && + !values.ephemeral && baseURL === LETTA_CLOUD_API_URL && !process.env.LETTA_API_KEY ) { diff --git a/src/integration-tests/startup-flow.integration.test.ts b/src/integration-tests/startup-flow.integration.test.ts index 1c01fb4461..b31af8dfaa 100644 --- a/src/integration-tests/startup-flow.integration.test.ts +++ b/src/integration-tests/startup-flow.integration.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { spawn } from "node:child_process"; -import { createAuthenticatedCliTestEnv } from "@/test-utils/test-process-env"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createAuthenticatedCliTestEnv, + createIsolatedCliTestEnv, +} from "@/test-utils/test-process-env"; import { formatAttemptDiagnostics, formatCapturedOutput, @@ -21,9 +28,17 @@ async function runCli( timeoutMs?: number; expectExit?: number; retryOnTimeouts?: number; + env?: NodeJS.ProcessEnv; + includeMemfsStartup?: boolean; } = {}, ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { - const { timeoutMs = 30000, expectExit, retryOnTimeouts = 1 } = options; + const { + timeoutMs = 30000, + expectExit, + retryOnTimeouts = 1, + env = createAuthenticatedCliTestEnv(), + includeMemfsStartup = true, + } = options; const failedAttempts: Array<{ attempt: number; message: string }> = []; const runOnce = () => @@ -31,10 +46,15 @@ async function runCli( (resolve, reject) => { const proc = spawn( "bun", - ["run", "dev", "--memfs-startup", "skip", ...args], + [ + "run", + "dev", + ...(includeMemfsStartup ? ["--memfs-startup", "skip"] : []), + ...args, + ], { cwd: projectRoot, - env: createAuthenticatedCliTestEnv(), + env, }, ); @@ -142,6 +162,8 @@ async function runCliJson( timeoutMs?: number; retryOnTimeouts?: number; retryOnParseErrors?: number; + env?: NodeJS.ProcessEnv; + includeMemfsStartup?: boolean; } = {}, ): Promise<{ stdout: string; @@ -235,6 +257,113 @@ describe("Startup Flow - Invalid Inputs", () => { describe("Startup Flow - Integration", () => { let testAgentId: string | null = null; + test( + "--ephemeral uses saved Cloud authentication without creating an agent", + async () => { + const apiKey = process.env.LETTA_API_KEY; + if (!apiKey) { + throw new Error("LETTA_API_KEY is required for this integration test"); + } + + const homeDir = await mkdtemp( + join(tmpdir(), "letta-ephemeral-cloud-home-"), + ); + const settingsDir = join(homeDir, ".letta"); + await mkdir(settingsDir, { recursive: true }); + await writeFile( + join(settingsDir, "settings.json"), + JSON.stringify({ + env: { LETTA_API_KEY: apiKey }, + preferredBackendMode: "api", + }), + ); + + try { + const result = await runCliJson( + [ + "--ephemeral", + "-m", + "openai/gpt-5.6-luna", + "-p", + "Reply with EPHEMERAL_CLOUD_OK and nothing else", + "--tools=", + "--output-format", + "json", + ], + { + timeoutMs: 180000, + includeMemfsStartup: false, + env: createIsolatedCliTestEnv({ + HOME: homeDir, + LETTA_SKIP_KEYCHAIN_CHECK: "1", + }), + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.output.agent_id).toBeNull(); + expect(result.output.conversation_id).toStartWith("conv-"); + expect(result.output.result).toBeDefined(); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }, + { timeout: 190000 }, + ); + + test( + "--ephemeral runs fully locally without Cloud authentication or persistent state", + async () => { + const homeDir = await mkdtemp( + join(tmpdir(), "letta-ephemeral-local-home-"), + ); + const storageDir = await mkdtemp( + join(tmpdir(), "letta-ephemeral-local-store-"), + ); + + try { + const result = await runCliJson( + [ + "--backend", + "local", + "--ephemeral", + "-m", + "openai/gpt-5.6-luna", + "-p", + "Reply with EPHEMERAL_LOCAL_OK and nothing else", + "--tools=", + "--output-format", + "json", + ], + { + timeoutMs: 60000, + includeMemfsStartup: false, + env: createIsolatedCliTestEnv({ + HOME: homeDir, + LETTA_LOCAL_BACKEND_DIR: storageDir, + LETTA_LOCAL_BACKEND_EXECUTOR: "deterministic", + LETTA_SKIP_KEYCHAIN_CHECK: "1", + }), + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.output.agent_id).toBeNull(); + expect(result.output.conversation_id).toStartWith("local-conv-"); + expect(result.output.result).toBeDefined(); + expect(existsSync(join(storageDir, "agents"))).toBe(false); + expect(existsSync(join(storageDir, "conversations"))).toBe(false); + expect(existsSync(join(storageDir, "memfs"))).toBe(false); + } finally { + await Promise.all([ + rm(homeDir, { recursive: true, force: true }), + rm(storageDir, { recursive: true, force: true }), + ]); + } + }, + { timeout: 70000 }, + ); + test( "--new-agent creates agent and responds", async () => { From b6f169e89a157dd4652cc03bc400a66acc42a793 Mon Sep 17 00:00:00 2001 From: Letta Integration <300689746+letta-integration[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:56:01 +0000 Subject: [PATCH 5/6] fix(headless): disable tools in ephemeral runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep agent-free one-shot conversations isolated from client tools and skills so default-model runs cannot enter approval continuation loops. See [trace](https://app.letta.com/chat/agent-57231da8-42f3-4523-b190-66c3eda21057?conversation=conv-0b449d7b-185b-4e55-a1b0-632b09398c86). Resume conversation with `letta --conv conv-0b449d7b-185b-4e55-a1b0-632b09398c86` 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Bob --- src/headless.ts | 8 ++++---- src/integration-tests/startup-flow.integration.test.ts | 5 +---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/headless.ts b/src/headless.ts index 3ea6dbf3ad..7dd797f3dd 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -748,10 +748,10 @@ export async function handleHeadlessCommand( disableModsForProcess(); } - // Set tool filter if provided (controls which tools are loaded) - if (values.tools !== undefined) { + const enabledTools = values.ephemeral ? "" : values.tools; + if (enabledTools !== undefined) { const { toolFilter } = await import("@/tools/filter"); - toolFilter.setEnabledTools(values.tools); + toolFilter.setEnabledTools(enabledTools); } const { cliPermissions } = await import( @@ -1750,7 +1750,7 @@ export async function handleHeadlessCommand( setAgentContext( agent.id, skillsDirectory, - resolvedSkillSources, + ephemeralFlag ? [] : resolvedSkillSources, agent.name ?? null, ); diff --git a/src/integration-tests/startup-flow.integration.test.ts b/src/integration-tests/startup-flow.integration.test.ts index b31af8dfaa..40e82a26eb 100644 --- a/src/integration-tests/startup-flow.integration.test.ts +++ b/src/integration-tests/startup-flow.integration.test.ts @@ -258,7 +258,7 @@ describe("Startup Flow - Integration", () => { let testAgentId: string | null = null; test( - "--ephemeral uses saved Cloud authentication without creating an agent", + "--ephemeral uses saved Cloud authentication and the default model without tools", async () => { const apiKey = process.env.LETTA_API_KEY; if (!apiKey) { @@ -282,11 +282,8 @@ describe("Startup Flow - Integration", () => { const result = await runCliJson( [ "--ephemeral", - "-m", - "openai/gpt-5.6-luna", "-p", "Reply with EPHEMERAL_CLOUD_OK and nothing else", - "--tools=", "--output-format", "json", ], From 4e6804ec670d6a2bce57f814f73491e60443cfc0 Mon Sep 17 00:00:00 2001 From: Letta Integration <300689746+letta-integration[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:06:02 +0000 Subject: [PATCH 6/6] fix(headless): preserve ephemeral client capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ephemeral conversations should omit persistent agent state, not the normal Letta Code client tools and skills. See [trace](https://app.letta.com/chat/agent-57231da8-42f3-4523-b190-66c3eda21057?conversation=conv-0b449d7b-185b-4e55-a1b0-632b09398c86). Resume conversation with `letta --conv conv-0b449d7b-185b-4e55-a1b0-632b09398c86` 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Bob --- src/headless.ts | 8 ++++---- src/integration-tests/startup-flow.integration.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/headless.ts b/src/headless.ts index 7dd797f3dd..3ea6dbf3ad 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -748,10 +748,10 @@ export async function handleHeadlessCommand( disableModsForProcess(); } - const enabledTools = values.ephemeral ? "" : values.tools; - if (enabledTools !== undefined) { + // Set tool filter if provided (controls which tools are loaded) + if (values.tools !== undefined) { const { toolFilter } = await import("@/tools/filter"); - toolFilter.setEnabledTools(enabledTools); + toolFilter.setEnabledTools(values.tools); } const { cliPermissions } = await import( @@ -1750,7 +1750,7 @@ export async function handleHeadlessCommand( setAgentContext( agent.id, skillsDirectory, - ephemeralFlag ? [] : resolvedSkillSources, + resolvedSkillSources, agent.name ?? null, ); diff --git a/src/integration-tests/startup-flow.integration.test.ts b/src/integration-tests/startup-flow.integration.test.ts index 40e82a26eb..b31af8dfaa 100644 --- a/src/integration-tests/startup-flow.integration.test.ts +++ b/src/integration-tests/startup-flow.integration.test.ts @@ -258,7 +258,7 @@ describe("Startup Flow - Integration", () => { let testAgentId: string | null = null; test( - "--ephemeral uses saved Cloud authentication and the default model without tools", + "--ephemeral uses saved Cloud authentication without creating an agent", async () => { const apiKey = process.env.LETTA_API_KEY; if (!apiKey) { @@ -282,8 +282,11 @@ describe("Startup Flow - Integration", () => { const result = await runCliJson( [ "--ephemeral", + "-m", + "openai/gpt-5.6-luna", "-p", "Reply with EPHEMERAL_CLOUD_OK and nothing else", + "--tools=", "--output-format", "json", ],