feat(agents): add contextInjection mode to skip workspace files on subsequent messages#40372
feat(agents): add contextInjection mode to skip workspace files on subsequent messages#40372dsantoreis wants to merge 5 commits into
Conversation
Greptile SummaryThis PR introduces a Key findings:
Confidence Score: 2/5
Last reviewed commit: 04a876a |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 04a876ad0a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 00a27604f9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
…bsequent messages Add agents.defaults.contextInjection config option with two modes: - "always" (default): current behavior, inject every message - "first-message-only": skip workspace file injection when the session transcript already contains assistant messages Detection reads the first 16KB of the session JSONL file to check for assistant role entries (sync, fast, no full transcript load). This addresses review feedback from openclaw#28072 where sessionFileExists was too coarse a proxy — pre-created, aborted, or empty sessions with existing files but no real assistant turns are handled correctly. Security benefit: workspace files (AGENTS.md, SOUL.md, USER.md, TOOLS.md) often contain API keys, personal data, and infrastructure details. With "always" mode these are sent to the LLM provider on every single message of a conversation. "first-message-only" reduces that exposure surface by ~93.5% over a 100-message session. 12 targeted tests cover: filter modes, transcript scanning (empty, user-only, with-assistant, malformed), and end-to-end bootstrap context resolution with both modes. Closes openclaw#9157
Problem: first-message-only context injection dropped bootstrap metadata after message 1 and used sync file I/O for history detection, leading to missing workspace commit reminders and event-loop blocking reads. Root cause: context filtering was applied before metadata-dependent checks and session history probing used fs sync APIs. Fix: make session history detection async, keep metadata checks by resolving bootstrap files in always mode when needed, and reuse ContextInjectionMode type in run params. Testing: pnpm test src/agents/bootstrap-files.context-injection.test.ts; pnpm check
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a6ec12d527
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1ab5f95ed9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2edaf4f60
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e18b57996
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const hasExistingAssistantMessages = | ||
| contextInjection === "first-message-only" | ||
| ? await sessionHasAssistantMessages(params.sessionFile) | ||
| : false; |
There was a problem hiding this comment.
Persist CLI transcript before gating context injection
Fresh evidence in this revision is that runCliAgent now gates on sessionHasAssistantMessages(params.sessionFile), but the CLI execution path does not write assistant turns into that JSONL transcript (the append logic in commands/agent.ts is ACP-specific via persistAcpTurnTranscript, and non-delivery CLI runs do not mirror assistant output back to the transcript). In contextInjection: "first-message-only" sessions, this leaves hasExistingAssistantMessages false on follow-up CLI turns, so workspace bootstrap files keep being injected every turn instead of being skipped.
Useful? React with 👍 / 👎.
This comment was marked as spam.
This comment was marked as spam.
Sorry, something went wrong.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 245511d0ff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }): WorkspaceBootstrapFile[] { | ||
| const mode = params.contextInjection ?? "always"; | ||
| if (mode === "first-message-only" && params.hasExistingAssistantMessages) { | ||
| return params.files.filter((file) => file.name === DEFAULT_AGENTS_FILENAME); |
There was a problem hiding this comment.
Keep HEARTBEAT.md in first-message-only follow-up runs
In first-message-only mode, this filter keeps only AGENTS.md, but lightweight heartbeat runs intentionally reduce bootstrap files to HEARTBEAT.md first (applyContextModeFilter), so follow-up heartbeat polls end up with an empty injected context once any assistant history exists. This silently drops user heartbeat instructions for sessions that enable both heartbeat.lightContext and agents.defaults.contextInjection: "first-message-only", changing heartbeat behavior after the first assistant turn.
Useful? React with 👍 / 👎.
This comment was marked as spam.
This comment was marked as spam.
Sorry, something went wrong.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dfaffc277b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const hasExistingAssistantMessages = | ||
| contextInjection === "first-message-only" ? Boolean(cliSessionIdToUse?.trim()) : false; |
There was a problem hiding this comment.
Base first-message-only skip on verified CLI continuity
hasExistingAssistantMessages is currently inferred from Boolean(cliSessionIdToUse?.trim()), which treats any non-empty stored CLI session ID as proof of prior assistant turns. In this same function, the returned agentMeta.sessionId falls back to params.sessionId when the backend output has no parsed session ID, so backends that emit text/no session token can still persist a non-empty ID and trigger follow-up skips. With agents.defaults.contextInjection="first-message-only", those follow-up runs can drop bootstrap context even though the backend session is actually fresh (or unresumable), causing missing AGENTS/SOUL instructions on subsequent turns for affected CLI backends.
Useful? React with 👍 / 👎.
This comment was marked as spam.
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
This comment was marked as spam.
Sorry, something went wrong.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4c43df5ab4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const mode = params.contextInjection ?? "always"; | ||
| if (mode === "first-message-only" && params.hasExistingAssistantMessages) { | ||
| const agentsFiles = params.files.filter((file) => file.name === DEFAULT_AGENTS_FILENAME); | ||
| return agentsFiles.length > 0 ? agentsFiles : params.files; |
There was a problem hiding this comment.
Enforce first-message-only when AGENTS.md is filtered out
In first-message-only mode with existing assistant history, applyContextInjectionFilter falls back to returning the full bootstrap list when no AGENTS.md entry is present. Because bootstrap hooks can replace context.bootstrapFiles entirely, any hook that omits AGENTS.md (for example, to curate a custom subset) will cause all files to be injected again on follow-up turns, silently disabling the token/privacy behavior this mode is meant to provide.
Useful? React with 👍 / 👎.
This comment was marked as spam.
This comment was marked as spam.
Sorry, something went wrong.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5f33dd8996
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| agentMeta: { | ||
| sessionId: output.sessionId ?? params.sessionId ?? "", | ||
| sessionId: output.sessionId ?? "", |
There was a problem hiding this comment.
Clear expired CLI session id when retry yields no session token
After the session_expired retry path succeeds, agentMeta.sessionId is now set to output.sessionId ?? "", so backends that return text (or otherwise omit a parseable session id) report an empty id even though the run recovered. In updateSessionStoreAfterAgentRun (src/commands/agent/session-store.ts), empty ids are ignored, so the stale expired id remains in cliSessionIds and the next turn reuses it, repeatedly triggering the session_expired failure/retry cycle for those backends. This regression was introduced by removing the fallback id in the retry return path.
Useful? React with 👍 / 👎.
This comment was marked as spam.
This comment was marked as spam.
Sorry, something went wrong.
|
This is directly relevant to setups with large workspace files (SOUL.md, USER.md, TOOLS.md, AGENTS.md) — the per-message token overhead is real and adds up in longer sessions. Notes from reviewing: Positive:
One open question before merge: Overall: LGTM on the implementation. The 3.4M token / ~$1.51 savings per 100-message session is meaningful for production setups. |
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
This comment was marked as spam.
|
LGTM — the synthetic CLI session ID concern from our earlier review is resolved. Commit 4c43df5ab correctly limits persistence to real resumable session IDs only. Clean fix, no further issues from our end. Thanks for the quick turnaround @dsantoreis. |
This comment was marked as spam.
This comment was marked as spam.
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Workspace bootstrap guardrails can be skipped via contextInjection="first-message-only" (transcript-based)
DescriptionThe new Why this is a security problem (depending on deployment/threat model):
Vulnerable logic: if (mode === "first-message-only" && params.hasExistingAssistantMessages) {
return [];
}and transcript-based detection: const parsed = JSON.parse(line);
if (parsed?.message?.role === "assistant") {
return true;
}RecommendationTreat workspace bootstrap files as potentially security-relevant and avoid making their continued injection depend solely on transcript state. Recommended mitigations (choose one or combine):
Example (persist a trusted flag): // pseudo-code: write to session store after first successful run
sessionEntry.bootstrapInjectedOnce = true;
// later
if (contextInjection === "first-message-only" && sessionEntry.bootstrapInjectedOnce) {
skipBootstrap();
}2. 🔵 Uncaught exception from FileHandle.close() in finally can crash run (DoS)
DescriptionThe
Vulnerable code: } finally {
await handle?.close();
}RecommendationGuard } finally {
try {
await handle?.close();
} catch {
// optionally log at debug level
}
}Optionally, if you want to preserve the error for logging without crashing: } finally {
await handle?.close().catch((err) => {
warn?.(`session transcript close failed: ${String(err)}`);
});
}Analyzed PR: #40372 at commit Last updated on: 2026-03-13T09:40:41Z |
This comment was marked as spam.
This comment was marked as spam.
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Prompt-safety regression: optional config can skip workspace bootstrap policy files after first assistant message
DescriptionThe new Why this matters:
Vulnerable logic (skips all bootstrap context files): if (mode === "first-message-only" && params.hasExistingAssistantMessages) {
return [];
}Data-flow context:
Notes:
RecommendationDo not allow
Example: keep a minimal allowlist when skipping: const MIN_SECURITY_FILES = new Set(["AGENTS.md", "SOUL.md"] as const);
if (mode === "first-message-only" && hasExistingAssistantMessages) {
return params.files.filter(f => MIN_SECURITY_FILES.has(f.name));
}
return params.files;This preserves persistent owner constraints while still reducing token usage. 2. 🟡 Cost/availability DoS via truncated session head scan in sessionHasAssistantMessages
DescriptionThe Because Pi transcripts are JSONL with one JSON object per line, a single very large first user message line can push the first assistant message past the first 16KB chunk. In that case the function will:
When Vulnerable code (truncation + per-line JSON.parse over a partial chunk): const buf = Buffer.alloc(SESSION_HEAD_MAX_BYTES);
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
const chunk = buf.toString("utf-8", 0, bytesRead);
const lines = chunk.split(/\r?\n/).slice(0, SESSION_HEAD_MAX_LINES);
for (const line of lines) {
...
try {
const parsed = JSON.parse(line);
if (parsed?.message?.role === "assistant") {
return true;
}
} catch {
// skip malformed lines
}
}RecommendationMake assistant-history detection robust against large first lines / head truncation. Good options (pick one):
Example (tail scan): import fs from "node:fs/promises";
const TAIL_MAX_BYTES = 256 * 1024;
export async function sessionHasAssistantMessages(sessionFile: string): Promise<boolean> {
let handle: fs.FileHandle | undefined;
try {
handle = await fs.open(sessionFile, "r");
const stat = await handle.stat();
const start = Math.max(0, stat.size - TAIL_MAX_BYTES);
const len = stat.size - start;
const buf = Buffer.alloc(len);
const { bytesRead } = await handle.read(buf, 0, len, start);
const chunk = buf.toString("utf-8", 0, bytesRead);
const lines = chunk.split(/\r?\n/);
// If we started mid-file, drop the first possibly-partial line
if (start > 0) lines.shift();
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
if (parsed?.type === "message" && parsed?.message?.role === "assistant") return true;
} catch {
continue;
}
}
} catch {
return false;
} finally {
try { await handle?.close(); } catch { /* ignore */ }
}
return false;
}This avoids the “huge first line keeps assistant messages out of the scanned head” false negative and reduces the risk of token/cost amplification when Analyzed PR: #40372 at commit Last updated on: 2026-03-13T08:13:26Z |
This comment was marked as spam.
This comment was marked as spam.
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Context injection policy bypass via truncated head-only scan of session transcript
DescriptionThe new Because it only parses the file head, a user can cause persistent false negatives ("no assistant history") even when assistant messages exist, by ensuring the first assistant message is not visible within that head window:
Security/robustness impact when
Vulnerable code (head-only parsing + silent failure): const buf = Buffer.alloc(SESSION_HEAD_MAX_BYTES);
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
const chunk = buf.toString("utf-8", 0, bytesRead);
const lines = chunk.split(/\r?\n/).slice(0, SESSION_HEAD_MAX_LINES);
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed?.message?.role === "assistant") {
return true;
}
} catch {
// skip malformed lines
}
}Relevant locations:
RecommendationMake assistant-history detection robust against long/partial lines and large prefixes. Recommended approaches (pick one):
import fs from "node:fs";
import readline from "node:readline";
const MAX_LINES = 2000;
const MAX_TOTAL_BYTES = 2 * 1024 * 1024; // cap
const MAX_LINE_BYTES = 256 * 1024; // cap per record
export async function sessionHasAssistantMessages(sessionFile: string): Promise<boolean> {
const stream = fs.createReadStream(sessionFile, { encoding: "utf8" });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let lines = 0;
let totalBytes = 0;
try {
for await (const line of rl) {
lines++;
totalBytes += Buffer.byteLength(line, "utf8") + 1;
if (lines > MAX_LINES || totalBytes > MAX_TOTAL_BYTES) break;
if (Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) continue; // skip huge record but keep scanning
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
if (parsed?.type === "message" && parsed?.message?.role === "assistant") return true;
} catch {
// skip malformed line
}
}
} finally {
rl.close();
stream.destroy();
}
return false;
}
Also consider tightening the predicate to 2. 🔵 Extra bootstrap hook execution + bootstrap file loading when contextInjection is meant to skip context (metadata-only path)
DescriptionIn However, it then performs a second call to Impact:
Vulnerable code (2nd hook-triggering call in skipped mode): const bootstrapMetadataFiles =
contextInjection === "first-message-only" && hasExistingAssistantMessages
? await resolveBootstrapFilesForRun({
...,
contextInjection: "always",
hasExistingAssistantMessages: false,
})
: hookAdjustedBootstrapFiles;Relevant behavior:
This makes the metadata-only check a hook execution sink. RecommendationAvoid running the full bootstrap resolution + hook pipeline just to detect whether Mitigations (choose one):
import fs from "node:fs/promises";
import path from "node:path";
const hasBootstrap = await fs
.access(path.join(effectiveWorkspace, "BOOTSTRAP.md"))
.then(() => true)
.catch(() => false);
Also consider caching by Analyzed PR: #40372 at commit Last updated on: 2026-03-13T08:45:38Z |
Co-authored-by: Daniel <dsantoreis@gmail.com>
This comment was marked as spam.
This comment was marked as spam.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2648d3788d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (start > 0) { | ||
| lines.shift(); |
There was a problem hiding this comment.
Keep boundary line when scanning transcript tail
When the session file exceeds the 256KB tail window and start lands exactly on a JSONL line boundary, the unconditional lines.shift() drops a complete first record instead of only trimming a partial line. If that dropped record is the only assistant entry in the scanned region, sessionHasAssistantMessages incorrectly returns false, so contextInjection: "first-message-only" re-injects bootstrap files on follow-up turns and loses the expected token/privacy reduction.
Useful? React with 👍 / 👎.
This comment was marked as spam.
This comment was marked as spam.
Sorry, something went wrong.
Co-authored-by: Daniel <dsantoreis@gmail.com>
This comment was marked as spam.
This comment was marked as spam.
|
Closing this PR because the author has more than 10 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5cf4065e17
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const bootstrapFiles = applyContextInjectionFilter({ | ||
| files: modeFiltered, | ||
| contextInjection: params.contextInjection, | ||
| hasExistingAssistantMessages: params.hasExistingAssistantMessages ?? false, | ||
| }); |
There was a problem hiding this comment.
Keep bootstrap metadata when skipping file injection
In first-message-only mode with prior assistant history, this now replaces bootstrapFiles with the filtered (often empty) list before returning from resolveBootstrapFilesForRun. That means downstream consumers in runEmbeddedAttempt receive no bootstrap metadata at all on follow-up turns, so checks like workspaceNotes (AGENTS.md presence) and bootstrap reporting/truncation metadata are silently lost even though the workspace files still exist. Keep an unfiltered metadata list and apply context-injection filtering only to the list used for prompt contextFiles.
Useful? React with 👍 / 👎.
This comment was marked as spam.
This comment was marked as spam.
|
To use Codex here, create an environment for this repo. |
Summary
Adds
agents.defaults.contextInjectionconfig option that controls when workspace bootstrap files (AGENTS.md, SOUL.md, USER.md, TOOLS.md, etc.) are injected into the system prompt:"always"(default)"first-message-only"Over a 100-message conversation with
"first-message-only", this saves ~3.4M tokens and ~$1.51 per session. The agent can stillreadworkspace files on demand when needed.This is a v2 of #28072, rewritten against current main with all review feedback addressed:
sessionFileExistsproxy with actual transcript JSONL scanning for assistant role entries — pre-created, aborted, or empty sessions are handled correctlyConfiguration
{ "agents": { "defaults": { "contextInjection": "first-message-only" } } }What changed
src/agents/bootstrap-files.ts: addedsessionHasAssistantMessages()— reads first 16KB of session JSONL (sync, fast) checking for"role":"assistant"entries. AddedapplyContextInjectionFilter()that skips all context files when mode is"first-message-only"and assistant history exists. Wired intoresolveBootstrapFilesForRunandresolveBootstrapContextForRun.src/agents/pi-embedded-runner/run/attempt.ts: resolvescontextInjectionfrom config, callssessionHasAssistantMessagesonly when mode is"first-message-only"(zero overhead in default mode), passes both values to bootstrap resolution.src/agents/pi-embedded-runner/run/params.ts: addedcontextInjectiontoEmbeddedRunParams.src/config/zod-schema.agent-defaults.ts,types.agent-defaults.ts,schema.labels.ts,schema.help.ts: schema, type, label, and help text for the new option.What did NOT change
"always") is unchanged — no breaking changesworkspaceNotesdetection andsystemPromptReporteven when file content is skippedcontextInjectionis applied as a second-pass filter after the existingcontextModefilterLinked Issues
Validation
Tests
12 tests in
src/agents/bootstrap-files.context-injection.test.ts:applyContextInjectionFilter:
sessionHasAssistantMessages:
End-to-end resolveBootstrapContextForRun:
Impact
"first-message-only"is configuredAI-assisted (Claude). Fully tested and reviewed.