Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/source-file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@
"src/websocket/listener/file-commands.ts": 1053,
"src/websocket/listener/lifecycle.ts": 1051,
"src/websocket/listener/protocol-inbound.ts": 2264,
"src/websocket/listener/protocol-outbound.ts": 1085,
"src/websocket/listener/protocol-outbound.ts": 1083,
"src/websocket/listener/turn.ts": 1010
}
2 changes: 2 additions & 0 deletions src/runtime-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface RuntimeContextSnapshot {
conversationId?: string | null;
skillsDirectory?: string | null;
skillSources?: SkillSource[];
/** Runtime override for whether tools may project the agent's MemFS. */
memfsEnabled?: boolean;
workingDirectory?: string | null;
/**
* Set when the runtime-scoped working directory was found deleted and
Expand Down
9 changes: 5 additions & 4 deletions src/tools/impl/shell-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,11 @@ export function getShellEnv(): NodeJS.ProcessEnv {
const localBackendEnabled =
process.env.LETTA_LOCAL_BACKEND_EXPERIMENTAL === "1" ||
process.env.LETTA_LOCAL_BACKEND_EXPERIMENTAL?.toLowerCase() === "true";
if (
!localBackendNoMemfs &&
(settingsManager.isMemfsEnabled(agentId) || localBackendEnabled)
) {
const runtimeMemfsEnabled = getRuntimeContext()?.memfsEnabled;
const memfsEnabled =
runtimeMemfsEnabled ??
(settingsManager.isMemfsEnabled(agentId) || localBackendEnabled);
if (!localBackendNoMemfs && memfsEnabled) {
const memoryDir = resolveScopedMemoryDir({ agentId });
if (!memoryDir) {
throw new Error("Unable to resolve memory directory");
Expand Down
3 changes: 3 additions & 0 deletions src/tools/toolset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ export async function prepareToolExecutionContextForScope(params: {
modContext?: ModContext;
modEvents?: ModEvents;
modAdapters?: ModAdapter[];
runtimeContext?: Partial<RuntimeContextSnapshot>;
}): Promise<PreparedScopeToolContext> {
const {
connectionId,
Expand All @@ -396,6 +397,7 @@ export async function prepareToolExecutionContextForScope(params: {
modContext,
modEvents,
modAdapters,
runtimeContext,
} = params;

const backend = getBackend();
Expand Down Expand Up @@ -462,6 +464,7 @@ export async function prepareToolExecutionContextForScope(params: {
modAdapters,
agent: agent as AgentState,
runtimeContext: {
...runtimeContext,
connectionId,
environmentDeviceId,
agentId,
Expand Down
4 changes: 2 additions & 2 deletions src/types/protocol_v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,13 +783,11 @@ export interface RuntimeStartCreateConversationOptions {
/** Body forwarded to the Letta conversations create API. */
body?: Omit<ConversationCreateParams, "agent_id">;
}

export interface RuntimeStartClientInfo {
name: string;
title?: string;
version?: string;
}

