diff --git a/README.md b/README.md index 3d917d3..30f5fa1 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,21 @@ An agent is the persistent entity with memory. A conversation is a thread on tha - `resumeSession(conversationId)` resumes a saved conversation. - `resumeSession(agentId)` resumes the agent's default conversation. +Pass `stateless: true` when a session should use an existing agent without +loading or changing its MemFS: + +```ts +await using session = client.createSession(agentId, { stateless: true }); +``` + +The agent and conversation still persist. Stateless sessions preserve the +agent's model, prompt, tools, tags, and sampling settings, but skip MemFS sync, +agent-scoped skills and mods, memory transcript writes, and reflection for that +session. Options that mutate persisted configuration (`model`, +`reasoningEffort`, `dreaming`, and `resources`) are rejected, as is +`session.updateModel()`. The option works with local, remote App Server, and +Cloud backends. + Portable sessions also expose the stateful controls needed by interactive clients: diff --git a/src/app-server-session.ts b/src/app-server-session.ts index 1175948..c5e661b 100644 --- a/src/app-server-session.ts +++ b/src/app-server-session.ts @@ -727,6 +727,12 @@ export class AppServerSession extends RemoteClientSessionCore { const mode = mapPermissionMode(options.permissionMode); if (mode) command.mode = mode; if (options.cwd !== undefined) command.cwd = options.cwd; + if ( + this.mode.kind === "session" && + this.mode.options.stateless === true + ) { + command.stateless = true; + } // Keep the distinction between omitted (use harness defaults) and [] // (disable bundled/global/agent/project skills). The app-server runtime is // session-scoped, so this must be sent on creation and every resume. diff --git a/src/cloud-session.ts b/src/cloud-session.ts index 3c75eb1..00d3675 100644 --- a/src/cloud-session.ts +++ b/src/cloud-session.ts @@ -473,6 +473,7 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore { const mode = mapPermissionMode(options.permissionMode); if (mode) command.mode = mode; if (options.cwd !== undefined) command.cwd = options.cwd; + if (this.cloudMode.options.stateless === true) command.stateless = true; if (options.skillSources !== undefined) { command.skill_sources = [...new Set(options.skillSources)]; } diff --git a/src/remote-client-session-core.ts b/src/remote-client-session-core.ts index 89a1f08..697677e 100644 --- a/src/remote-client-session-core.ts +++ b/src/remote-client-session-core.ts @@ -317,6 +317,11 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession { } async updateModel(update: string | UpdateModelOptions): Promise { + if (this.mode.kind === "session" && this.mode.options.stateless === true) { + throw new Error( + "updateModel() is unavailable in a stateless session because it changes persisted agent configuration.", + ); + } if (!this.initialized) { await this.initialize(); } @@ -779,7 +784,9 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession { } const dreamingSettings = resolveDreamingSettings(options.dreaming); - if (dreamingSettings) { + const isStatelessSession = + this.mode.kind === "session" && this.mode.options.stateless === true; + if (dreamingSettings && !isStatelessSession) { const response = await this.controller.request( "set_reflection_settings", { diff --git a/src/tests/client.test.ts b/src/tests/client.test.ts index a81cec6..1974b52 100644 --- a/src/tests/client.test.ts +++ b/src/tests/client.test.ts @@ -781,7 +781,10 @@ describe("LettaAgentClient", () => { appServer: { url: "ws://127.0.0.1:4500/ws", WebSocket: FakeAppServerSocket }, }); - const session = client.createSession("agent-123", { cwd: "/tmp/project" }); + const session = client.createSession("agent-123", { + cwd: "/tmp/project", + stateless: true, + }); try { const init = await asAdvanced(session).initialize(); expect(init.agentId).toBe("agent-123"); @@ -792,7 +795,11 @@ describe("LettaAgentClient", () => { agent_id: "agent-123", create_conversation: { body: {} }, cwd: "/tmp/project", + stateless: true, }); + await expect(session.updateModel("openai/gpt-5.2")).rejects.toThrow( + "unavailable in a stateless session", + ); } finally { session.close(); } diff --git a/src/tests/cloud-session.test.ts b/src/tests/cloud-session.test.ts index db88708..af42850 100644 --- a/src/tests/cloud-session.test.ts +++ b/src/tests/cloud-session.test.ts @@ -2123,6 +2123,7 @@ describe("CloudEnvironmentSession", () => { }); const session = client.resumeSession("agent-1", { + stateless: true, tools: [ { name: "lookup_ticket", @@ -2158,6 +2159,7 @@ describe("CloudEnvironmentSession", () => { const controlSocket = FakeCloudSocket.socket("control")!; const runtimeStart = controlSocket.sent.find((command) => command.type === "runtime_start")!; expect(runtimeStart).toMatchObject({ + stateless: true, external_tools: [ { tools: expect.arrayContaining([ diff --git a/src/tests/validation.test.ts b/src/tests/validation.test.ts index 88b663c..0de9834 100644 --- a/src/tests/validation.test.ts +++ b/src/tests/validation.test.ts @@ -50,6 +50,23 @@ describe("validation", () => { ).toThrow("Invalid toolset.include"); }); + test("validates stateless session options", () => { + expect(() => + validateCreateSessionOptions({ stateless: "yes" } as never), + ).toThrow("Invalid stateless"); + + for (const options of [ + { stateless: true, model: "openai/gpt-5.2" }, + { stateless: true, reasoningEffort: "high" }, + { stateless: true, dreaming: { trigger: "step-count" } }, + { stateless: true, resources: [] }, + ]) { + expect(() => validateCreateSessionOptions(options as never)).toThrow( + "changes persisted agent configuration", + ); + } + }); + test("rejects invalid session reasoning effort", () => { expect(() => validateCreateSessionOptions({ diff --git a/src/types.ts b/src/types.ts index b4b2974..3898c5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -663,6 +663,13 @@ export interface CreateSessionOptions { /** Working directory for the CLI process */ cwd?: string; + /** + * Run without loading or changing the agent's MemFS. The agent and + * conversation remain persistent; this only changes the session's local + * memory, agent-skill, agent-mod, transcript, and reflection behavior. + */ + stateless?: boolean; + /** * Restrict available skills by source. * Empty array disables all skills (`--no-skills`). diff --git a/src/validation.ts b/src/validation.ts index d09b992..337a862 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -223,6 +223,22 @@ function validateMcpServers(servers: McpServers | undefined): void { * Validate CreateSessionOptions (used by createSession and resumeSession). */ export function validateCreateSessionOptions(options: CreateSessionOptions): void { + if (options.stateless !== undefined && typeof options.stateless !== "boolean") { + throw new Error("Invalid stateless. Expected a boolean."); + } + if (options.stateless) { + const persistedOption = [ + ["model", options.model], + ["reasoningEffort", options.reasoningEffort], + ["dreaming", options.dreaming], + ["resources", options.resources], + ].find(([, value]) => value !== undefined); + if (persistedOption) { + throw new Error( + `stateless sessions cannot set ${persistedOption[0]} because it changes persisted agent configuration.`, + ); + } + } validateClientToolset(options.toolset); validateSkillSources(options.skillSources); validateMcpServers(options.mcpServers);