Skip to content

Commit ad925bd

Browse files
authored
Preserve AGENTS.md policy during bootstrap truncation (#82921)
Fixes #82920
1 parent 9108ae0 commit ad925bd

5 files changed

Lines changed: 178 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ Docs: https://docs.openclaw.ai
281281
- ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.
282282
- Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.
283283
- Agents/diagnostics: treat repeated same-handle embedded-run cleanup as idempotent while preserving true replacement-handle mismatch diagnostics. Fixes #82959. (#82960) Thanks @galiniliev.
284+
- Agents/subagents: preserve high-priority `AGENTS.md` policy in bootstrap context when oversized files are trimmed, and warn agents to read the full policy file before relying on scoped rules. Fixes #82920. (#82921) Thanks @galiniliev.
284285
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
285286
- Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.
286287
- Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving `default` instead of silently dropping it.

src/agents/bootstrap-budget.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,28 @@ describe("bootstrap prompt warnings", () => {
326326
expect(lines).toContain("+1 more truncated file(s).");
327327
});
328328

329+
it("warns explicitly when AGENTS.md bootstrap policy is truncated", () => {
330+
const analysis = analyzeBootstrapBudget({
331+
files: [
332+
{
333+
name: "AGENTS.md",
334+
path: "/tmp/AGENTS.md",
335+
missing: false,
336+
rawChars: 150,
337+
injectedChars: 100,
338+
truncated: true,
339+
},
340+
],
341+
bootstrapMaxChars: 120,
342+
bootstrapTotalMaxChars: 200,
343+
});
344+
const lines = formatBootstrapTruncationWarningLines({ analysis });
345+
346+
expect(lines).toContain(
347+
"AGENTS.md was truncated; read the full AGENTS.md before relying on scoped policy.",
348+
);
349+
});
350+
329351
it("disambiguates duplicate file names in warning lines", () => {
330352
const analysis = analyzeBootstrapBudget({
331353
files: [
@@ -390,6 +412,7 @@ describe("bootstrap prompt warnings", () => {
390412
expect(always.warningShown).toBe(true);
391413
expect(always.lines).toStrictEqual([
392414
"AGENTS.md: 150 raw -> 100 injected (~33% removed; max/file).",
415+
"AGENTS.md was truncated; read the full AGENTS.md before relying on scoped policy.",
393416
"If unintentional, raise agents.defaults.bootstrapMaxChars and/or agents.defaults.bootstrapTotalMaxChars.",
394417
]);
395418
});

src/agents/bootstrap-budget.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ function formatWarningCause(cause: BootstrapTruncationCause): string {
6868
return cause === "per-file-limit" ? "max/file" : "max/total";
6969
}
7070

71+
function isAgentsBootstrapName(name: string): boolean {
72+
return name.toLowerCase() === "agents.md";
73+
}
74+
7175
function normalizeSeenSignatures(signatures?: string[]): string[] {
7276
if (!Array.isArray(signatures) || signatures.length === 0) {
7377
return [];
@@ -293,6 +297,9 @@ export function formatBootstrapTruncationWarningLines(params: {
293297
`+${params.analysis.truncatedFiles.length - topFiles.length} more truncated file(s).`,
294298
);
295299
}
300+
if (params.analysis.truncatedFiles.some((file) => isAgentsBootstrapName(file.name))) {
301+
lines.push("AGENTS.md was truncated; read the full AGENTS.md before relying on scoped policy.");
302+
}
296303
lines.push(
297304
"If unintentional, raise agents.defaults.bootstrapMaxChars and/or agents.defaults.bootstrapTotalMaxChars.",
298305
);

src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,26 @@ describe("buildBootstrapContextFiles", () => {
9999
expect(result?.content).toContain("[...truncated, read HEARTBEAT.md for full content...]");
100100
expect(result?.content.length).toBeLessThanOrEqual(maxChars);
101101
});
102+
it("keeps policy digest lines from oversized AGENTS.md middle content", () => {
103+
const requiredScopedInstruction =
104+
"- Required scoped instruction: read scoped AGENTS.md before editing subtree work.";
105+
const content = [
106+
"# Root policy",
107+
"A".repeat(900),
108+
"## Scoped policy",
109+
requiredScopedInstruction,
110+
"B".repeat(700),
111+
"tail marker",
112+
].join("\n");
113+
const [result] = buildBootstrapContextFiles([makeFile({ content })], {
114+
maxChars: 600,
115+
});
116+
117+
expect(result?.content.length).toBeLessThanOrEqual(600);
118+
expect(result?.content).toContain("[Policy digest from AGENTS.md]");
119+
expect(result?.content).toContain(requiredScopedInstruction);
120+
expect(result?.content).toContain("[...truncated, read AGENTS.md for full content...]");
121+
});
102122
it("keeps bootstrap bytes in tiny per-file budgets when the marker is longer than the limit", () => {
103123
const maxChars = 64;
104124
const content = `HEAD-${"a".repeat(1_000)}-TAIL`;

src/agents/pi-embedded-helpers/bootstrap.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ const MIN_BOOTSTRAP_FILE_BUDGET_CHARS = 64;
9696
const BOOTSTRAP_HEAD_RATIO = 0.75;
9797
const BOOTSTRAP_TAIL_RATIO = 0.25;
9898
const MIN_BOOTSTRAP_TRIMMED_CONTENT_CHARS = 16;
99+
const AGENTS_BOOTSTRAP_FILENAME = "AGENTS.md";
100+
const AGENTS_POLICY_DIGEST_RATIO = 0.35;
101+
const AGENTS_POLICY_HEAD_RATIO = 0.45;
102+
const AGENTS_POLICY_TAIL_RATIO = 0.15;
103+
const AGENTS_POLICY_DIGEST_MAX_LINE_CHARS = 240;
99104

100105
type TrimBootstrapResult = {
101106
content: string;
@@ -104,6 +109,11 @@ type TrimBootstrapResult = {
104109
originalLength: number;
105110
};
106111

112+
type PolicyDigest = {
113+
text: string;
114+
omittedLines: number;
115+
};
116+
107117
export function resolveBootstrapMaxChars(cfg?: OpenClawConfig, agentId?: string | null): number {
108118
const raw =
109119
cfg && agentId
@@ -141,6 +151,120 @@ export function resolveBootstrapPromptTruncationWarningMode(
141151
return DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE;
142152
}
143153

154+
function isAgentsBootstrapFile(fileName: string): boolean {
155+
return fileName.toLowerCase() === AGENTS_BOOTSTRAP_FILENAME.toLowerCase();
156+
}
157+
158+
function isPolicyDigestCandidate(line: string): boolean {
159+
if (/^(?:#{1,6}|\s*[-*+]|\s*\d+[.)])\s+\S/u.test(line)) {
160+
return true;
161+
}
162+
return /\b(?:AGENTS\.md|scoped|required|must|never|do not|before subtree|read scoped|owner|security|secret|credential|test|validation|command|commit|push|github|pr)\b/iu.test(
163+
line,
164+
);
165+
}
166+
167+
function normalizePolicyDigestLine(line: string): string {
168+
const normalized = line.trim().replace(/\s+/gu, " ");
169+
if (normalized.length <= AGENTS_POLICY_DIGEST_MAX_LINE_CHARS) {
170+
return normalized;
171+
}
172+
return `${truncateUtf16Safe(normalized, AGENTS_POLICY_DIGEST_MAX_LINE_CHARS - 1)}…`;
173+
}
174+
175+
function buildAgentsPolicyDigest(content: string, budget: number): PolicyDigest {
176+
if (budget <= 0) {
177+
return { text: "", omittedLines: 0 };
178+
}
179+
180+
const candidates = content
181+
.split(/\r?\n/u)
182+
.map((line, index) => ({ index, line: normalizePolicyDigestLine(line) }))
183+
.filter(({ line }) => line.length > 0 && isPolicyDigestCandidate(line));
184+
const highPriorityPattern =
185+
/\b(?:AGENTS\.md|scoped|required|must|never|do not|before subtree|read scoped|security|secret|credential)\b/iu;
186+
const selected = new Set<number>();
187+
let used = 0;
188+
const trySelect = (candidate: { index: number; line: string }) => {
189+
const separatorChars = selected.size > 0 ? 1 : 0;
190+
if (used + separatorChars + candidate.line.length > budget) {
191+
return;
192+
}
193+
selected.add(candidate.index);
194+
used += separatorChars + candidate.line.length;
195+
};
196+
197+
for (const candidate of candidates) {
198+
if (highPriorityPattern.test(candidate.line)) {
199+
trySelect(candidate);
200+
}
201+
}
202+
for (const candidate of candidates) {
203+
if (!selected.has(candidate.index)) {
204+
trySelect(candidate);
205+
}
206+
}
207+
208+
const lines = candidates
209+
.filter((candidate) => selected.has(candidate.index))
210+
.toSorted((a, b) => a.index - b.index)
211+
.map((candidate) => candidate.line);
212+
return {
213+
text: lines.join("\n"),
214+
omittedLines: Math.max(0, candidates.length - lines.length),
215+
};
216+
}
217+
218+
function trimAgentsBootstrapContent(content: string, maxChars: number): TrimBootstrapResult {
219+
const trimmed = content.trimEnd();
220+
if (trimmed.length <= maxChars) {
221+
return {
222+
content: trimmed,
223+
truncated: false,
224+
maxChars,
225+
originalLength: trimmed.length,
226+
};
227+
}
228+
229+
let headChars = Math.floor(maxChars * AGENTS_POLICY_HEAD_RATIO);
230+
let tailChars = Math.floor(maxChars * AGENTS_POLICY_TAIL_RATIO);
231+
let digestBudget = Math.floor(maxChars * AGENTS_POLICY_DIGEST_RATIO);
232+
let digest = buildAgentsPolicyDigest(trimmed, digestBudget);
233+
const render = () =>
234+
[
235+
trimmed.slice(0, headChars),
236+
`[...truncated, read ${AGENTS_BOOTSTRAP_FILENAME} for full content...]`,
237+
digest.text ? "[Policy digest from AGENTS.md]" : "",
238+
digest.text,
239+
digest.omittedLines > 0 ? `[...${digest.omittedLines} more policy lines omitted...]` : "",
240+
`…(truncated ${AGENTS_BOOTSTRAP_FILENAME}: kept ${headChars}+policy ${digest.text.length}+${tailChars} chars of ${trimmed.length})…`,
241+
tailChars > 0 ? trimmed.slice(-tailChars) : "",
242+
]
243+
.filter((part) => part.length > 0)
244+
.join("\n");
245+
246+
let rendered = render();
247+
while (rendered.length > maxChars && (tailChars > 0 || headChars > 1 || digestBudget > 0)) {
248+
const overflow = rendered.length - maxChars;
249+
if (tailChars > 0) {
250+
tailChars = Math.max(0, tailChars - overflow);
251+
} else if (headChars > 1) {
252+
headChars = Math.max(1, headChars - overflow);
253+
} else {
254+
digestBudget = Math.max(0, digestBudget - overflow);
255+
digest = buildAgentsPolicyDigest(trimmed, digestBudget);
256+
}
257+
rendered = render();
258+
}
259+
260+
return {
261+
content: rendered.length > maxChars ? truncateUtf16Safe(rendered, maxChars) : rendered,
262+
truncated: true,
263+
maxChars,
264+
originalLength: trimmed.length,
265+
};
266+
}
267+
144268
function trimBootstrapContent(
145269
content: string,
146270
fileName: string,
@@ -155,6 +279,9 @@ function trimBootstrapContent(
155279
originalLength: trimmed.length,
156280
};
157281
}
282+
if (isAgentsBootstrapFile(fileName)) {
283+
return trimAgentsBootstrapContent(content, maxChars);
284+
}
158285

159286
const markerTemplate = (headChars: number, tailChars: number) =>
160287
[

0 commit comments

Comments
 (0)