Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/bundled/claude-code/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function systemPrompt(
team?: string;
/** The agent's `core` memory; `null` when it has none yet. */
memory?: string | null;
/** Its recent notes, as `recentNotesSection` gives them. */
notes?: string;
}>,
) {
const model = input.scope === "thread" ? threadModel : channelModel;
Expand All @@ -55,6 +57,7 @@ export function systemPrompt(
: "",
input.team?.trim() ?? "",
coreMemorySection(input.memory),
input.notes ?? "",
]
.filter(Boolean)
.join("\n\n");
Expand Down
96 changes: 86 additions & 10 deletions src/bundled/claude-code/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,12 @@ function setup(
};
const host = { spawn, request: vi.fn() } as unknown as Host;
const read = vi.fn(async (_filters: readonly ReadFilter[]) => thread);
const memoryRead = vi.fn(async () => ({
entries: [] as readonly MemoryEntry[],
partial: false,
}));
const memoryRead = vi.fn(
async (_pubkey: string, _signal: AbortSignal, _slug?: string) => ({
entries: [] as readonly MemoryEntry[],
partial: false,
}),
);
const session = {
workflowAuthority: options.workflowAuthority,
read,
Expand Down Expand Up @@ -839,7 +841,7 @@ it("puts the agent's teams in its system prompt, resuming idle sessions when the
runtime.dispose();
});

it("starts each session from the host's core memory, reading none from the relay", async () => {
it("starts each session from the host's core memory, reading only recent notes from the relay", async () => {
const { deliver, claudes, core, memoryRead } = setup();
const mention = (id: string) =>
deliver({
Expand All @@ -862,7 +864,13 @@ it("starts each session from the host's core memory, reading none from the relay
await flush(10);
expect(asked("1")).toContain("<core-memory>\nI am Claude.\n</core-memory>");
expect(core).toHaveBeenCalledWith(self);
expect(memoryRead).not.toHaveBeenCalled();
// Core memory comes from the host; the relay reads are yesterday's and
// today's notes, each by its slug.
expect(memoryRead).toHaveBeenCalledTimes(2);
for (const [pubkey, , slug] of memoryRead.mock.calls) {
expect(pubkey).toBe(self);
expect(slug).toMatch(/^mem\/notes\/\d{4}-\d{2}-\d{2}$/);
}
// The host could not read it: the session starts without it.
core.mockResolvedValueOnce(undefined);
await mention("2");
Expand All @@ -871,6 +879,63 @@ it("starts each session from the host's core memory, reading none from the relay
expect(asked("2")).not.toContain("<core-memory>");
});

it("starts a session without recent notes the relay is slow to give", async () => {
const { deliver, claudes, memoryRead } = setup();
memoryRead.mockImplementation(() => new Promise(() => {}));
await deliver({
type: "mention",
event: message("1", "@Claude 1", {
tags: [
["h", channel],
["p", self],
],
}),
});
await vi.waitFor(
() =>
expect(claudes().find((process) => process.prompts.length)).toBeDefined(),
{ timeout: 4_000 },
);
expect(
systemPromptOf(claudes().find((process) => process.prompts.length)),
).not.toContain("<recent-notes>");
});

it("starts each session with the agent's recent notes after its core memory", async () => {
const { deliver, claudes, core, memoryRead } = setup();
core.mockResolvedValueOnce("I am Claude.");
memoryRead.mockImplementation(async (_pubkey, _signal, slug) => ({
entries: slug?.startsWith("mem/notes/")
? [
{
slug,
body: `Noted in ${slug}.`,
eventId: "e".repeat(64),
createdAt: 1,
},
]
: [],
partial: false,
}));
await deliver({
type: "mention",
event: message("1", "@Claude 1", {
tags: [
["h", channel],
["p", self],
],
}),
});
await flush(10);
const prompt = systemPromptOf(
claudes().find((process) => process.prompts.length),
);
const [yesterday, today] = memoryRead.mock.calls.map(([, , slug]) => slug);
expect(prompt).toContain(
`</core-memory>\n\n<recent-notes>\n<note slug="${yesterday}">\nNoted in ${yesterday}.\n</note>\n<note slug="${today}">`,
);
});

it("names the agent in its system prompt, resuming idle sessions when it is renamed", async () => {
const root = message("2", "@Claude hi", {
tags: [
Expand Down Expand Up @@ -2241,10 +2306,16 @@ it("method calls: a session launch makes no config call on the relay session; on
await flush(10);
expect(f.claudes()).toHaveLength(1);
expect(systemPromptOf(f.claudes()[0])).toContain("I am Claude.");
// The start makes no config requests. The only call is the conversation's
// name lookup for the sender and mentioned agent, which requests only
// profiles missing from the cache (none when both are cached).
expect(made()).toEqual({
// The start makes no config requests. The only calls are the agent's
// recent notes, by slug, and the conversation's name lookup for the sender
// and mentioned agent, which requests only profiles missing from the cache
// (none when both are cached).
const { "agentMemories.read": notes, ...rest } = made();
expect(notes?.map(([pubkey, , slug]) => [pubkey, slug])).toEqual([
[self, expect.stringMatching(/^mem\/notes\/\d{4}-\d{2}-\d{2}$/)],
[self, expect.stringMatching(/^mem\/notes\/\d{4}-\d{2}-\d{2}$/)],
]);
expect(rest).toEqual({
"profiles.ensure": [[[alice, self], "foreground"]],
});
});
Expand Down Expand Up @@ -2390,6 +2461,8 @@ it("controls an idle Claude session without sending a message, and rejects reset
const agent = { ...f.agent, config: DEFAULT_CONFIG } as unknown as Agent;
try {
await f.deliver({ type: "mention", event: root });
// The start reads the agent's recent notes first.
await flush(10);
const process = f.claudes()[0];
if (!process) throw new Error("Expected Claude process");
await vi.waitFor(() => expect(process.prompts).toHaveLength(1));
Expand Down Expand Up @@ -2420,6 +2493,7 @@ it("controls an idle Claude session without sending a message, and rejects reset
],
}),
});
await flush(10);
expect(f.claudes()).toHaveLength(2);
expect(f.claudes()[1]?.options.args).not.toContain("--resume");
} finally {
Expand Down Expand Up @@ -2488,6 +2562,8 @@ it("keeps an accepted Claude handoff past its deadline while compaction runs", a
let finishCompaction: (() => void) | undefined;
try {
await f.deliver({ type: "mention", event: root });
// The start reads the agent's recent notes first.
await flush(10);
const process = f.claudes()[0];
if (!process) throw new Error("Expected Claude process");
await vi.waitFor(() => expect(process.prompts).toHaveLength(1));
Expand Down
18 changes: 17 additions & 1 deletion src/bundled/claude-code/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import { appClient, readFile } from "../../buzz-mcp/app-client";
import {
MemoryReads,
READ_TIMEOUT_MS,
abortable,
recentNotesSection,
} from "../../features/agent-harness/core-memory";
import { readProject } from "../../features/agent-harness/project";
import { SessionControls } from "../../features/agent-harness/session-control";
Expand Down Expand Up @@ -160,6 +162,9 @@ const listing = (added: readonly AddedTool[]): Listing => ({
: tool,
});

/** How long a session start waits for the agent's recent notes. */
const NOTES_WAIT_MS = 2_000;

export class ClaudeRuntime {
private readonly agents = new Map<AgentKey, Entry>();
private readonly memoryReads = new MemoryReads();
Expand Down Expand Up @@ -937,7 +942,17 @@ export class ClaudeRuntime {
]),
launch: async (key) => {
const { workspace, instructions, scope } = entry.config;
const memory = await this.core(agent);
// Read once per process: a new note doesn't restart a session, and a
// slow read starts it without them.
const [memory, notes] = await Promise.all([
this.core(agent),
recentNotesSection((slug) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Release startup note reads when their budget expires

The outer timeout stops startup waiting after two seconds, but this.memories() still permits the underlying read to run for ten seconds. Since owner-view memory reads share a serialized queue, a stalled startup note read can make another agent’s memory read time out after this session has already started. I reproduced a 2.5-second core read succeeding without these startup reads and timing out with them. Could the startup budget also cancel the underlying note work, without cancelling unrelated shared readers?

Posted by Codex (gpt-6.1-sol) on behalf of kylez

abortable(
this.memories(agent, slug),
AbortSignal.timeout(NOTES_WAIT_MS),
),
),
]);
const directories = key ? entry.directories.get(key) : undefined;
return {
...where(entry.config),
Expand All @@ -955,6 +970,7 @@ export class ClaudeRuntime {
instructions,
team: entry.team,
...(memory === undefined ? {} : { memory }),
notes,
}),
};
},
Expand Down
78 changes: 77 additions & 1 deletion src/features/agent-harness/core-memory.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { expect, it, vi } from "vitest";
import type { MemoryEntry, MemoryListing } from "../agents/memory";
import { MemoryReads, coreMemorySection, coreOf } from "./core-memory";
import {
MemoryReads,
NOTE_LIMIT,
coreMemorySection,
coreOf,
recentNotesSection,
} from "./core-memory";

const agent = "a".repeat(64);
const core: MemoryEntry = {
Expand Down Expand Up @@ -117,3 +123,73 @@ it("shows core memory, prompts for one when there is none, and omits unknown", (
expect(coreMemorySection(undefined)).toBe("");
expect(coreMemorySection(" ")).toBe("");
});

it("shows yesterday's and today's notes, oldest first, by this device's dates", async () => {
const note = (slug: string, body: string) => ({ ...core, slug, body });
const read = vi.fn(async (slug: string) =>
listing(
slug === "mem/notes/2026-02-28"
? [note(slug, "Ada likes tea.\n")]
: slug === "mem/notes/2026-03-01"
? [note(slug, "Ada moved to Leeds.")]
: [],
),
);
// Just after midnight on the 1st: yesterday is the last day of February.
const now = new Date(2026, 2, 1, 0, 5);
expect(await recentNotesSection(read, now)).toBe(
[
"<recent-notes>",
'<note slug="mem/notes/2026-02-28">',
"Ada likes tea.",
"</note>",
'<note slug="mem/notes/2026-03-01">',
"Ada moved to Leeds.",
"</note>",
"</recent-notes>",
].join("\n"),
);
expect(read.mock.calls.map(([slug]) => slug)).toEqual([
"mem/notes/2026-02-28",
"mem/notes/2026-03-01",
]);
});

it("adds nothing for a missing, blank or unread note", async () => {
const now = new Date(2026, 9, 30, 12);
expect(await recentNotesSection(async () => listing([]), now)).toBe("");
expect(await recentNotesSection(async () => listing([], true), now)).toBe("");
expect(
await recentNotesSection(
async (slug) => listing([{ ...core, slug, body: " \n" }]),
now,
),
).toBe("");
expect(
await recentNotesSection(async () => {
throw new Error("Buzz is not connected");
}, now),
).toBe("");
// One read failing still shows the other note.
expect(
await recentNotesSection(async (slug) => {
if (slug.endsWith("29")) throw new Error("timed out");
return listing([{ ...core, slug, body: "Today." }]);
}, now),
).toContain('<note slug="mem/notes/2026-10-30">\nToday.\n</note>');
});

it("keeps the newest part of a long note, cut where a character starts", async () => {
// "é" is two bytes, so the limit falls inside one.
const body = `${"old\n".repeat(10)}${"é".repeat(NOTE_LIMIT / 2)}x`;
const section = await recentNotesSection(
async (slug) =>
slug.endsWith("30") ? listing([{ ...core, slug, body }]) : listing([]),
new Date(2026, 9, 30, 12),
);
const kept = section.split("\n")[3] ?? "";
expect(section.split("\n")[2]).toBe("[older part of this note left out]");
expect(kept).toBe(`${"é".repeat(NOTE_LIMIT / 2 - 1)}x`);
expect(new TextEncoder().encode(kept).length).toBeLessThanOrEqual(NOTE_LIMIT);
expect(section).not.toContain("old\n");
});
39 changes: 39 additions & 0 deletions src/features/agent-harness/core-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,45 @@ export function coreMemorySection(memory: string | null | undefined) {
return body ? `<core-memory>\n${body}\n</core-memory>` : "";
}

/** The most of each note `recentNotesSection` shows, in UTF-8 bytes. */
export const NOTE_LIMIT = 8 * 1024;

/** Yesterday's and today's `mem/notes/<date>` notes, in a section of a prompt.
* Dates are this device's, which JS takes as UTC when it has no time zone.
* Notes are appended to, so a long one keeps its end. A note that is missing,
* blank or unread adds nothing. */
export async function recentNotesSection(
read: (slug: string) => Promise<MemoryListing>,
now = new Date(),
) {
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const notes = await Promise.all(
[yesterday, now].map(async (day) => {
const slug = `mem/notes/${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`;
const body = await read(slug).then(
(listing) =>
listing.entries.find((entry) => entry.slug === slug)?.body.trim(),
() => undefined,
);
return body ? `<note slug="${slug}">\n${tail(body)}\n</note>` : "";
}),
);
const body = notes.filter(Boolean).join("\n");
return body ? `<recent-notes>\n${body}\n</recent-notes>` : "";
}

const pad = (value: number) => String(value).padStart(2, "0");

/** The last `NOTE_LIMIT` bytes of `text`, cut where a character starts. */
function tail(text: string) {
const bytes = new TextEncoder().encode(text);
if (bytes.length <= NOTE_LIMIT) return text;
let start = bytes.length - NOTE_LIMIT;
while (((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1;
return `[older part of this note left out]\n${new TextDecoder().decode(bytes.subarray(start))}`;
}

/** Stops waiting when `signal` aborts, even if the read itself does not. */
export function abortable<T>(promise: Promise<T>, signal: AbortSignal) {
return new Promise<T>((resolve, reject) => {
Expand Down
Loading
Loading