mirror of https://github.com/openclaw/openclaw.git
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
This commit is contained in:
parent
39099b8022
commit
9ba97ceaed
|
|
@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
|
|||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
433dc1a6776b3c782524489d6bb22c770015d4915f6886da89bb3538698f0057 config-baseline.json
|
||||
71414a189b62e3a362443068cb911372b2fe326a0bf43237a36d475533508499 config-baseline.core.json
|
||||
1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json
|
||||
7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json
|
||||
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
|
||||
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import {
|
|||
type AgentBootstrapHookContext,
|
||||
} from "../hooks/internal-hooks.js";
|
||||
import { makeTempWorkspace } from "../test-helpers/workspace.js";
|
||||
import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js";
|
||||
import {
|
||||
FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
hasCompletedBootstrapTurn,
|
||||
resolveBootstrapContextForRun,
|
||||
resolveBootstrapFilesForRun,
|
||||
resolveContextInjectionMode,
|
||||
} from "./bootstrap-files.js";
|
||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
|
||||
function registerExtraBootstrapFileHook() {
|
||||
|
|
@ -127,3 +133,181 @@ describe("resolveBootstrapContextForRun", () => {
|
|||
expect(files).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCompletedBootstrapTurn", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(await fs.realpath("/tmp"), "openclaw-bootstrap-turn-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false when session file does not exist", async () => {
|
||||
expect(await hasCompletedBootstrapTurn(path.join(tmpDir, "missing.jsonl"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty session files", async () => {
|
||||
const sessionFile = path.join(tmpDir, "empty.jsonl");
|
||||
await fs.writeFile(sessionFile, "", "utf8");
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for header-only session files", async () => {
|
||||
const sessionFile = path.join(tmpDir, "header-only.jsonl");
|
||||
await fs.writeFile(sessionFile, `${JSON.stringify({ type: "session", id: "s1" })}\n`, "utf8");
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no assistant turn has been flushed yet", async () => {
|
||||
const sessionFile = path.join(tmpDir, "user-only.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({ type: "session", id: "s1" }),
|
||||
JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for assistant turns without a recorded full bootstrap marker", async () => {
|
||||
const sessionFile = path.join(tmpDir, "assistant-no-marker.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({ type: "session", id: "s1" }),
|
||||
JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }),
|
||||
JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a full bootstrap completion marker exists", async () => {
|
||||
const sessionFile = path.join(tmpDir, "full-bootstrap.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }),
|
||||
JSON.stringify({
|
||||
type: "custom",
|
||||
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
data: { timestamp: 1 },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when compaction happened after the last assistant turn", async () => {
|
||||
const sessionFile = path.join(tmpDir, "post-compaction.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "custom",
|
||||
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
data: { timestamp: 1 },
|
||||
}),
|
||||
JSON.stringify({ type: "compaction", summary: "trimmed" }),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a later full bootstrap marker happens after compaction", async () => {
|
||||
const sessionFile = path.join(tmpDir, "assistant-after-compaction.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "custom",
|
||||
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
data: { timestamp: 1 },
|
||||
}),
|
||||
JSON.stringify({ type: "compaction", summary: "trimmed" }),
|
||||
JSON.stringify({ type: "message", message: { role: "user", content: "new ask" } }),
|
||||
JSON.stringify({ type: "message", message: { role: "assistant", content: "new reply" } }),
|
||||
JSON.stringify({
|
||||
type: "custom",
|
||||
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
data: { timestamp: 2 },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores malformed JSON lines", async () => {
|
||||
const sessionFile = path.join(tmpDir, "malformed.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
"{broken",
|
||||
JSON.stringify({
|
||||
type: "custom",
|
||||
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
data: { timestamp: 1 },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("finds a recent full bootstrap marker even when the scan starts mid-file", async () => {
|
||||
const sessionFile = path.join(tmpDir, "large-prefix.jsonl");
|
||||
const hugePrefix = "x".repeat(300 * 1024);
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({ type: "message", message: { role: "user", content: hugePrefix } }),
|
||||
JSON.stringify({
|
||||
type: "custom",
|
||||
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
data: { timestamp: 1 },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for symbolic links", async () => {
|
||||
const realFile = path.join(tmpDir, "real.jsonl");
|
||||
const linkFile = path.join(tmpDir, "link.jsonl");
|
||||
await fs.writeFile(
|
||||
realFile,
|
||||
`${JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 } })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.symlink(realFile, linkFile);
|
||||
expect(await hasCompletedBootstrapTurn(linkFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContextInjectionMode", () => {
|
||||
it("defaults to always when config is missing", () => {
|
||||
expect(resolveContextInjectionMode(undefined)).toBe("always");
|
||||
});
|
||||
|
||||
it("defaults to always when the setting is omitted", () => {
|
||||
expect(resolveContextInjectionMode({ agents: { defaults: {} } } as never)).toBe("always");
|
||||
});
|
||||
|
||||
it("returns the configured continuation-skip mode", () => {
|
||||
expect(
|
||||
resolveContextInjectionMode({
|
||||
agents: { defaults: { contextInjection: "continuation-skip" } },
|
||||
} as never),
|
||||
).toBe("continuation-skip");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import fs from "node:fs/promises";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
|
||||
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
|
||||
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
|
||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
|
|
@ -16,6 +18,85 @@ import {
|
|||
export type BootstrapContextMode = "full" | "lightweight";
|
||||
export type BootstrapContextRunKind = "default" | "heartbeat" | "cron";
|
||||
|
||||
const CONTINUATION_SCAN_MAX_TAIL_BYTES = 256 * 1024;
|
||||
const CONTINUATION_SCAN_MAX_RECORDS = 500;
|
||||
export const FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE = "openclaw:bootstrap-context:full";
|
||||
|
||||
export function resolveContextInjectionMode(config?: OpenClawConfig): AgentContextInjection {
|
||||
return config?.agents?.defaults?.contextInjection ?? "always";
|
||||
}
|
||||
|
||||
export async function hasCompletedBootstrapTurn(sessionFile: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(sessionFile);
|
||||
if (stat.isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fh = await fs.open(sessionFile, "r");
|
||||
try {
|
||||
const bytesToRead = Math.min(stat.size, CONTINUATION_SCAN_MAX_TAIL_BYTES);
|
||||
if (bytesToRead <= 0) {
|
||||
return false;
|
||||
}
|
||||
const start = stat.size - bytesToRead;
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead);
|
||||
const { bytesRead } = await fh.read(buffer, 0, bytesToRead, start);
|
||||
let text = buffer.toString("utf-8", 0, bytesRead);
|
||||
if (start > 0) {
|
||||
const firstNewline = text.indexOf("\n");
|
||||
if (firstNewline === -1) {
|
||||
return false;
|
||||
}
|
||||
text = text.slice(firstNewline + 1);
|
||||
}
|
||||
|
||||
const records = text
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.slice(-CONTINUATION_SCAN_MAX_RECORDS);
|
||||
let compactedAfterLatestAssistant = false;
|
||||
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
const line = records[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const record = entry as
|
||||
| {
|
||||
type?: string;
|
||||
customType?: string;
|
||||
message?: { role?: string };
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
if (record?.type === "compaction") {
|
||||
compactedAfterLatestAssistant = true;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
record?.type === "custom" &&
|
||||
record.customType === FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE
|
||||
) {
|
||||
return !compactedAfterLatestAssistant;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeBootstrapWarn(params: {
|
||||
sessionLabel: string;
|
||||
warn?: (message: string) => void;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTempPaths,
|
||||
createContextEngineAttemptRunner,
|
||||
getHoisted,
|
||||
resetEmbeddedAttemptHarness,
|
||||
} from "./attempt.spawn-workspace.test-support.js";
|
||||
|
||||
const hoisted = getHoisted();
|
||||
|
||||
describe("runEmbeddedAttempt context injection", () => {
|
||||
const tempPaths: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resetEmbeddedAttemptHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempPaths(tempPaths);
|
||||
});
|
||||
|
||||
it("skips bootstrap reinjection on safe continuation turns when configured", async () => {
|
||||
hoisted.resolveContextInjectionModeMock.mockReturnValue("continuation-skip");
|
||||
hoisted.hasCompletedBootstrapTurnMock.mockResolvedValue(true);
|
||||
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine: {
|
||||
assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
|
||||
},
|
||||
sessionKey: "agent:main",
|
||||
tempPaths,
|
||||
});
|
||||
|
||||
expect(hoisted.hasCompletedBootstrapTurnMock).toHaveBeenCalled();
|
||||
expect(hoisted.resolveBootstrapContextForRunMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks continuation state only after taking the session lock", async () => {
|
||||
hoisted.resolveContextInjectionModeMock.mockReturnValue("continuation-skip");
|
||||
hoisted.hasCompletedBootstrapTurnMock.mockResolvedValue(true);
|
||||
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine: {
|
||||
assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
|
||||
},
|
||||
sessionKey: "agent:main",
|
||||
tempPaths,
|
||||
});
|
||||
|
||||
expect(hoisted.acquireSessionWriteLockMock).toHaveBeenCalled();
|
||||
expect(hoisted.hasCompletedBootstrapTurnMock).toHaveBeenCalled();
|
||||
const lockCallOrder = hoisted.acquireSessionWriteLockMock.mock.invocationCallOrder[0];
|
||||
const continuationCallOrder = hoisted.hasCompletedBootstrapTurnMock.mock.invocationCallOrder[0];
|
||||
expect(lockCallOrder).toBeLessThan(continuationCallOrder);
|
||||
});
|
||||
|
||||
it("still resolves bootstrap context when continuation-skip has no completed assistant turn yet", async () => {
|
||||
hoisted.resolveContextInjectionModeMock.mockReturnValue("continuation-skip");
|
||||
hoisted.hasCompletedBootstrapTurnMock.mockResolvedValue(false);
|
||||
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine: {
|
||||
assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
|
||||
},
|
||||
sessionKey: "agent:main",
|
||||
tempPaths,
|
||||
});
|
||||
|
||||
expect(hoisted.resolveBootstrapContextForRunMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never skips heartbeat bootstrap filtering", async () => {
|
||||
hoisted.resolveContextInjectionModeMock.mockReturnValue("continuation-skip");
|
||||
hoisted.hasCompletedBootstrapTurnMock.mockResolvedValue(true);
|
||||
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine: {
|
||||
assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
|
||||
},
|
||||
attemptOverrides: {
|
||||
bootstrapContextMode: "lightweight",
|
||||
bootstrapContextRunKind: "heartbeat",
|
||||
},
|
||||
sessionKey: "agent:main:heartbeat:test",
|
||||
tempPaths,
|
||||
});
|
||||
|
||||
expect(hoisted.hasCompletedBootstrapTurnMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.resolveBootstrapContextForRunMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contextMode: "lightweight",
|
||||
runKind: "heartbeat",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records full bootstrap completion after a successful non-heartbeat turn", async () => {
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine: {
|
||||
assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
|
||||
},
|
||||
sessionKey: "agent:main",
|
||||
tempPaths,
|
||||
});
|
||||
|
||||
expect(hoisted.sessionManager.appendCustomEntry).toHaveBeenCalledWith(
|
||||
"openclaw:bootstrap-context:full",
|
||||
expect.objectContaining({
|
||||
runId: "run-context-engine-forwarding",
|
||||
sessionId: "embedded-session",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not record full bootstrap completion for heartbeat runs", async () => {
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine: {
|
||||
assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
|
||||
},
|
||||
attemptOverrides: {
|
||||
bootstrapContextMode: "lightweight",
|
||||
bootstrapContextRunKind: "heartbeat",
|
||||
},
|
||||
sessionKey: "agent:main:heartbeat:test",
|
||||
tempPaths,
|
||||
});
|
||||
|
||||
expect(hoisted.sessionManager.appendCustomEntry).not.toHaveBeenCalledWith(
|
||||
"openclaw:bootstrap-context:full",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -48,6 +48,8 @@ type AttemptSpawnWorkspaceHoisted = {
|
|||
flushPendingToolResultsAfterIdleMock: AsyncUnknownMock;
|
||||
releaseWsSessionMock: UnknownMock;
|
||||
resolveBootstrapContextForRunMock: Mock<() => Promise<BootstrapContext>>;
|
||||
resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">;
|
||||
hasCompletedBootstrapTurnMock: Mock<() => Promise<boolean>>;
|
||||
getGlobalHookRunnerMock: Mock<() => unknown>;
|
||||
initializeGlobalHookRunnerMock: UnknownMock;
|
||||
runContextEngineMaintenanceMock: AsyncUnknownMock;
|
||||
|
|
@ -90,6 +92,10 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
|
|||
bootstrapFiles: [],
|
||||
contextFiles: [],
|
||||
}));
|
||||
const resolveContextInjectionModeMock = vi.fn<() => "always" | "continuation-skip">(
|
||||
() => "always",
|
||||
);
|
||||
const hasCompletedBootstrapTurnMock = vi.fn<() => Promise<boolean>>(async () => false);
|
||||
const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined);
|
||||
const initializeGlobalHookRunnerMock = vi.fn();
|
||||
const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined);
|
||||
|
|
@ -113,6 +119,8 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
|
|||
flushPendingToolResultsAfterIdleMock,
|
||||
releaseWsSessionMock,
|
||||
resolveBootstrapContextForRunMock,
|
||||
resolveContextInjectionModeMock,
|
||||
hasCompletedBootstrapTurnMock,
|
||||
getGlobalHookRunnerMock,
|
||||
initializeGlobalHookRunnerMock,
|
||||
runContextEngineMaintenanceMock,
|
||||
|
|
@ -177,6 +185,8 @@ vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
|
|||
vi.mock("../../bootstrap-files.js", () => ({
|
||||
makeBootstrapWarn: () => () => {},
|
||||
resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock,
|
||||
resolveContextInjectionMode: hoisted.resolveContextInjectionModeMock,
|
||||
hasCompletedBootstrapTurn: hoisted.hasCompletedBootstrapTurnMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../skills.js", () => ({
|
||||
|
|
@ -585,6 +595,8 @@ export function resetEmbeddedAttemptHarness(
|
|||
bootstrapFiles: [],
|
||||
contextFiles: [],
|
||||
});
|
||||
hoisted.resolveContextInjectionModeMock.mockReset().mockReturnValue("always");
|
||||
hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false);
|
||||
hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined);
|
||||
hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined);
|
||||
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
|
||||
|
|
@ -684,6 +696,10 @@ export const cacheTtlEligibleModel = {
|
|||
input: ["text"],
|
||||
} as unknown as Model<Api>;
|
||||
|
||||
const testAuthStorage = {
|
||||
getApiKey: async () => undefined,
|
||||
};
|
||||
|
||||
export async function createContextEngineAttemptRunner(params: {
|
||||
contextEngine: {
|
||||
bootstrap?: (params: {
|
||||
|
|
@ -789,10 +805,7 @@ export async function createContextEngineAttemptRunner(params: {
|
|||
provider: "openai",
|
||||
modelId: "gpt-test",
|
||||
model: testModel,
|
||||
authStorage: {
|
||||
getApiKey: async () => undefined,
|
||||
setRuntimeApiKey: () => {},
|
||||
} as never,
|
||||
authStorage: testAuthStorage as never,
|
||||
modelRegistry: {} as never,
|
||||
thinkLevel: "off",
|
||||
senderIsOwner: true,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,13 @@ import {
|
|||
buildBootstrapInjectionStats,
|
||||
prependBootstrapPromptWarning,
|
||||
} from "../../bootstrap-budget.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||
import {
|
||||
FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
|
||||
hasCompletedBootstrapTurn,
|
||||
makeBootstrapWarn,
|
||||
resolveBootstrapContextForRun,
|
||||
resolveContextInjectionMode,
|
||||
} from "../../bootstrap-files.js";
|
||||
import { createCacheTrace } from "../../cache-trace.js";
|
||||
import {
|
||||
listChannelSupportedActions,
|
||||
|
|
@ -366,17 +372,40 @@ export async function runEmbeddedAttempt(
|
|||
agentId: sessionAgentId,
|
||||
});
|
||||
|
||||
const sessionLock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
maxHoldMs: resolveSessionLockMaxHoldFromTimeout({
|
||||
timeoutMs: resolveRunTimeoutWithCompactionGraceMs({
|
||||
runTimeoutMs: params.timeoutMs,
|
||||
compactionTimeoutMs: resolveCompactionTimeoutMs(params.config),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
const { bootstrapFiles: hookAdjustedBootstrapFiles, contextFiles } =
|
||||
await resolveBootstrapContextForRun({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||
contextMode: params.bootstrapContextMode,
|
||||
runKind: params.bootstrapContextRunKind,
|
||||
});
|
||||
const contextInjectionMode = resolveContextInjectionMode(params.config);
|
||||
const isContinuationTurn =
|
||||
contextInjectionMode === "continuation-skip" &&
|
||||
params.bootstrapContextRunKind !== "heartbeat" &&
|
||||
(await hasCompletedBootstrapTurn(params.sessionFile));
|
||||
const shouldRecordCompletedBootstrapTurn =
|
||||
!isContinuationTurn &&
|
||||
params.bootstrapContextMode !== "lightweight" &&
|
||||
params.bootstrapContextRunKind !== "heartbeat";
|
||||
const { bootstrapFiles: hookAdjustedBootstrapFiles, contextFiles } = isContinuationTurn
|
||||
? {
|
||||
bootstrapFiles: [],
|
||||
contextFiles: [],
|
||||
}
|
||||
: await resolveBootstrapContextForRun({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||
contextMode: params.bootstrapContextMode,
|
||||
runKind: params.bootstrapContextRunKind,
|
||||
});
|
||||
const bootstrapMaxChars = resolveBootstrapMaxChars(params.config);
|
||||
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config);
|
||||
const bootstrapAnalysis = analyzeBootstrapBudget({
|
||||
|
|
@ -740,16 +769,6 @@ export async function runEmbeddedAttempt(
|
|||
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
|
||||
let systemPromptText = systemPromptOverride();
|
||||
|
||||
const sessionLock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
maxHoldMs: resolveSessionLockMaxHoldFromTimeout({
|
||||
timeoutMs: resolveRunTimeoutWithCompactionGraceMs({
|
||||
runTimeoutMs: params.timeoutMs,
|
||||
compactionTimeoutMs: resolveCompactionTimeoutMs(params.config),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
||||
let removeToolResultContextGuard: (() => void) | undefined;
|
||||
|
|
@ -1911,6 +1930,25 @@ export async function runEmbeddedAttempt(
|
|||
});
|
||||
}
|
||||
|
||||
if (
|
||||
shouldRecordCompletedBootstrapTurn &&
|
||||
!promptError &&
|
||||
!aborted &&
|
||||
!yieldAborted &&
|
||||
!timedOutDuringCompaction &&
|
||||
!compactionOccurredThisAttempt
|
||||
) {
|
||||
try {
|
||||
sessionManager.appendCustomEntry(FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, {
|
||||
timestamp: Date.now(),
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
} catch (entryErr) {
|
||||
log.warn(`failed to persist bootstrap completion entry: ${String(entryErr)}`);
|
||||
}
|
||||
}
|
||||
|
||||
cacheTrace?.recordStage("session:after", {
|
||||
messages: messagesSnapshot,
|
||||
note: timedOutDuringCompaction
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
|
||||
export { extractAssistantText, stripToolMessages };
|
||||
export { resolveCommandSurfaceChannel, resolveChannelAccountId };
|
||||
export type { ChatMessage };
|
||||
|
||||
export const COMMAND = "/subagents";
|
||||
export const COMMAND_KILL = "/kill";
|
||||
|
|
|
|||
|
|
@ -3172,6 +3172,21 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
|||
skipBootstrap: {
|
||||
type: "boolean",
|
||||
},
|
||||
contextInjection: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "always",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "continuation-skip",
|
||||
},
|
||||
],
|
||||
title: "Context Injection",
|
||||
description:
|
||||
'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.',
|
||||
},
|
||||
bootstrapMaxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
|
|
@ -23888,6 +23903,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
|||
help: "Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"agents.defaults.contextInjection": {
|
||||
label: "Context Injection",
|
||||
help: 'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.',
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"agents.defaults.bootstrapMaxChars": {
|
||||
label: "Bootstrap Max Chars",
|
||||
help: "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
||||
|
|
|
|||
|
|
@ -826,6 +826,8 @@ export const FIELD_HELP: Record<string, string> = {
|
|||
"Maximum same-provider auth-profile rotations allowed for rate-limit errors before switching to model fallback (default: 1).",
|
||||
"agents.defaults.workspace":
|
||||
"Default workspace path exposed to agent runtime tools for filesystem context and repo-aware behavior. Set this explicitly when running from wrappers so path resolution stays deterministic.",
|
||||
"agents.defaults.contextInjection":
|
||||
'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.',
|
||||
"agents.defaults.bootstrapMaxChars":
|
||||
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
||||
"agents.defaults.bootstrapTotalMaxChars":
|
||||
|
|
|
|||
|
|
@ -328,6 +328,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||
"agents.defaults.skills": "Skills",
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
"agents.defaults.repoRoot": "Repo Root",
|
||||
"agents.defaults.contextInjection": "Context Injection",
|
||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||
"agents.defaults.bootstrapTotalMaxChars": "Bootstrap Total Max Chars",
|
||||
"agents.defaults.bootstrapPromptTruncationWarning": "Bootstrap Prompt Truncation Warning",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import type {
|
|||
} from "./types.base.js";
|
||||
import type { MemorySearchConfig } from "./types.tools.js";
|
||||
|
||||
export type AgentContextInjection = "always" | "continuation-skip";
|
||||
|
||||
export type AgentModelEntryConfig = {
|
||||
alias?: string;
|
||||
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
||||
|
|
@ -73,6 +75,14 @@ export type AgentDefaultsConfig = {
|
|||
repoRoot?: string;
|
||||
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
||||
skipBootstrap?: boolean;
|
||||
/**
|
||||
* Controls when workspace bootstrap files (AGENTS.md, SOUL.md, etc.) are
|
||||
* injected into the system prompt:
|
||||
* - always: inject on every turn (default)
|
||||
* - continuation-skip: skip injection on safe continuation turns once the
|
||||
* transcript already contains a completed assistant turn
|
||||
*/
|
||||
contextInjection?: AgentContextInjection;
|
||||
/** Max chars for injected bootstrap files before truncation (default: 20000). */
|
||||
bootstrapMaxChars?: number;
|
||||
/** Max total chars across all injected bootstrap files (default: 150000). */
|
||||
|
|
|
|||
|
|
@ -22,4 +22,18 @@ describe("agent defaults schema", () => {
|
|||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts contextInjection: always", () => {
|
||||
const result = AgentDefaultsSchema.parse({ contextInjection: "always" })!;
|
||||
expect(result.contextInjection).toBe("always");
|
||||
});
|
||||
|
||||
it("accepts contextInjection: continuation-skip", () => {
|
||||
const result = AgentDefaultsSchema.parse({ contextInjection: "continuation-skip" })!;
|
||||
expect(result.contextInjection).toBe("continuation-skip");
|
||||
});
|
||||
|
||||
it("rejects invalid contextInjection values", () => {
|
||||
expect(() => AgentDefaultsSchema.parse({ contextInjection: "never" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export const AgentDefaultsSchema = z
|
|||
skills: z.array(z.string()).optional(),
|
||||
repoRoot: z.string().optional(),
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
contextInjection: z.union([z.literal("always"), z.literal("continuation-skip")]).optional(),
|
||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||
bootstrapTotalMaxChars: z.number().int().positive().optional(),
|
||||
bootstrapPromptTruncationWarning: z
|
||||
|
|
|
|||
Loading…
Reference in New Issue