Skip to content

perf: add contextInjection option to skip workspace re-injection (#9157)#46813

Closed
anup00900 wants to merge 1 commit into
openclaw:mainfrom
anup00900:perf/skip-workspace-reinjection-9157
Closed

perf: add contextInjection option to skip workspace re-injection (#9157)#46813
anup00900 wants to merge 1 commit into
openclaw:mainfrom
anup00900:perf/skip-workspace-reinjection-9157

Conversation

@anup00900

Copy link
Copy Markdown

Summary

Fixes #9157 — workspace files (AGENTS.md, SOUL.md, USER.md, etc.) are re-injected into the system prompt on every single message, wasting ~35,600 tokens per turn (~93.5% of the token budget).

The fix: Add a contextInjection config option under agents.defaults:

{
  "agents": {
    "defaults": {
      "contextInjection": "first-message-only"
    }
  }
}
  • "always" (default): current behavior — zero breaking changes
  • "first-message-only": skip workspace file injection when the session file already exists

Measured impact (from issue reporter)

Message Without fix With fix
#1 8,260 tokens (cache write) 8,260 tokens (same)
#2 8,260 tokens (re-injected) 1,488 tokens (new content only)
#3+ 8,260 tokens (re-injected) ~1,500 tokens (cached)

~93% token reduction over a conversation. ~$1.51 saved per 100-message session.

Changes

  • src/agents/pi-embedded-runner/run/attempt.ts: Check session file existence + config before calling resolveBootstrapContextForRun
  • src/config/types.agent-defaults.ts: Add contextInjection type
  • src/config/zod-schema.agent-defaults.ts: Add Zod validation

Test plan

  • All existing tests pass (6 pre-existing failures in attempt.spawn-workspace.test.ts — same on main)
  • Lint and format clean
  • Default "always" preserves current behavior — zero breaking changes
  • Manual: Set contextInjection: "first-message-only" → verify workspace files injected on msg 1, skipped on msg 2+
  • Manual: Verify agent can still read workspace files on subsequent messages

Related

…nclaw#9157)

Workspace files (AGENTS.md, SOUL.md, USER.md, etc.) are re-injected
into the system prompt on every message, wasting ~35,600 tokens per
turn (~93.5% of the token budget in multi-message conversations).

Add a contextInjection config option under agents.defaults:
- "always" (default): current behavior, inject on every message
- "first-message-only": inject only when the session file does not
  yet exist, reducing token costs by ~93% for subsequent messages

The agent retains full workspace context from message openclaw#1 and can use
the read tool to re-check files if needed on later messages.
@anup00900

Copy link
Copy Markdown
Author

@vincentkoc @steipete — this addresses the highly-requested #9157 (12 thumbs-up). Adds an opt-in contextInjection: "first-message-only" config that skips re-injecting workspace files on every message, cutting token costs by ~93%. Default is "always" so zero breaking changes. Supersedes the closed #28072. Happy to squash commits.

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: XS labels Mar 15, 2026
@greptile-apps

greptile-apps Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a contextInjection config option to agents.defaults that lets users opt into skipping workspace context file re-injection (AGENTS.md, SOUL.md, USER.md, etc.) on every turn after the first, yielding a reported ~93% token reduction for long conversations. The implementation is minimal and backwards-compatible — "always" remains the default. The type definition and Zod schema are clean and consistent with the rest of the codebase.

Two things worth addressing before merging:

  • Duplicate fs.stat call (attempt.ts lines 1430–1435): The new stat check is repeated almost identically ~300 lines later as hadSessionFile. Moving the bootstrap-context resolution to after hadSessionFile is computed and reusing that variable would eliminate one unnecessary I/O operation and keep the "first message?" logic in a single place.

  • Undocumented post-compaction behaviour (attempt.ts lines 1436–1446, types.agent-defaults.ts line 146): When compaction runs and clears the conversation history, the session file still exists, so workspace files will never be re-injected for that session even after compaction. The existing compaction.postCompactionSections only re-injects named AGENTS.md sections, not the full bootstrap context. The JSDoc on contextInjection should call this tradeoff out explicitly so operators know to configure postCompactionSections when they enable this option.

Confidence Score: 4/5

  • Safe to merge with minor documentation and code-quality improvements recommended
  • The core logic is sound, default behaviour is unchanged, and the type/schema additions are consistent with the codebase. The two identified issues (duplicate stat call, undocumented post-compaction behaviour) are style/documentation concerns rather than correctness bugs for the common case.
  • attempt.ts — duplicate fs.stat call and undocumented compaction interaction
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 1430-1435

Comment:
**Duplicate `fs.stat` call**

The `fs.stat(params.sessionFile)` call introduced here is duplicated approximately 300 lines later (line 1731–1734) where `hadSessionFile` is computed with the exact same logic:

```typescript
const hadSessionFile = await fs
  .stat(params.sessionFile)
  .then(() => true)
  .catch(() => false);
```

Additionally, `repairSessionFileIfNeeded` runs between the two stat calls (line 1727), which could theoretically produce inconsistent results if the repair alters the file's existence.

Consider moving the bootstrap context resolution to after `hadSessionFile` is computed, and reusing it:

```typescript
const skipContextInjection =
  contextInjection === "first-message-only" && hadSessionFile;
```

This eliminates one unnecessary filesystem I/O operation and keeps the "did a session file already exist?" logic in one place.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 1436-1446

Comment:
**No re-injection after compaction**

When context compaction occurs, the session file still exists on disk, so `skipContextInjection` will remain `true` for every subsequent `runEmbeddedAttempt` call — including the first one after a compaction event. Because compaction replaces or summarises the message history (including the initial bootstrap system prompt content), workspace files like `SOUL.md` and `USER.md` that were only injected on the very first message of the session may no longer be present in the model's effective context after compaction.

The existing `compaction.postCompactionSections` mechanism re-injects named sections from `AGENTS.md`, but it does not cover the full bootstrap context that `resolveBootstrapContextForRun` would have provided.

Consider documenting this tradeoff explicitly in the `contextInjection` JSDoc, e.g.:

```typescript
/**
 * ...
 * Note: with "first-message-only", workspace context is NOT re-injected after
 * context compaction. Use compaction.postCompactionSections to explicitly
 * preserve critical sections from AGENTS.md across compaction boundaries.
 */
contextInjection?: "always" | "first-message-only";
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: e0e0733

Comment on lines +1430 to +1435
const skipContextInjection =
contextInjection === "first-message-only" &&
(await fs
.stat(params.sessionFile)
.then(() => true)
.catch(() => false));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate fs.stat call

The fs.stat(params.sessionFile) call introduced here is duplicated approximately 300 lines later (line 1731–1734) where hadSessionFile is computed with the exact same logic:

const hadSessionFile = await fs
  .stat(params.sessionFile)
  .then(() => true)
  .catch(() => false);

Additionally, repairSessionFileIfNeeded runs between the two stat calls (line 1727), which could theoretically produce inconsistent results if the repair alters the file's existence.

Consider moving the bootstrap context resolution to after hadSessionFile is computed, and reusing it:

const skipContextInjection =
  contextInjection === "first-message-only" && hadSessionFile;

This eliminates one unnecessary filesystem I/O operation and keeps the "did a session file already exist?" logic in one place.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 1430-1435

Comment:
**Duplicate `fs.stat` call**

The `fs.stat(params.sessionFile)` call introduced here is duplicated approximately 300 lines later (line 1731–1734) where `hadSessionFile` is computed with the exact same logic:

```typescript
const hadSessionFile = await fs
  .stat(params.sessionFile)
  .then(() => true)
  .catch(() => false);
```

Additionally, `repairSessionFileIfNeeded` runs between the two stat calls (line 1727), which could theoretically produce inconsistent results if the repair alters the file's existence.

Consider moving the bootstrap context resolution to after `hadSessionFile` is computed, and reusing it:

```typescript
const skipContextInjection =
  contextInjection === "first-message-only" && hadSessionFile;
```

This eliminates one unnecessary filesystem I/O operation and keeps the "did a session file already exist?" logic in one place.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +1436 to +1446
const { bootstrapFiles: hookAdjustedBootstrapFiles, contextFiles } = skipContextInjection
? { 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,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No re-injection after compaction

When context compaction occurs, the session file still exists on disk, so skipContextInjection will remain true for every subsequent runEmbeddedAttempt call — including the first one after a compaction event. Because compaction replaces or summarises the message history (including the initial bootstrap system prompt content), workspace files like SOUL.md and USER.md that were only injected on the very first message of the session may no longer be present in the model's effective context after compaction.

The existing compaction.postCompactionSections mechanism re-injects named sections from AGENTS.md, but it does not cover the full bootstrap context that resolveBootstrapContextForRun would have provided.

Consider documenting this tradeoff explicitly in the contextInjection JSDoc, e.g.:

/**
 * ...
 * Note: with "first-message-only", workspace context is NOT re-injected after
 * context compaction. Use compaction.postCompactionSections to explicitly
 * preserve critical sections from AGENTS.md across compaction boundaries.
 */
contextInjection?: "always" | "first-message-only";
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 1436-1446

Comment:
**No re-injection after compaction**

When context compaction occurs, the session file still exists on disk, so `skipContextInjection` will remain `true` for every subsequent `runEmbeddedAttempt` call — including the first one after a compaction event. Because compaction replaces or summarises the message history (including the initial bootstrap system prompt content), workspace files like `SOUL.md` and `USER.md` that were only injected on the very first message of the session may no longer be present in the model's effective context after compaction.

The existing `compaction.postCompactionSections` mechanism re-injects named sections from `AGENTS.md`, but it does not cover the full bootstrap context that `resolveBootstrapContextForRun` would have provided.

Consider documenting this tradeoff explicitly in the `contextInjection` JSDoc, e.g.:

```typescript
/**
 * ...
 * Note: with "first-message-only", workspace context is NOT re-injected after
 * context compaction. Use compaction.postCompactionSections to explicitly
 * preserve critical sections from AGENTS.md across compaction boundaries.
 */
contextInjection?: "always" | "first-message-only";
```

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0e0733a77

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1431 to +1435
contextInjection === "first-message-only" &&
(await fs
.stat(params.sessionFile)
.then(() => true)
.catch(() => false));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use assistant history to detect first turn before skipping context

This gate treats sessionFile existence as “not first message”, but later in the same flow prepareSessionManagerForRun explicitly handles pre-created/header-only transcripts as an initial run state. In that scenario (contextInjection: "first-message-only" + existing file without assistant turns), bootstrap files are skipped even though this is still the first real model turn, so AGENTS/SOUL/USER context is never injected for that session start.

Useful? React with 👍 / 👎.

Comment on lines +1436 to +1438
const { bootstrapFiles: hookAdjustedBootstrapFiles, contextFiles } = skipContextInjection
? { bootstrapFiles: [], contextFiles: [] }
: await resolveBootstrapContextForRun({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep heartbeat context resolution when first-message-only is set

Returning { bootstrapFiles: [], contextFiles: [] } here bypasses resolveBootstrapContextForRun entirely, which also bypasses run-kind-specific behavior. Heartbeat runs set bootstrapContextRunKind: "heartbeat" (see src/auto-reply/reply/agent-runner-execution.ts) and rely on applyContextModeFilter to inject HEARTBEAT.md each run (src/agents/bootstrap-files.ts); with this short-circuit, subsequent heartbeat turns lose that context whenever the session file already exists.

Useful? React with 👍 / 👎.

@vincentkoc

Copy link
Copy Markdown
Member

closing this one out.

this idea is obsolete now: main already has a landed contextInjection implementation in the agent runtime/config surfaces, so keeping an older parallel PR open just creates review noise.

if there is still a missing edge case here, it should come back as a narrowly scoped follow-up against current main, not as the older pre-landing version.

@vincentkoc vincentkoc closed this Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance: Workspace file injection wastes 93.5% of token budget

2 participants