Skip to content

Commit 94340b9

Browse files
authored
fix(agent-init): move session startup context into the runtime (#65055)
* fix: preload startup memory for bare session resets * docs: align AGENTS template with startup context runtime * fix(agent-init): harden startup context prompt handling * fix(agent-init): tighten startup context parsing and limits * fix(agent-init): honor calendar-day startup memory windows * docs: clarify startup daily memory injection
1 parent 17553b4 commit 94340b9

18 files changed

Lines changed: 771 additions & 22 deletions

docs/concepts/system-prompt.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,12 @@ heartbeats are disabled for the default agent or
110110
files concise — especially `MEMORY.md`, which can grow over time and lead to
111111
unexpectedly high context usage and more frequent compaction.
112112

113-
> **Note:** `memory/*.md` daily files are **not** injected automatically. They
114-
> are accessed on demand via the `memory_search` and `memory_get` tools, so they
115-
> do not count against the context window unless the model explicitly reads them.
113+
> **Note:** `memory/*.md` daily files are **not** part of the normal bootstrap
114+
> Project Context. On ordinary turns they are accessed on demand via the
115+
> `memory_search` and `memory_get` tools, so they do not count against the
116+
> context window unless the model explicitly reads them. Bare `/new` and
117+
> `/reset` turns are the exception: the runtime can prepend recent daily memory
118+
> as a one-shot startup-context block for that first turn.
116119
117120
Large files are truncated with a marker. The max per-file size is controlled by
118121
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap

docs/reference/templates/AGENTS.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@ If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out w
1515

1616
## Session Startup
1717

18-
Before doing anything else:
18+
Use runtime-provided startup context first.
1919

20-
1. Read `SOUL.md` — this is who you are
21-
2. Read `USER.md` — this is who you're helping
22-
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
23-
4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
20+
That context may already include:
2421

25-
Don't ask permission. Just do it.
22+
- `AGENTS.md`, `SOUL.md`, and `USER.md`
23+
- recent daily memory such as `memory/YYYY-MM-DD.md`
24+
- `MEMORY.md` when this is the main session
25+
26+
Do not manually reread startup files unless:
27+
28+
1. The user explicitly asks
29+
2. The provided context is missing something you need
30+
3. You need a deeper follow-up read beyond the provided startup context
2631

2732
## Memory
2833

docs/reference/token-use.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
1818
- Tool list + short descriptions
1919
- Skills list (only metadata; instructions are loaded on demand with `read`)
2020
- Self-update instructions
21-
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
21+
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`.
2222
- Time (UTC + user timezone)
2323
- Reply tags + heartbeat behavior
2424
- Runtime metadata (host/OS/model/thinking)

src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ function maybeReplyText(reply: Awaited<ReturnType<GetReplyFromConfig>>) {
9999
return Array.isArray(reply) ? reply[0]?.text : reply?.text;
100100
}
101101

102+
function formatDateStampForZone(nowMs: number, timeZone: string): string {
103+
const parts = new Intl.DateTimeFormat("en-US", {
104+
timeZone,
105+
year: "numeric",
106+
month: "2-digit",
107+
day: "2-digit",
108+
}).formatToParts(new Date(nowMs));
109+
const year = parts.find((part) => part.type === "year")?.value;
110+
const month = parts.find((part) => part.type === "month")?.value;
111+
const day = parts.find((part) => part.type === "day")?.value;
112+
return `${year}-${month}-${day}`;
113+
}
114+
102115
function mockEmbeddedOkPayload() {
103116
return mockRunEmbeddedPiAgentOk("ok");
104117
}
@@ -251,6 +264,113 @@ describe("trigger handling", () => {
251264
});
252265
});
253266

267+
it("prepends runtime-loaded daily memory context on bare /new", async () => {
268+
await withTempHome(async (home) => {
269+
const workspaceDir = join(home, "openclaw");
270+
const timeZone = "America/Chicago";
271+
const nowMs = Date.now();
272+
const todayStamp = formatDateStampForZone(nowMs, timeZone);
273+
const yesterdayStamp = formatDateStampForZone(nowMs - 24 * 60 * 60 * 1000, timeZone);
274+
await fs.mkdir(join(workspaceDir, "memory"), { recursive: true });
275+
await fs.writeFile(
276+
join(workspaceDir, "memory", `${todayStamp}.md`),
277+
"today startup note",
278+
"utf-8",
279+
);
280+
await fs.writeFile(
281+
join(workspaceDir, "memory", `${yesterdayStamp}.md`),
282+
"yesterday startup note",
283+
"utf-8",
284+
);
285+
286+
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
287+
runEmbeddedPiAgentMock.mockReset();
288+
runEmbeddedPiAgentMock.mockResolvedValue({
289+
payloads: [{ text: "hello" }],
290+
meta: {
291+
durationMs: 1,
292+
agentMeta: { sessionId: "s", provider: "p", model: "m" },
293+
},
294+
});
295+
296+
const cfg = makeCfg(home);
297+
cfg.agents ??= {};
298+
cfg.agents.defaults ??= {};
299+
cfg.agents.defaults.userTimezone = timeZone;
300+
301+
const res = await getReplyFromConfig(
302+
{
303+
Body: "/new",
304+
From: "+1003",
305+
To: "+2000",
306+
CommandAuthorized: true,
307+
},
308+
{},
309+
cfg,
310+
);
311+
312+
const text = Array.isArray(res) ? res[0]?.text : res?.text;
313+
expect(text).toBe("hello");
314+
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
315+
expect(prompt).toContain("[Startup context loaded by runtime]");
316+
expect(prompt).toContain(`[Untrusted daily memory: memory/${todayStamp}.md]`);
317+
expect(prompt).toContain("BEGIN_QUOTED_NOTES");
318+
expect(prompt).toContain("today startup note");
319+
expect(prompt).toContain(`[Untrusted daily memory: memory/${yesterdayStamp}.md]`);
320+
expect(prompt).toContain("yesterday startup note");
321+
});
322+
});
323+
324+
it("treats normalized /RESET as reset for startupContext.applyOn", async () => {
325+
await withTempHome(async (home) => {
326+
const workspaceDir = join(home, "openclaw");
327+
const timeZone = "America/Chicago";
328+
const nowMs = Date.now();
329+
const todayStamp = formatDateStampForZone(nowMs, timeZone);
330+
await fs.mkdir(join(workspaceDir, "memory"), { recursive: true });
331+
await fs.writeFile(
332+
join(workspaceDir, "memory", `${todayStamp}.md`),
333+
"reset startup note",
334+
"utf-8",
335+
);
336+
337+
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
338+
runEmbeddedPiAgentMock.mockReset();
339+
runEmbeddedPiAgentMock.mockResolvedValue({
340+
payloads: [{ text: "hello" }],
341+
meta: {
342+
durationMs: 1,
343+
agentMeta: { sessionId: "s", provider: "p", model: "m" },
344+
},
345+
});
346+
347+
const cfg = makeCfg(home);
348+
cfg.agents ??= {};
349+
cfg.agents.defaults ??= {};
350+
cfg.agents.defaults.userTimezone = timeZone;
351+
cfg.agents.defaults.startupContext = {
352+
applyOn: ["reset"],
353+
};
354+
355+
const res = await getReplyFromConfig(
356+
{
357+
Body: "/RESET",
358+
From: "+1003",
359+
To: "+2000",
360+
CommandAuthorized: true,
361+
},
362+
{},
363+
cfg,
364+
);
365+
366+
const text = Array.isArray(res) ? res[0]?.text : res?.text;
367+
expect(text).toBe("hello");
368+
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
369+
expect(prompt).toContain(`[Untrusted daily memory: memory/${todayStamp}.md]`);
370+
expect(prompt).toContain("reset startup note");
371+
});
372+
});
373+
254374
it("sanitizes thinking directives before the agent run", async () => {
255375
await withTempHome(async (home) => {
256376
const thinkCases = [

src/auto-reply/reply.triggers.trigger-handling.test-harness.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ export async function runGreetingPromptForBareNewOrReset(params: {
442442
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
443443
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
444444
expect(prompt).toContain("A new session was started via /new or /reset");
445-
expect(prompt).toContain("Run your Session Startup sequence");
445+
expect(prompt).toContain("If runtime-provided startup context is included for this first turn");
446446
}
447447

448448
export function installTriggerHandlingE2eTestHooks() {

src/auto-reply/reply/get-reply-run.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { buildReplyPromptBodies } from "./prompt-prelude.js";
4444
import { resolveActiveRunQueueAction } from "./queue-policy.js";
4545
import { resolveQueueSettings } from "./queue/settings-runtime.js";
4646
import { buildBareSessionResetPrompt } from "./session-reset-prompt.js";
47+
import { buildSessionStartupContextPrelude, shouldApplyStartupContext } from "./startup-context.js";
4748
import { drainFormattedSystemEvents } from "./session-system-events.js";
4849
import { resolveTypingMode } from "./typing-mode.js";
4950
import { resolveRunTypingPolicy } from "./typing-policy.js";
@@ -287,8 +288,10 @@ export async function runPreparedReply(
287288
// Use CommandBody/RawBody for bare reset detection (clean message without structural context).
288289
const rawBodyTrimmed = (ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "").trim();
289290
const baseBodyTrimmedRaw = baseBody.trim();
290-
const isWholeMessageCommand = command.commandBodyNormalized.trim() === rawBodyTrimmed;
291-
const isResetOrNewCommand = /^\/(new|reset)(?:\s|$)/.test(rawBodyTrimmed);
291+
const normalizedCommandBody = command.commandBodyNormalized.trim();
292+
const isWholeMessageCommand =
293+
normalizedCommandBody === rawBodyTrimmed || normalizedCommandBody === rawBodyTrimmed.toLowerCase();
294+
const isResetOrNewCommand = /^\/(new|reset)(?:\s|$)/.test(normalizedCommandBody);
292295
if (
293296
allowTextCommands &&
294297
(!commandAuthorized || !command.isAuthorizedSender) &&
@@ -298,10 +301,18 @@ export async function runPreparedReply(
298301
typing.cleanup();
299302
return undefined;
300303
}
301-
const isBareNewOrReset = rawBodyTrimmed === "/new" || rawBodyTrimmed === "/reset";
304+
const isBareNewOrReset = /^\/(new|reset)$/.test(normalizedCommandBody);
302305
const isBareSessionReset =
303306
isNewSession &&
304307
((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset);
308+
const startupAction = /^\/reset(?:\s|$)/.test(normalizedCommandBody) ? "reset" : "new";
309+
const startupContextPrelude = isBareSessionReset &&
310+
shouldApplyStartupContext({ cfg, action: startupAction })
311+
? await buildSessionStartupContextPrelude({
312+
workspaceDir,
313+
cfg,
314+
})
315+
: null;
305316
const baseBodyFinal = isBareSessionReset ? buildBareSessionResetPrompt(cfg) : baseBody;
306317
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
307318
const inboundUserContext = buildInboundUserContextPrefix(
@@ -316,7 +327,7 @@ export async function runPreparedReply(
316327
envelopeOptions,
317328
);
318329
const baseBodyForPrompt = isBareSessionReset
319-
? baseBodyFinal
330+
? [startupContextPrelude, baseBodyFinal].filter(Boolean).join("\n\n")
320331
: [inboundUserContext, baseBodyFinal].filter(Boolean).join("\n\n");
321332
const baseBodyTrimmed = baseBodyForPrompt.trim();
322333
const hasMediaAttachment = Boolean(

src/auto-reply/reply/session-reset-prompt.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import type { OpenClawConfig } from "../../config/config.js";
33
import { buildBareSessionResetPrompt } from "./session-reset-prompt.js";
44

55
describe("buildBareSessionResetPrompt", () => {
6-
it("includes the core session startup instruction", () => {
6+
it("includes the runtime-owned startup instruction without falsely claiming context exists", () => {
77
const prompt = buildBareSessionResetPrompt();
8-
expect(prompt).toContain("Run your Session Startup sequence");
9-
expect(prompt).toContain("read the required files before responding to the user");
8+
expect(prompt).toContain("If runtime-provided startup context is included for this first turn");
9+
expect(prompt).not.toContain("read the required files before responding to the user");
10+
expect(prompt).not.toContain("Startup context has already been assembled by runtime");
1011
});
1112

1213
it("appends current time line so agents know the date", () => {

src/auto-reply/reply/session-reset-prompt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { appendCronStyleCurrentTimeLine } from "../../agents/current-time.js";
22
import type { OpenClawConfig } from "../../config/types.openclaw.js";
33

44
const BARE_SESSION_RESET_PROMPT_BASE =
5-
"A new session was started via /new or /reset. Run your Session Startup sequence - read the required files before responding to the user. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning.";
5+
"A new session was started via /new or /reset. If runtime-provided startup context is included for this first turn, use it before responding to the user. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning.";
66

77
/**
88
* Build the bare session reset prompt, appending the current date/time so agents
9-
* know which daily memory files to read during their Session Startup sequence.
9+
* know which daily memory files the runtime resolved for startup context.
1010
* Without this, agents on /new or /reset guess the date from their training cutoff.
1111
*/
1212
export function buildBareSessionResetPrompt(cfg?: OpenClawConfig, nowMs?: number): string {

0 commit comments

Comments
 (0)