Skip to content

Commit c9aadda

Browse files
authored
feat(harness): memfs-less worker agents and explicit per-session memory scopes (#3285)
1 parent db294bf commit c9aadda

7 files changed

Lines changed: 139 additions & 14 deletions

File tree

src/settings-manager.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,9 +1688,10 @@ class SettingsManager {
16881688
? (updates.systemPromptVersion ?? undefined)
16891689
: existing.systemPromptVersion,
16901690
};
1691-
// Clean up undefined/false values
1691+
// Clean up undefined/false values (explicit memfs:false is kept — it
1692+
// marks deliberately memfs-less worker agents; see isMemfsExplicitlyDisabled)
16921693
if (!updated.pinned) delete updated.pinned;
1693-
if (!updated.memfs) delete updated.memfs;
1694+
if (updated.memfs === undefined) delete updated.memfs;
16941695
if (!updated.toolset || updated.toolset === "auto")
16951696
delete updated.toolset;
16961697
if (!updated.systemPromptPreset) delete updated.systemPromptPreset;
@@ -1707,9 +1708,9 @@ class SettingsManager {
17071708
systemPromptHash: updates.systemPromptHash ?? undefined,
17081709
systemPromptVersion: updates.systemPromptVersion ?? undefined,
17091710
};
1710-
// Clean up undefined/false values
1711+
// Clean up undefined/false values (explicit memfs:false is kept)
17111712
if (!newAgent.pinned) delete newAgent.pinned;
1712-
if (!newAgent.memfs) delete newAgent.memfs;
1713+
if (newAgent.memfs === undefined) delete newAgent.memfs;
17131714
if (!newAgent.toolset || newAgent.toolset === "auto")
17141715
delete newAgent.toolset;
17151716
if (!newAgent.systemPromptPreset) delete newAgent.systemPromptPreset;
@@ -1731,6 +1732,17 @@ class SettingsManager {
17311732
return this.getAgentSettings(agentId, memfsServerKey)?.memfs === true;
17321733
}
17331734

1735+
/**
1736+
* Whether memfs was EXPLICITLY disabled for this agent (memfs: false in
1737+
* settings) — distinct from "never configured". Worker-style agents
1738+
* created memfs-less record this so lazy repair paths don't re-enable.
1739+
*/
1740+
isMemfsExplicitlyDisabled(agentId: string): boolean {
1741+
const settings = this.getSettings();
1742+
const memfsServerKey = getCurrentMemfsServerKey(settings);
1743+
return this.getAgentSettings(agentId, memfsServerKey)?.memfs === false;
1744+
}
1745+
17341746
/**
17351747
* Enable or disable memory filesystem for an agent on the current server.
17361748
*/

src/tools/impl/shell-env.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,31 @@ export function getShellEnv(): NodeJS.ProcessEnv {
398398
),
399399
)
400400
: false;
401-
402-
if (inheritedMemoryDir && inheritedMemoryIsParentScoped) {
401+
// An EXPLICIT memory scope (LETTA_MEMORY_DIR_EXPLICIT=1, set by a
402+
// launcher that deliberately points this session's memory at an
403+
// isolated copy — e.g. an SDK dream batch's memfs clone) is honored
404+
// when it lies outside the agents' memory store: such a path cannot
405+
// be another agent's memory, which is what this guard protects.
406+
// Without the marker, a non-parent-scoped inherited value is treated
407+
// as stale leakage and stripped, as before.
408+
const inheritedMemoryExplicit =
409+
process.env.LETTA_MEMORY_DIR_EXPLICIT === "1";
410+
const memoryStoreDir = path.dirname(
411+
path.dirname(getScopedMemoryFilesystemRoot(agentId)),
412+
);
413+
const inheritedMemoryOutsideStore = Boolean(
414+
inheritedMemoryPath &&
415+
!inheritedMemoryPath.startsWith(
416+
`${path.resolve(memoryStoreDir)}${path.sep}`,
417+
) &&
418+
inheritedMemoryPath !== path.resolve(memoryStoreDir),
419+
);
420+
421+
if (
422+
inheritedMemoryDir &&
423+
(inheritedMemoryIsParentScoped ||
424+
(inheritedMemoryExplicit && inheritedMemoryOutsideStore))
425+
) {
403426
env.MEMORY_DIR = inheritedMemoryDir;
404427
env.LETTA_MEMORY_DIR = inheritedLettaMemoryDir || inheritedMemoryDir;
405428
} else {

src/tools/shell-env.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,71 @@ test("getShellEnv does not inject MEMORY_DIR aliases when memfs is disabled", ()
384384
});
385385
});
386386