export interface RuntimeStartCommand {
type: "runtime_start";
/** Echoed back in the response for request correlation. */
Expand All @@ -808,6 +806,8 @@ export interface RuntimeStartCommand {
cwd?: string | null;
/** Initial permission mode for this runtime scope. */
mode?: DevicePermissionMode;
/** Runtime-only policy that skips local MemFS reads and writes. */
stateless?: boolean;
skill_sources?: readonly ("bundled" | "global" | "agent" | "project")[];
/** Preserve the current override when skill_sources is omitted. */ preserve_skill_sources?: boolean;
/** Optional client metadata for diagnostics/future protocol negotiation. */
Expand Down
33 changes: 23 additions & 10 deletions src/websocket/listener/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
applySetMaxContext,
formatSetMaxContextResult,
} from "@/agent/max-context";
import { getScopedMemoryFilesystemRoot } from "@/agent/memory-filesystem";
import { REMEMBER_PROMPT } from "@/agent/prompt-assets";
import type { ConversationMessageCompactBody } from "@/backend";
import { getBackend } from "@/backend";
Expand Down Expand Up @@ -54,6 +53,10 @@ import {
} from "./protocol-outbound";
import { flushRemoteSettingsWrites } from "./remote-settings";
import { clearConversationRuntimeState, emitListenerStatus } from "./runtime";
import {
getConversationMemoryDirectory,
isConversationMemfsEnabled,
} from "./runtime-memory";
import {
ensureSecretsHydratedForAgent,
invalidateSecretsCacheForAgent,
Expand Down Expand Up @@ -111,6 +114,15 @@ export async function handleExecuteCommand(
try {
let output: string;

if (
conversationRuntime.stateless &&
["doctor", "init", "remember", "reflect"].includes(command.command_id)
) {
throw new Error(
`/${command.command_id} is unavailable in a stateless runtime because it writes agent memory.`,
);
}

switch (command.command_id) {
case "clear":
output = await handleClearCommand(socket, conversationRuntime, {
Expand Down Expand Up @@ -341,7 +353,10 @@ export async function handleReloadCommand(
);
}

await reloadListenerModAdapter(listener, conversationRuntime.agentId);
await reloadListenerModAdapter(
listener,
conversationRuntime.stateless ? undefined : conversationRuntime.agentId,
);

if (conversationRuntime.agentId) {
invalidateSecretsCacheForAgent(listener, conversationRuntime.agentId);
Expand Down Expand Up @@ -558,7 +573,7 @@ async function handleCompactCommand(
);
if (
reflectionSettings.trigger === "compaction-event" &&
settingsManager.isMemfsEnabled(agentId)
isConversationMemfsEnabled(conversationRuntime)
) {
void buildMaybeLaunchReflectionSubagent({
runtime: conversationRuntime,
Expand Down Expand Up @@ -687,9 +702,8 @@ async function handleDoctorCommand(
}

const { context: gitContext } = gatherInitGitContext();
const memoryDir = settingsManager.isMemfsEnabled(agentId)
? getScopedMemoryFilesystemRoot(agentId)
: undefined;
const memoryDir =
getConversationMemoryDirectory(conversationRuntime) ?? undefined;
const skillNameFrontmatterRepair =
await repairMissingSkillNameFrontmatter(memoryDir);
const skillNameFrontmatterRepairReport =
Expand Down Expand Up @@ -747,9 +761,8 @@ async function handleInitCommand(
}

const { context: gitContext } = gatherInitGitContext();
const memoryDir = settingsManager.isMemfsEnabled(agentId)
? getScopedMemoryFilesystemRoot(agentId)
: undefined;
const memoryDir =
getConversationMemoryDirectory(conversationRuntime) ?? undefined;

const initMessage = buildInitMessage({ gitContext, memoryDir });

Expand Down Expand Up @@ -900,7 +913,7 @@ async function handleReflectCommand(
const result = await launchReflectionSubagent({
agentId,
conversationId,
memfsEnabled: settingsManager.isMemfsEnabled(agentId),
memfsEnabled: isConversationMemfsEnabled(conversationRuntime),
triggerSource: "manual",
description: "Reflecting on conversation",
recompileByConversation: listener.systemPromptRecompileByConversation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,68 @@ describe("runtime_start skill sources", () => {
}
});

test("keeps stateless policy across idle runtime eviction until reset", async () => {
const storageDir = await mkdtemp(join(tmpdir(), "runtime-stateless-"));
try {
const backend = new LocalBackend({
storageDir,
executionMode: "deterministic",
});
__testSetBackend(backend);
const agent = await backend.createAgent({
name: "Stateless SDK worker",
model: "anthropic/claude-sonnet-4-6",
} as AgentCreateBody);
const listener = createRuntime();
const context = {
socket: {} as WebSocket,
connectionId: "test-connection",
runtime: listener,
safeSocketSend: () => true,
runDetachedListenerTask: () => {},
getOrCreateScopedRuntime,
replaySyncStateForRuntime: async () => {},
};

await handleRuntimeStartCommand(
{
type: "runtime_start",
request_id: "runtime-stateless",
agent_id: agent.id,
conversation_id: "default",
stateless: true,
recover_approvals: false,
},
context,
);

const scoped = getOrCreateScopedRuntime(listener, agent.id, "default");
expect(scoped.stateless).toBe(true);
expect(listener.statelessByConversation?.has(scoped.key)).toBe(true);
expect(evictConversationRuntimeIfIdle(scoped)).toBe(true);
expect(
getOrCreateScopedRuntime(listener, agent.id, "default").stateless,
).toBe(true);

await handleRuntimeStartCommand(
{
type: "runtime_start",
request_id: "runtime-stateful",
agent_id: agent.id,
conversation_id: "default",
recover_approvals: false,
},
context,
);
expect(
getOrCreateScopedRuntime(listener, agent.id, "default").stateless,
).toBe(false);
expect(listener.statelessByConversation?.has(scoped.key)).toBe(false);
} finally {
await rm(storageDir, { recursive: true, force: true });
}
});

test("preserves skill sources when a secondary controller updates runtime tools", async () => {
const storageDir = await mkdtemp(join(tmpdir(), "runtime-skills-"));
try {
Expand Down
3 changes: 3 additions & 0 deletions src/websocket/listener/commands/runtime-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
persistPermissionModeMapForRuntime,
} from "@/websocket/listener/permission-mode";
import { isRuntimeStartCommand } from "@/websocket/listener/protocol-inbound";
import { setConversationRuntimeStateless } from "@/websocket/listener/runtime-memory";
import type {
ConversationRuntime,
ListenerConnectionId,
Expand Down Expand Up @@ -305,6 +306,8 @@ async function applyRuntimeStartState(
);
}

setConversationRuntimeStateless(scopedRuntime, parsed.stateless === true);

if (parsed.mode) {
const mode = migratePermissionMode(parsed.mode);
if (!mode) {
Expand Down
4 changes: 2 additions & 2 deletions src/websocket/listener/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,6 @@ function stampInboundUserMessageOtids(
}

export function createRuntime(): ListenerRuntime {
const bootWorkingDirectory = getCurrentWorkingDirectory();
return {
socket: null,
transport: null,
Expand Down Expand Up @@ -312,11 +311,12 @@ export function createRuntime(): ListenerRuntime {
pendingQueueEmitScope: undefined,
onWsEvent: undefined,
reminderState: createSharedReminderState(),
bootWorkingDirectory,
bootWorkingDirectory: getCurrentWorkingDirectory(),
workingDirectoryByConversation: loadPersistedCwdMap(),
worktreeWatcherByConversation: new Map(),
permissionModeByConversation: loadPersistedPermissionModeMap(),
skillSourcesByConversation: new Map(),
statelessByConversation: new Set(),
reminderStateByConversation: new Map(),
contextTrackerByConversation: new Map(),
systemPromptRecompileByConversation: new Map(),
Expand Down
9 changes: 7 additions & 2 deletions src/websocket/listener/message-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
estimateSystemPromptTokensFromMemoryDir,
setSystemPromptDoctorState,
} from "@/cli/helpers/system-prompt-warning";
import { settingsManager } from "@/settings-manager";
import type {
AbortMessageCommand,
ApprovalResponseBody,
Expand Down Expand Up @@ -63,6 +62,7 @@ import {
} from "./queue";
import { emitLoopErrorNotice } from "./recoverable-notices";
import { getActiveRuntime, safeEmitWsEvent } from "./runtime";
import { isConversationMemfsEnabled } from "./runtime-memory";
import type { ListenerTransport } from "./transport";
import { handleIncomingMessage } from "./turn";
import type {
Expand Down Expand Up @@ -761,7 +761,12 @@ export function createListenerMessageHandler(
// Internal-only: refresh doctor state after recompile (no chat output)
if (parsed.command_id === "refresh_doctor_state") {
const agentId = parsed.runtime.agent_id;
if (agentId && settingsManager.isMemfsEnabled(agentId)) {
const doctorRuntime = getOrCreateScopedRuntime(
runtime,
agentId,
parsed.runtime.conversation_id,
);
if (agentId && isConversationMemfsEnabled(doctorRuntime)) {
try {
const { getScopedMemoryFilesystemRoot } = await import(
"@/agent/memory-filesystem"
Expand Down
20 changes: 17 additions & 3 deletions src/websocket/listener/mod-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function createListenerModContext(
reasoning_effort?: string | null;
} | null;
} | null;
memfsEnabled?: boolean;
modelIdentifier?: string | null;
permissionMode?: string | null;
toolset?: string | null;
Expand All @@ -107,12 +108,21 @@ export function createListenerModContext(
});
return {
...context,
memfs: resolveListenerAgentMemfsContext(context.agent.id),
memfs:
options.memfsEnabled === false
? { enabled: false, memoryDir: null }
: resolveListenerAgentMemfsContext(context.agent.id),
};
}

export function createListenerAgentModContext(agentId: string): ModContext {
return createListenerModContext({ agent: { id: agentId } });
export function createListenerAgentModContext(
agentId: string,
memfsEnabled?: boolean,
): ModContext {
return createListenerModContext({
agent: { id: agentId },
...(memfsEnabled !== undefined ? { memfsEnabled } : {}),
});
}

export function createListenerModAdapter(
Expand Down Expand Up @@ -235,8 +245,12 @@ export async function ensureListenerAgentModAdapter(
export async function ensureListenerModAdaptersForAgent(
runtime: ListenerRuntime,
agentId: string,
options: { includeAgent?: boolean } = {},
): Promise<ModAdapter[]> {
const globalAdapter = ensureListenerModAdapter(runtime);
if (options.includeAgent === false) {
return [globalAdapter];
}
const agentAdapter = await ensureListenerAgentModAdapter(runtime, agentId);
return agentAdapter ? [globalAdapter, agentAdapter] : [globalAdapter];
}
Expand Down
7 changes: 7 additions & 0 deletions src/websocket/listener/protocol-inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ describe("agent/conversation management protocol-inbound validators", () => {
conversation_source_tags: ["channel:slack"],
cwd: "/tmp/project",
mode: "acceptEdits",
stateless: true,
skill_sources: [],
preserve_skill_sources: true,
client_info: { name: "test", title: "Test", version: "1.0.0" },
Expand Down Expand Up @@ -239,6 +240,12 @@ describe("agent/conversation management protocol-inbound validators", () => {
agent_id: "agent-1",
mode: "bad",
},
{
type: "runtime_start",
request_id: "r0",
agent_id: "agent-1",
stateless: "yes",
},
{
type: "runtime_start",
request_id: "r0",
Expand Down
2 changes: 1 addition & 1 deletion src/websocket/listener/protocol-inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,6 @@ function isRuntimeStartClientInfo(value: unknown): boolean {
(value.version === undefined || typeof value.version === "string")
);
}

export function isRuntimeStartCommand(
value: unknown,
): value is RuntimeStartCommand {
Expand All @@ -542,6 +541,7 @@ export function isRuntimeStartCommand(
isStringArray(c.conversation_source_tags)) &&
(c.cwd === undefined || c.cwd === null || typeof c.cwd === "string") &&
(c.mode === undefined || isDevicePermissionMode(c.mode)) &&
(c.stateless === undefined || typeof c.stateless === "boolean") &&
(c.skill_sources === undefined || isSkillSourceArray(c.skill_sources)) &&
(c.preserve_skill_sources === undefined ||
typeof c.preserve_skill_sources === "boolean") &&
Expand Down
Loading
Loading