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:
Vincent Koc 2026-04-06 05:27:28 +01:00 committed by GitHub
parent 39099b8022
commit 9ba97ceaed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 527 additions and 28 deletions

View File

@ -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

View File

@ -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

View File

@ -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");
});
});

View File

@ -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;

View File

@ -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(),
);
});
});

View File

@ -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,

View File

@ -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

View File

@ -38,6 +38,7 @@ import {
export { extractAssistantText, stripToolMessages };
export { resolveCommandSurfaceChannel, resolveChannelAccountId };
export type { ChatMessage };
export const COMMAND = "/subagents";
export const COMMAND_KILL = "/kill";

View File

@ -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).",

View File

@ -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":

View File

@ -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",

View File

@ -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). */

View File

@ -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();
});
});

View File

@ -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