Skip to content

Commit 9ba97ce

Browse files
authored
perf(agents): add continuation-skip context injection (#61268)
* test(agents): cover continuation bootstrap reuse * perf(agents): add continuation-skip context injection * docs(changelog): note context injection reuse * perf(agents): bound continuation bootstrap scan * fix(agents): require full bootstrap proof for continuation skip * fix(agents): decide continuation skip under lock * fix(commands): re-export subagent chat message type * fix(agents): clean continuation rebase leftovers
1 parent 39099b8 commit 9ba97ce

14 files changed

Lines changed: 527 additions & 28 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
5050
- Memory/dreaming: drop generic date/day headings from daily-note chunk prefixes while keeping meaningful section labels, so staged snippets stay cleaner and more reusable. (#61597) Thanks @mbelinky.
5151
- Plugins/Lobster: run bundled Lobster workflows in process instead of spawning the external CLI, reducing transport overhead and unblocking native runtime integration. (#61523) Thanks @mbelinky.
5252
- Plugins/Lobster: harden managed resume validation so invalid TaskFlow resume calls fail earlier, and memoize embedded runtime loading per runner while keeping failed loads retryable. (#61566) Thanks @mbelinky.
53+
- Agents/bootstrap: add opt-in `agents.defaults.contextInjection: "continuation-skip"` so safe continuation turns can skip workspace bootstrap re-injection, while heartbeat runs and post-compaction retries still rebuild context when needed. Fixes #9157. Thanks @cgdusek.
5354

5455
### Fixes
5556

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
433dc1a6776b3c782524489d6bb22c770015d4915f6886da89bb3538698f0057 config-baseline.json
2-
71414a189b62e3a362443068cb911372b2fe326a0bf43237a36d475533508499 config-baseline.core.json
1+
1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json
2+
7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json
33
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
44
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json

src/agents/bootstrap-files.test.ts

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import {
77
type AgentBootstrapHookContext,
88
} from "../hooks/internal-hooks.js";
99
import { makeTempWorkspace } from "../test-helpers/workspace.js";
10-
import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js";
10+
import {
11+
FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
12+
hasCompletedBootstrapTurn,
13+
resolveBootstrapContextForRun,
14+
resolveBootstrapFilesForRun,
15+
resolveContextInjectionMode,
16+
} from "./bootstrap-files.js";
1117
import type { WorkspaceBootstrapFile } from "./workspace.js";
1218

1319
function registerExtraBootstrapFileHook() {
@@ -127,3 +133,181 @@ describe("resolveBootstrapContextForRun", () => {
127133
expect(files).toEqual([]);
128134
});
129135
});
136+
137+
describe("hasCompletedBootstrapTurn", () => {
138+
let tmpDir: string;
139+
140+
beforeEach(async () => {
141+
tmpDir = await fs.mkdtemp(path.join(await fs.realpath("/tmp"), "openclaw-bootstrap-turn-"));
142+
});
143+
144+
afterEach(async () => {
145+
await fs.rm(tmpDir, { recursive: true, force: true });
146+
});
147+
148+
it("returns false when session file does not exist", async () => {
149+
expect(await hasCompletedBootstrapTurn(path.join(tmpDir, "missing.jsonl"))).toBe(false);
150+
});
151+
152+
it("returns false for empty session files", async () => {
153+
const sessionFile = path.join(tmpDir, "empty.jsonl");
154+
await fs.writeFile(sessionFile, "", "utf8");
155+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
156+
});
157+
158+
it("returns false for header-only session files", async () => {
159+
const sessionFile = path.join(tmpDir, "header-only.jsonl");
160+
await fs.writeFile(sessionFile, `${JSON.stringify({ type: "session", id: "s1" })}\n`, "utf8");
161+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
162+
});
163+
164+
it("returns false when no assistant turn has been flushed yet", async () => {
165+
const sessionFile = path.join(tmpDir, "user-only.jsonl");
166+
await fs.writeFile(
167+
sessionFile,
168+
[
169+
JSON.stringify({ type: "session", id: "s1" }),
170+
JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }),
171+
].join("\n") + "\n",
172+
"utf8",
173+
);
174+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
175+
});
176+
177+
it("returns false for assistant turns without a recorded full bootstrap marker", async () => {
178+
const sessionFile = path.join(tmpDir, "assistant-no-marker.jsonl");
179+
await fs.writeFile(
180+
sessionFile,
181+
[
182+
JSON.stringify({ type: "session", id: "s1" }),
183+
JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }),
184+
JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }),
185+
].join("\n") + "\n",
186+
"utf8",
187+
);
188+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
189+
});
190+
191+
it("returns true when a full bootstrap completion marker exists", async () => {
192+
const sessionFile = path.join(tmpDir, "full-bootstrap.jsonl");
193+
await fs.writeFile(
194+
sessionFile,
195+
[
196+
JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }),
197+
JSON.stringify({
198+
type: "custom",
199+
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
200+
data: { timestamp: 1 },
201+
}),
202+
].join("\n") + "\n",
203+
"utf8",
204+
);
205+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
206+
});
207+
208+
it("returns false when compaction happened after the last assistant turn", async () => {
209+
const sessionFile = path.join(tmpDir, "post-compaction.jsonl");
210+
await fs.writeFile(
211+
sessionFile,
212+
[
213+
JSON.stringify({
214+
type: "custom",
215+
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
216+
data: { timestamp: 1 },
217+
}),
218+
JSON.stringify({ type: "compaction", summary: "trimmed" }),
219+
].join("\n") + "\n",
220+
"utf8",
221+
);
222+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
223+
});
224+
225+
it("returns true when a later full bootstrap marker happens after compaction", async () => {
226+
const sessionFile = path.join(tmpDir, "assistant-after-compaction.jsonl");
227+
await fs.writeFile(
228+
sessionFile,
229+
[
230+
JSON.stringify({
231+
type: "custom",
232+
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
233+
data: { timestamp: 1 },
234+
}),
235+
JSON.stringify({ type: "compaction", summary: "trimmed" }),
236+
JSON.stringify({ type: "message", message: { role: "user", content: "new ask" } }),
237+
JSON.stringify({ type: "message", message: { role: "assistant", content: "new reply" } }),
238+
JSON.stringify({
239+
type: "custom",
240+
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
241+
data: { timestamp: 2 },
242+
}),
243+
].join("\n") + "\n",
244+
"utf8",
245+
);
246+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
247+
});
248+
249+
it("ignores malformed JSON lines", async () => {
250+
const sessionFile = path.join(tmpDir, "malformed.jsonl");
251+
await fs.writeFile(
252+
sessionFile,
253+
[
254+
"{broken",
255+
JSON.stringify({
256+
type: "custom",
257+
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
258+
data: { timestamp: 1 },
259+
}),
260+
].join("\n") + "\n",
261+
"utf8",
262+
);
263+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
264+
});
265+
266+
it("finds a recent full bootstrap marker even when the scan starts mid-file", async () => {
267+
const sessionFile = path.join(tmpDir, "large-prefix.jsonl");
268+
const hugePrefix = "x".repeat(300 * 1024);
269+
await fs.writeFile(
270+
sessionFile,
271+
[
272+
JSON.stringify({ type: "message", message: { role: "user", content: hugePrefix } }),
273+
JSON.stringify({
274+
type: "custom",
275+
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
276+
data: { timestamp: 1 },
277+
}),
278+
].join("\n") + "\n",
279+
"utf8",
280+
);
281+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
282+
});
283+
284+
it("returns false for symbolic links", async () => {
285+
const realFile = path.join(tmpDir, "real.jsonl");
286+
const linkFile = path.join(tmpDir, "link.jsonl");
287+
await fs.writeFile(
288+
realFile,
289+
`${JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 } })}\n`,
290+
"utf8",
291+
);
292+
await fs.symlink(realFile, linkFile);
293+
expect(await hasCompletedBootstrapTurn(linkFile)).toBe(false);
294+
});
295+
});
296+
297+
describe("resolveContextInjectionMode", () => {
298+
it("defaults to always when config is missing", () => {
299+
expect(resolveContextInjectionMode(undefined)).toBe("always");
300+
});
301+
302+
it("defaults to always when the setting is omitted", () => {
303+
expect(resolveContextInjectionMode({ agents: { defaults: {} } } as never)).toBe("always");
304+
});
305+
306+
it("returns the configured continuation-skip mode", () => {
307+
expect(
308+
resolveContextInjectionMode({
309+
agents: { defaults: { contextInjection: "continuation-skip" } },
310+
} as never),
311+
).toBe("continuation-skip");
312+
});
313+
});