387+
test("getShellEnv honors an explicit MEMORY_DIR scope outside the memory store", () => {
388+
withTemporaryAgentEnv(`agent-test-${Date.now()}`, () => {
389+
withTemporaryEnv(
390+
{
391+
MEMORY_DIR: "/tmp/dream-batch-clone/output",
392+
LETTA_MEMORY_DIR: "/tmp/dream-batch-clone/output",
393+
LETTA_MEMORY_DIR_EXPLICIT: "1",
394+
},
395+
() => {
396+
const originalIsMemfsEnabled =
397+
settingsManager.isMemfsEnabled.bind(settingsManager);
398+
(
399+
settingsManager as unknown as {
400+
isMemfsEnabled: (id: string) => boolean;
401+
}
402+
).isMemfsEnabled = () => false;
403+
try {
404+
const env = getShellEnv();
405+
expect(env.MEMORY_DIR).toBe("/tmp/dream-batch-clone/output");
406+
expect(env.LETTA_MEMORY_DIR).toBe("/tmp/dream-batch-clone/output");
407+
} finally {
408+
(
409+
settingsManager as unknown as {
410+
isMemfsEnabled: (id: string) => boolean;
411+
}
412+
).isMemfsEnabled = originalIsMemfsEnabled;
413+
}
414+
},
415+
);
416+
});
417+
});
418+
419+
test("getShellEnv strips an explicit scope pointing into another agent's memory", () => {
420+
const otherAgentMemory = getMemoryFilesystemRoot(`agent-other-${Date.now()}`);
421+
withTemporaryAgentEnv(`agent-test-${Date.now()}`, () => {
422+
withTemporaryEnv(
423+
{
424+
MEMORY_DIR: otherAgentMemory,
425+
LETTA_MEMORY_DIR: otherAgentMemory,
426+
LETTA_MEMORY_DIR_EXPLICIT: "1",
427+
},
428+
() => {
429+
const originalIsMemfsEnabled =
430+
settingsManager.isMemfsEnabled.bind(settingsManager);
431+
(
432+
settingsManager as unknown as {
433+
isMemfsEnabled: (id: string) => boolean;
434+
}
435+
).isMemfsEnabled = () => false;
436+
try {
437+
const env = getShellEnv();
438+
expect(env.MEMORY_DIR).toBeUndefined();
439+
expect(env.LETTA_MEMORY_DIR).toBeUndefined();
440+
} finally {
441+
(
442+
settingsManager as unknown as {
443+
isMemfsEnabled: (id: string) => boolean;
444+
}
445+
).isMemfsEnabled = originalIsMemfsEnabled;
446+
}
447+
},
448+
);
449+
});
450+
});
451+
387452
test("getShellEnv preserves inherited parent MEMORY_DIR for subagents", () => {
388453
const parentAgentId = `agent-parent-${Date.now()}`;
389454
const childAgentId = `agent-child-${Date.now()}`;

src/types/protocol_v2.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,12 @@ export interface RuntimeStartCreateAgentOptions {
782782
body: AgentCreateParams;
783783
/** Whether to pin the created agent globally. Defaults to true. */
784784
pin_global?: boolean;
785+
/**
786+
* Whether to set up the memory filesystem for the created agent (tag
787+
* stamp + settings + repo clone). Defaults to true; false creates a
788+
* worker-style agent whose memory scope is provided per session.
789+
*/
790+
memfs?: boolean;
785791
}
786792

787793
export interface RuntimeStartCreateConversationOptions {

src/websocket/listener/commands/runtime-start.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,16 +134,23 @@ async function resolveRuntimeStartAgent(
134134
): Promise<AgentState> {
135135
const backend = getBackend();
136136
if (parsed.create_agent) {
137+
const withMemfs = parsed.create_agent.memfs !== false;
137138
const { prepareRawCreateAgentBodyForMemfs, enableMemfsIfCloud } =
138139
await import("@/agent/memory-filesystem");
139-
const body = await prepareRawCreateAgentBodyForMemfs(
140-
parsed.create_agent.body,
141-
);
140+
const body = withMemfs
141+
? await prepareRawCreateAgentBodyForMemfs(parsed.create_agent.body)
142+
: parsed.create_agent.body;
142143
const agent = await backend.createAgent(body);
143-
// Finish memfs setup (settings, repo clone, legacy tool detach) without
144-
// blocking runtime start. The tag is already stamped at creation, so
145-
// lazy sync paths can complete this even if the process dies here.
146-
void enableMemfsIfCloud(agent.id);
144+
if (withMemfs) {
145+
// Finish memfs setup (settings, repo clone, legacy tool detach) without
146+
// blocking runtime start. The tag is already stamped at creation, so
147+
// lazy sync paths can complete this even if the process dies here.
148+
void enableMemfsIfCloud(agent.id);
149+
} else {
150+
// Worker-style agent: no memfs of its own; a memory scope may be
151+
// provided per session (MEMORY_DIR + LETTA_MEMORY_DIR_EXPLICIT).
152+
settingsManager.setMemfsEnabled(agent.id, false);
153+
}
147154
created.agent = true;
148155
if (parsed.create_agent.pin_global !== false) {
149156
settingsManager.pinAgent(agent.id);

src/websocket/listener/memfs-sync.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ import type { ListenerRuntime } from "./types";
1414
* Core sync logic — fetches agent, checks tag, clones/pulls repo.
1515
*/
1616
async function syncMemfsForAgent(agentId: string): Promise<void> {
17+
const { settingsManager } = await import("@/settings-manager");
18+
if (settingsManager.isMemfsExplicitlyDisabled(agentId)) {
19+
// Worker-style agent deliberately created without a memfs (e.g. dream
20+
// reflectors) — its memory scope arrives per session via MEMORY_DIR.
21+
// Not a broken agent; do not "repair" it.
22+
debugLog(
23+
"memfs-sync",
24+
`Agent ${agentId} is explicitly memfs-disabled, skipping sync`,
25+
);
26+
return;
27+
}
1728
const { getBackend } = await import("@/backend");
1829
// `include: ["agent.tags"]` is required — without it the API can return
1930
// empty tags for a correctly tagged agent, which previously made the

src/websocket/listener/protocol-inbound.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,8 @@ function isRuntimeStartCreateAgentOptions(value: unknown): boolean {
410410
if (!isObjectRecord(value)) return false;
411411
return (
412412
isObjectRecord(value.body) &&
413-
(value.pin_global === undefined || typeof value.pin_global === "boolean")
413+
(value.pin_global === undefined || typeof value.pin_global === "boolean") &&
414+
(value.memfs === undefined || typeof value.memfs === "boolean")
414415
);
415416
}
416417

0 commit comments

Comments
 (0)