diff --git a/changelog.d/letta-model-pill.md b/changelog.d/letta-model-pill.md new file mode 100644 index 0000000000..1816074f2c --- /dev/null +++ b/changelog.d/letta-model-pill.md @@ -0,0 +1,3 @@ +### Added +- A Letta Code pane on Letta's local backend shows its model and reasoning effort in the composer, + as Letta runs the turn: the conversation's own `/model` choice, else its agent's. diff --git a/docs/guide.md b/docs/guide.md index 45a9c81128..0b0c192afd 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -283,6 +283,11 @@ until `letta` starts again on that conversation. In the home folder it always do while another pane runs `letta` in the same folder or reports the same conversation, and until the conversation's first turn on this PC has ended; Chat comes back by itself once that is no longer so. +With Letta's local backend, the composer shows the model and reasoning effort the conversation +runs with, read from Letta's local store (`lc-local-backend`): the conversation's own `/model` +choice, else its agent's. A cloud conversation keeps those on Letta's server, so its composer shows +none. + Letta writes a turn only when it ends, so a turn appears once it is done, and a cancelled one never does. While it runs, Chat shows the turns written so far and **Working…**, and a message you sent stays a faded bubble for a minute at most; the Terminal shows the turn as it happens. Only diff --git a/server/conversation.ts b/server/conversation.ts index beb5f6a7ce..06c6c554e6 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -63,7 +63,7 @@ import { resolveAgyStore, sameAgyStore, type AgyStore } from "./agy-store.ts"; import { museSession, museToolOutput, MuseUnavailable, parseMuseTranscript } from "./muse.ts"; import { resolveMuseSession } from "./muse-store.ts"; import { LettaUnavailable, lettaToolOutput, parseLettaTranscript } from "./letta.ts"; -import { resolveLettaTranscript } from "./letta-store.ts"; +import { lettaLocalSettings, resolveLettaTranscript } from "./letta-store.ts"; import { withinRetentionBudget } from "./retention-budget.ts"; import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts"; import { COPILOT_IMAGE_REF, copilotImage, copilotOffsetOutput, copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts"; @@ -1406,7 +1406,7 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ /** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */ type ResolvedTranscript = - | ({ path: string; codexHome?: string; agy?: AgyStore; piAgentDir?: string | null; ompBlobs?: string | null } & ( + | ({ path: string; codexHome?: string; agy?: AgyStore; piAgentDir?: string | null; ompBlobs?: string | null; letta?: { store: string; agent: string; conversation: string } } & ( | { source: Exclude } | { source: "copilot-transcript"; binding: string; peerClaims: ReadonlySet } )) @@ -1479,7 +1479,7 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin // no file yet is no proof the conversation has no turns (one begun elsewhere): Terminal stands in const found = await resolveLettaTranscript(pane, panes ?? [pane], known); if (found === null) throw new ConversationUnavailable("no_session_path"); - return { source: "letta-transcript", path: found.path }; + return { source: "letta-transcript", path: found.path, letta: { store: found.store, agent: found.agent, conversation: found.conversation } }; } if (agent === "agy") { const found = await resolveAgyStore(pane); @@ -1782,6 +1782,18 @@ export async function paneConversation(paneId: string, codexHome?: string, page: version: answerVersion(answer.version, model), }; } + if (resolved.source === "letta-transcript" && resolved.letta) { + // Letta's transcript names no model, and /model writes no turn: its local store holds the + // setting (lettaLocalSettings). Enrich a response, never the shared transcript page, and include + // the setting in its ETag. + const { store, agent, conversation } = resolved.letta; + const settings = lettaLocalSettings(store, agent, conversation); + if (settings !== null && (settings.model !== null || settings.reasoning_effort !== null)) return { + ...answer, + metadata: { ...answer.metadata, ...settings }, + version: answerVersion(answer.version, `${settings.model}\0${settings.reasoning_effort}`), + }; + } if (resolved.source === "grok-transcript") { try { return { ...answer, activity: grokProjection(resolved.path, statSync(resolved.path)).activity }; } catch { throw new ConversationUnavailable("transcript_missing"); } diff --git a/server/letta-store.ts b/server/letta-store.ts index e34382bc33..82572b0bdb 100644 --- a/server/letta-store.ts +++ b/server/letta-store.ts @@ -19,7 +19,7 @@ */ import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, realpathSync } from "node:fs"; import { basename, isAbsolute, join, normalize } from "node:path"; -import type { HerdrPane } from "../shared/protocol.ts"; +import type { ConversationMetadata, HerdrPane } from "../shared/protocol.ts"; import { darwinProcess } from "./claude-store.ts"; import { paneProcessInfo, type ProcessInfo } from "./herdr/client.ts"; import { processStartLowerBound } from "./process-start.ts"; @@ -78,12 +78,17 @@ export function parseEnviron(text: string): Env { return env; } +/** O_NONBLOCK, so a FIFO put where a record was is not waited on; O_NOFOLLOW, so a link there is refused. */ +const RECORD_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0); + +/** The text of a regular file of at most `bytes`, opened through no final link; null otherwise. */ function readBounded(path: string, bytes: number): string | null { let fd: number; - try { fd = openSync(path, "r"); } catch { return null; } + try { fd = openSync(path, RECORD_FLAGS); } catch { return null; } try { - const size = fstatSync(fd).size; - if (size > bytes) return null; + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > bytes) return null; + const size = stat.size; const buffer = Buffer.alloc(size); return buffer.subarray(0, readSync(fd, buffer, 0, size, 0)).toString("utf8"); } catch { return null; } finally { closeSync(fd); } @@ -282,13 +287,48 @@ export function lettaLocalOwner(store: string, agent: string, conversation: stri } catch { return false; } } -/** The transcript a Letta pane shows, written and held as lettaTranscriptHeld says, or null. */ -export async function resolveLettaTranscript(pane: HerdrPane, panes: readonly HerdrPane[], known?: ProcessInfo | null): Promise { +const recordAt = (path: string): Record | null => { + const text = readBounded(path, SETTINGS_BYTES); + if (text === null) return null; + try { + const parsed: unknown = JSON.parse(text); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : null; + } catch { return null; } +}; +const fields = (value: unknown): Record => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const word = (value: unknown): string | null => typeof value === "string" && value.trim() !== "" ? value.trim() : null; + +/** + * The model and reasoning effort Letta runs a local conversation with, as its local backend resolves + * them for a turn: the conversation's own `model` (what `/model` sets in it), else its agent's, and the + * conversation's `model_settings` over the agent's, whose effort Letta reads (reasoningForSettings) as + * `reasoning.reasoning_effort`, else `effort` (what it writes for Anthropic and Bedrock), else + * `reasoning_effort`. Letta's transcript names neither, and `/model` writes no turn. Null for a cloud + * conversation, whose settings live on the server, and for records that do not read as Letta writes + * them, name another agent, or (a local conversation's) another conversation. + */ +export function lettaLocalSettings(store: string, agent: string, conversation: string): ConversationMetadata | null { + const key = conversation === "default" ? `default:${agent}` : conversation.startsWith("local-conv-") ? `conversation:${conversation}` : null; + if (key === null) return null; + const own = recordAt(join(store, "conversations", Buffer.from(key).toString("base64url"), "conversation.json")); + const base = recordAt(join(store, "agents", `${Buffer.from(agent).toString("base64url")}.json`)); + if (own === null || base === null || own["agent_id"] !== agent || base["id"] !== agent) return null; + if (conversation !== "default" && own["id"] !== conversation) return null; + const settings = { ...fields(base["model_settings"]), ...fields(own["model_settings"]) }; + return { + model: word(own["model"]) ?? word(base["model"]), + reasoning_effort: word(fields(settings["reasoning"])["reasoning_effort"] ?? settings["effort"] ?? settings["reasoning_effort"]), + }; +} + +/** The transcript a Letta pane shows, written and held as lettaTranscriptHeld says, and its process's local store; or null. */ +export async function resolveLettaTranscript(pane: HerdrPane, panes: readonly HerdrPane[], known?: ProcessInfo | null): Promise<(LettaTranscript & { store: string }) | null> { const owner = await paneLettaProcess(pane.pane_id, known); if (owner === null || !lettaInStartFolder(owner) || !lettaRecordOwned(pane, panes, owner)) return null; const found = lettaTranscriptFor(pane, panes, owner); const store = lettaLocalStore(owner.env); const root = lettaTranscriptRoot(owner.env); if (found === null || store === null || root === null || !lettaLocalOwner(store, found.agent, found.conversation)) return null; - return lettaTranscriptHeld(found.path, root) ? found : null; + return lettaTranscriptHeld(found.path, root) ? { ...found, store } : null; } diff --git a/server/letta.test.ts b/server/letta.test.ts index c87faa6d5d..edcba71199 100644 --- a/server/letta.test.ts +++ b/server/letta.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { forgetTranscriptState, paneTranscript, transcriptPage, transcriptToolOutput } from "./conversation.ts"; +import { forgetTranscriptState, paneConversation, paneTranscript, transcriptPage, transcriptToolOutput } from "./conversation.ts"; import { ConversationUnavailable } from "./conversation-shared.ts"; import * as herdr from "./herdr/client.ts"; import { LettaUnavailable, lettaToolOutput, parseLettaTranscript } from "./letta.ts"; -import { isLettaProcess, type LettaProcess, lettaBinding, lettaInStartFolder, lettaLocalOwner, lettaRecordOwned, lettaOpenedSessions, lettaSessionOf, lettaTranscriptFor, lettaTranscriptHeld, lettaTranscriptPath, parseEnviron } from "./letta-store.ts"; +import * as lettaStore from "./letta-store.ts"; +import { isLettaProcess, type LettaProcess, lettaBinding, lettaInStartFolder, lettaLocalOwner, lettaLocalSettings, lettaRecordOwned, lettaOpenedSessions, lettaSessionOf, lettaTranscriptFor, lettaTranscriptHeld, lettaTranscriptPath, parseEnviron } from "./letta-store.ts"; import type { HerdrPane, SessionSnapshot } from "../shared/protocol.ts"; // Recorded from letta-code 0.34.27 in an isolated herdr session (local backend, herdr's Letta @@ -254,6 +256,151 @@ describe("Letta conversation binding", () => { expect(lettaLocalOwner(store, AGENT, "default")).toBe(true); }); + test("a local conversation's model and effort are its own, else its agent's, as Letta runs the turn", () => { + const store = join(root, "store"); + const record = (key: string, body: unknown): void => { + const folder = join(store, "conversations", Buffer.from(key).toString("base64url")); + mkdirSync(folder, { recursive: true }); + writeFileSync(join(folder, "conversation.json"), JSON.stringify(body)); + }; + // the shapes letta-code 0.34.31's local backend writes: an agent record, and a conversation + // whose `model` / `model_settings` are set only once `/model` changes them in that conversation + mkdirSync(join(store, "agents"), { recursive: true }); + writeFileSync(join(store, "agents", `${Buffer.from(AGENT).toString("base64url")}.json`), JSON.stringify({ + id: AGENT, name: "Protos", model: "claude-mimosa/claude-opus-5-5", + model_settings: { provider_type: "claude-mimosa", reasoning: { reasoning_effort: "high" }, context_window_limit: 1000000 }, + })); + expect(lettaLocalSettings(store, AGENT, "local-conv-1")).toBeNull(); + + // no override: the agent's model and effort + record("conversation:local-conv-1", JSON.parse(localConversation)); + expect(lettaLocalSettings(store, AGENT, "local-conv-1")).toEqual({ model: "claude-mimosa/claude-opus-5-5", reasoning_effort: "high" }); + // /model in this conversation: its own model and settings win + record("conversation:local-conv-1", { ...JSON.parse(localConversation), model: "claude-ing/claude-sonnet-5-5", + model_settings: { provider_type: "claude-ing", reasoning: { reasoning_effort: "medium" }, reasoning_effort: "medium" } }); + expect(lettaLocalSettings(store, AGENT, "local-conv-1")).toEqual({ model: "claude-ing/claude-sonnet-5-5", reasoning_effort: "medium" }); + // an effort set alone keeps the agent's model + record("conversation:local-conv-1", { ...JSON.parse(localConversation), model_settings: { reasoning: { reasoning_effort: "low" } } }); + expect(lettaLocalSettings(store, AGENT, "local-conv-1")).toEqual({ model: "claude-mimosa/claude-opus-5-5", reasoning_effort: "low" }); + // Letta reads the nested effort first (reasoningForSettings): a top-level one beside it changes nothing + record("conversation:local-conv-1", { ...JSON.parse(localConversation), model_settings: { reasoning_effort: "low" } }); + expect(lettaLocalSettings(store, AGENT, "local-conv-1")).toEqual({ model: "claude-mimosa/claude-opus-5-5", reasoning_effort: "high" }); + // the default conversation is keyed with its agent + record(`default:${AGENT}`, { id: "default", agent_id: AGENT }); + expect(lettaLocalSettings(store, AGENT, "default")).toEqual({ model: "claude-mimosa/claude-opus-5-5", reasoning_effort: "high" }); + + // a cloud conversation's settings are on the server; a record of another agent or a broken one names nothing + expect(lettaLocalSettings(store, AGENT, "conv-0d9d5c5e-3c3a-4a51-9d0c-1f5f2c1b7a11")).toBeNull(); + expect(lettaLocalSettings(store, "agent-local-b", "local-conv-1")).toBeNull(); + writeFileSync(join(store, "conversations", Buffer.from("conversation:local-conv-1").toString("base64url"), "conversation.json"), "{"); + expect(lettaLocalSettings(store, AGENT, "local-conv-1")).toBeNull(); + }); + + /** Letta's local store under `root`: an agent record and conversation records, by the keys Letta derives. */ + const store = () => join(root, "store"); + const agentRecord = (body: unknown): string => { + mkdirSync(join(store(), "agents"), { recursive: true }); + const path = join(store(), "agents", `${Buffer.from(AGENT).toString("base64url")}.json`); + if (body !== null) writeFileSync(path, JSON.stringify(body)); + return path; + }; + const conversationRecord = (key: string, body: unknown): string => { + const folder = join(store(), "conversations", Buffer.from(key).toString("base64url")); + mkdirSync(folder, { recursive: true }); + if (body !== null) writeFileSync(join(folder, "conversation.json"), JSON.stringify(body)); + return join(folder, "conversation.json"); + }; + + test("an Anthropic or Bedrock agent's effort is read from `effort`, where Letta writes it, before `reasoning_effort`", () => { + // buildModelSettings in letta-code 0.34.23 for an anthropic/ handle + agentRecord({ id: AGENT, model: "anthropic/claude-sonnet-4-5", model_settings: { provider_type: "anthropic", effort: "high" } }); + conversationRecord("conversation:local-conv-1", JSON.parse(localConversation)); + expect(lettaLocalSettings(store(), AGENT, "local-conv-1")).toEqual({ model: "anthropic/claude-sonnet-4-5", reasoning_effort: "high" }); + conversationRecord("conversation:local-conv-1", { ...JSON.parse(localConversation), model_settings: { reasoning_effort: "low" } }); + expect(lettaLocalSettings(store(), AGENT, "local-conv-1")).toEqual({ model: "anthropic/claude-sonnet-4-5", reasoning_effort: "high" }); + }); + + test("a local conversation's settings come only from the record whose own id it is", () => { + agentRecord({ id: AGENT, model: "base-model" }); + conversationRecord("conversation:local-conv-1", { ...JSON.parse(localConversation), id: "local-conv-9", model: "other" }); + expect(lettaLocalSettings(store(), AGENT, "local-conv-1")).toBeNull(); + conversationRecord("conversation:local-conv-1", { ...JSON.parse(localConversation), model: "own" }); + expect(lettaLocalSettings(store(), AGENT, "local-conv-1")).toEqual({ model: "own", reasoning_effort: null }); + }); + + test("a store record reached through a link is not read", () => { + writeFileSync(join(root, "outside.json"), JSON.stringify({ id: AGENT, model: "from-outside" })); + symlinkSync(join(root, "outside.json"), agentRecord(null)); + const own = conversationRecord("conversation:local-conv-1", JSON.parse(localConversation)); + expect(lettaLocalSettings(store(), AGENT, "local-conv-1")).toBeNull(); + rmSync(agentRecord(null)); + agentRecord({ id: AGENT, model: "base-model" }); + // the default conversation's record linked to another conversation's + symlinkSync(own, conversationRecord(`default:${AGENT}`, null)); + expect(lettaLocalSettings(store(), AGENT, "default")).toBeNull(); + writeFileSync(join(root, "conversation.json"), localConversation); + rmSync(own); + symlinkSync(join(root, "conversation.json"), own); + expect(lettaLocalOwner(store(), AGENT, "local-conv-1")).toBe(false); + expect(lettaLocalSettings(store(), AGENT, "local-conv-1")).toBeNull(); + }); + + test.skipIf(process.platform === "win32")("a FIFO where Letta keeps a record is not waited on", () => { + const own = conversationRecord("conversation:local-conv-1", JSON.parse(localConversation)); + const base = agentRecord(null); + // a FIFO with no writer blocks a plain open: the reads run in a child, so a hang fails this test, not the run + const probe = (): unknown => { + const module = JSON.stringify(join(import.meta.dir, "letta-store.ts")); + const child = spawnSync(process.execPath, ["-e", `import { lettaLocalOwner, lettaLocalSettings } from ${module}; + console.log(JSON.stringify([lettaLocalOwner(${JSON.stringify(store())}, ${JSON.stringify(AGENT)}, "local-conv-1"), lettaLocalSettings(${JSON.stringify(store())}, ${JSON.stringify(AGENT)}, "local-conv-1")]));`], + { encoding: "utf8", timeout: 5000 }); + expect(child.signal).toBeNull(); + return JSON.parse(child.stdout); + }; + expect(spawnSync("mkfifo", [base]).status).toBe(0); + expect(probe()).toEqual([true, null]); + rmSync(own); + expect(spawnSync("mkfifo", [own]).status).toBe(0); + expect(probe()).toEqual([false, null]); + }, 15_000); + + test("a Letta pane's chat shows the store's model and effort, and its ETag follows them with no append", async () => { + const path = join(root, "transcript.jsonl"); + writeFileSync(path, newLog); + agentRecord({ id: AGENT, model: "anthropic/claude-sonnet-4-5", model_settings: { provider_type: "anthropic", effort: "high" } }); + const own = conversationRecord("conversation:local-conv-1", JSON.parse(localConversation)); + const target = pane("w1:p1", "local-conv-1", root); + const resolution = spyOn(lettaStore, "resolveLettaTranscript").mockResolvedValue({ path, session: "local-conv-1", agent: AGENT, conversation: "local-conv-1", store: store() }); + const snapshot = spyOn(herdr, "sessionSnapshot").mockResolvedValue({ panes: [target], agents: [], workspaces: [], tabs: [], layouts: [], version: "fixture", protocol: 22 } as never); + const info = spyOn(herdr, "paneProcessInfo").mockResolvedValue({ pane_id: target.pane_id, foreground_processes: [] } as never); + const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async () => ({}) as never); + try { + const cached = transcriptPage("letta-transcript", path); + const first = await paneConversation(target.pane_id); + expect(first.metadata).toMatchObject({ model: "anthropic/claude-sonnet-4-5", reasoning_effort: "high" }); + expect((await paneConversation(target.pane_id)).version).toBe(first.version); + // /model in the conversation writes its record, not the transcript + conversationRecord("conversation:local-conv-1", { ...JSON.parse(localConversation), model: "anthropic/claude-opus-4-5", model_settings: { effort: "low" } }); + const second = await paneConversation(target.pane_id); + expect(second.metadata).toMatchObject({ model: "anthropic/claude-opus-4-5", reasoning_effort: "low" }); + expect(second.version).not.toBe(first.version); + expect(first.metadata).toMatchObject({ model: "anthropic/claude-sonnet-4-5", reasoning_effort: "high" }); + // the shared transcript page is never enriched + expect(cached.metadata).toMatchObject({ model: null, reasoning_effort: null }); + expect(transcriptPage("letta-transcript", path)).toEqual(cached); + // a record that does not read leaves the transcript's own answer + writeFileSync(own, "{"); + const third = await paneConversation(target.pane_id); + expect(third.metadata).toMatchObject({ model: null, reasoning_effort: null }); + expect(new Set([first.version, second.version, third.version]).size).toBe(3); + } finally { + rpc.mockRestore(); + info.mockRestore(); + snapshot.mockRestore(); + resolution.mockRestore(); + } + }); + test("only a regular file at the path Letta derives is read, never one reached through a link", () => { const transcripts = join(root, "transcripts"); const path = lettaTranscriptPath(transcripts, AGENT, "local-conv-1"); @@ -306,7 +453,8 @@ describe("Letta conversation binding", () => { const record = join(config, "lc-local-backend", "conversations", Buffer.from("conversation:local-conv-1").toString("base64url")); mkdirSync(record, { recursive: true }); writeFileSync(join(record, "conversation.json"), localConversation); - expect(await resolve(bound)).toEqual({ source: "letta-transcript", path }); + expect(await resolve(bound)).toEqual({ source: "letta-transcript", path, + letta: { store: join(config, "lc-local-backend"), agent: AGENT, conversation: "local-conv-1" } }); await expect(resolve(pane("w1:p1", `default:${AGENT}`, project))).rejects.toThrow("no_session_path"); await expect(resolve(pane("w1:p1", null, project))).rejects.toThrow("no_session_path"); // another pane running Letta in the folder writes the same record @@ -314,7 +462,8 @@ describe("Letta conversation binding", () => { foreground = movedIn.pid; await expect(resolve(bound)).rejects.toThrow("no_session_path"); foreground = child.pid; - expect(await resolve(bound)).toEqual({ source: "letta-transcript", path }); + expect(await resolve(bound)).toEqual({ source: "letta-transcript", path, + letta: { store: join(config, "lc-local-backend"), agent: AGENT, conversation: "local-conv-1" } }); const peer = pane("w2:p1", "default:agent-local-b", project); await expect(paneTranscript(bound.pane_id, undefined, { panes: [bound, peer] } as SessionSnapshot)).rejects.toThrow("no_session_path"); } finally {