src/agents/bootstrap-files.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import fs from "node:fs/promises";
12
import type { OpenClawConfig } from "../config/config.js";
3+
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
24
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
35
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
46
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
@@ -16,6 +18,85 @@ import {
1618
export type BootstrapContextMode = "full" | "lightweight";
1719
export type BootstrapContextRunKind = "default" | "heartbeat" | "cron";
1820

21+
const CONTINUATION_SCAN_MAX_TAIL_BYTES = 256 * 1024;
22+
const CONTINUATION_SCAN_MAX_RECORDS = 500;
23+
export const FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE = "openclaw:bootstrap-context:full";
24+
25+
export function resolveContextInjectionMode(config?: OpenClawConfig): AgentContextInjection {
26+
return config?.agents?.defaults?.contextInjection ?? "always";
27+
}
28+
29+
export async function hasCompletedBootstrapTurn(sessionFile: string): Promise<boolean> {
30+
try {
31+
const stat = await fs.lstat(sessionFile);
32+
if (stat.isSymbolicLink()) {
33+
return false;
34+
}
35+
36+
const fh = await fs.open(sessionFile, "r");
37+
try {
38+
const bytesToRead = Math.min(stat.size, CONTINUATION_SCAN_MAX_TAIL_BYTES);
39+
if (bytesToRead <= 0) {
40+
return false;
41+
}
42+
const start = stat.size - bytesToRead;
43+
const buffer = Buffer.allocUnsafe(bytesToRead);
44+
const { bytesRead } = await fh.read(buffer, 0, bytesToRead, start);
45+
let text = buffer.toString("utf-8", 0, bytesRead);
46+
if (start > 0) {
47+
const firstNewline = text.indexOf("\n");
48+
if (firstNewline === -1) {
49+
return false;
50+
}
51+
text = text.slice(firstNewline + 1);
52+
}
53+
54+
const records = text
55+
.split(/\r?\n/u)
56+
.filter((line) => line.trim().length > 0)
57+
.slice(-CONTINUATION_SCAN_MAX_RECORDS);
58+
let compactedAfterLatestAssistant = false;
59+
60+
for (let i = records.length - 1; i >= 0; i--) {
61+
const line = records[i];
62+
if (!line) {
63+
continue;
64+
}
65+
let entry: unknown;
66+
try {
67+
entry = JSON.parse(line);
68+
} catch {
69+
continue;
70+
}
71+
const record = entry as
72+
| {
73+
type?: string;
74+
customType?: string;
75+
message?: { role?: string };
76+
}
77+
| null
78+
| undefined;
79+
if (record?.type === "compaction") {
80+
compactedAfterLatestAssistant = true;
81+
continue;
82+
}
83+
if (
84+
record?.type === "custom" &&
85+
record.customType === FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE
86+
) {
87+
return !compactedAfterLatestAssistant;
88+
}
89+
}
90+
91+
return false;
92+
} finally {
93+
await fh.close();
94+
}
95+
} catch {
96+
return false;
97+
}
98+
}
99+
19100
export function makeBootstrapWarn(params: {
20101
sessionLabel: string;
21102
warn?: (message: string) => void;

0 commit comments

Comments
 (0)