Skip to content

fix(agents): preserve original task prompt on model fallback for new sessions#55632

Merged
obviyus merged 5 commits intoopenclaw:mainfrom
openperf:fix/55581-subagent-fallback-prompt
Mar 29, 2026
Merged

fix(agents): preserve original task prompt on model fallback for new sessions#55632
obviyus merged 5 commits intoopenclaw:mainfrom
openperf:fix/55581-subagent-fallback-prompt

Conversation

@openperf
Copy link
Copy Markdown
Contributor

Summary

Fixes #55581 — Preserves the original task prompt during model fallback for new sessions (like subagent spawns) instead of replacing it with a generic recovery message.

Root Cause

  1. When a model fallback occurs, resolveFallbackRetryPrompt replaces the original task prompt with "Continue where you left off. The previous model attempt failed or timed out." to prevent duplicate user messages in the session history.
  2. However, for new sessions (such as those created by sessions_spawn for subagents), the SessionManager only flushes the initial user message to the transcript file after the first assistant response arrives.
  3. If the primary model fails early (e.g., due to an authentication error or prompt rejection), the original user message is never persisted to the session history.
  4. Consequently, the fallback model receives only the generic recovery message without any prior context, causing the subagent to lose its original task entirely.

Changes

src/agents/command/attempt-execution.ts

  • Added a sessionHasHistory parameter to resolveFallbackRetryPrompt and runAgentAttempt.
  • Updated resolveFallbackRetryPrompt to return the original body instead of the recovery message when sessionHasHistory is false, ensuring the fallback model receives the actual task.

src/agents/agent-command.ts

  • Passed !isNewSession as the sessionHasHistory argument when calling runAgentAttempt within the fallback loop.

src/agents/command/attempt-execution.test.ts

  • Added unit tests for resolveFallbackRetryPrompt to verify prompt preservation logic for both new and existing sessions across initial attempts and fallback retries.

Test Results

  • Unit tests added and passing for resolveFallbackRetryPrompt.
  • Verified that fallback retries on new sessions correctly preserve the original prompt.

@openclaw-barnacle openclaw-barnacle bot added agents Agent runtime and tooling size: S labels Mar 27, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR fixes a real bug where subagent (new-session) fallback retries would receive only a generic recovery prompt — "Continue where you left off. The previous model attempt failed or timed out." — instead of the original task, causing the subagent to silently lose its entire task context. The root cause is that SessionManager defers flushing the initial user message to disk until the first assistant response arrives, so early primary-model failures (auth errors, prompt rejections) leave no persisted history for the fallback model to build on.

Key changes:

  • resolveFallbackRetryPrompt gains an optional sessionHasHistory flag; when false (or omitted), the function returns the original body on fallback retries instead of the recovery message.
  • runAgentAttempt passes sessionHasHistory through to resolveFallbackRetryPrompt.
  • agentCommandInternal computes sessionHasHistory: !isNewSession once before the fallback loop and supplies it on every invocation.
  • A new unit-test file covers all four cases: initial attempt (no-op), fallback with history (recovery message), fallback without history (original body), and the undefined defensive default (original body).

The logic is clean and the tests are thorough. One minor edge case worth keeping in mind: isNewSession is determined before the fallback loop starts, so it doesn't update if the primary model completes at least one full assistant-response turn on a new session (which would have flushed the user message to disk). In that scenario the fallback would re-inject the original task, producing a duplicate user message. In practice this matters only for mid-turn failures on brand-new sessions, which are outside the stated scope (auth/rejection errors that fail before any output), but it is worth a follow-up if fallback triggers are ever broadened.

Confidence Score: 4/5

Safe to merge; the fix is correct for all described failure modes and unit-tested thoroughly, with a narrow theoretical edge case noted.

The change is minimal, targeted, and backed by comprehensive unit tests. The single P2 concern (static isNewSession flag not reflecting post-attempt transcript state) is outside the stated scope of the fix and unlikely to manifest in practice today. No regressions for existing sessions.

No files require special attention; the edge-case note on agent-command.ts:788 is informational for future work.

Important Files Changed

Filename Overview
src/agents/agent-command.ts Adds sessionHasHistory: !isNewSession when invoking runAgentAttempt inside the fallback loop. Minimal, correct for described early-failure scenarios, with a narrow edge case for new sessions where the primary model completes at least one response turn.
src/agents/command/attempt-execution.ts Adds optional sessionHasHistory param to resolveFallbackRetryPrompt and runAgentAttempt; returns original body when history is absent on fallback retry. Logic is clean and well-commented.
src/agents/command/attempt-execution.test.ts New test file with comprehensive unit coverage for resolveFallbackRetryPrompt across all relevant combinations of isFallbackRetry and sessionHasHistory.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/agent-command.ts
Line: 788

Comment:
**`isNewSession` doesn't reflect whether the primary attempt wrote history**

`isNewSession` is resolved once before the fallback loop starts and never updated. The fix correctly handles the case where the primary model fails immediately (auth error, prompt rejection) before any history is flushed. However, if the primary model completes at least one full assistant-response turn — which, per the PR description, is when `SessionManager` flushes the initial user message to disk — the session will actually have history in the transcript even though `isNewSession` is still `true`.

In that edge case, passing `sessionHasHistory: false` causes the fallback to re-send the original body, producing a duplicated user message in the transcript: `[user: task] [assistant: partial work] [user: task again]`. The recovery prompt (`"Continue where you left off…"`) would be more appropriate there.

For the primary target scenarios (auth errors, prompt rejections) this is fine since both fail before any output is written. But if the scope of fallback retries ever widens to include mid-turn failures on new sessions, a more precise check — e.g. inspecting the on-disk transcript after the primary attempt — would be safer than relying on the pre-run `isNewSession` flag.

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

Reviews (1): Last reviewed commit: "fix(agents): preserve original task prom..." | Re-trigger Greptile

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 27, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Unbounded JSONL line parsing can cause memory/CPU DoS in sessionFileHasContent()
2 🟡 Medium Unclosed readline/stream in sessionFileHasContent() can leak resources on early return
Vulnerabilities

1. 🟡 Unbounded JSONL line parsing can cause memory/CPU DoS in sessionFileHasContent()

Property Value
Severity Medium
CWE CWE-400
Location src/agents/command/attempt-execution.ts:61-74

Description

sessionFileHasContent() scans a session transcript JSONL file using readline and JSON.parse on each line. While it caps the number of records inspected (SESSION_FILE_MAX_RECORDS = 500), it does not cap the size of an individual line.

Impacts:

  • readline buffers an entire line before yielding it; a single extremely large JSONL record (e.g., a huge user prompt embedded in the transcript) can allocate large memory.
  • JSON.parse(line) on a very large string can spike CPU and memory.
  • This function is called on agent execution paths (agent-command.ts) to decide fallback prompt behavior; if an attacker can influence transcript contents (e.g., by submitting very large prompts that are written to the transcript), they may be able to trigger resource exhaustion.

Vulnerable code:

const rl = readline.createInterface({ input: fh.createReadStream({ encoding: "utf-8" }) });
for await (const line of rl) {
  ...
  obj = JSON.parse(line);
}

Recommendation

Add hard limits on data processed by bytes, not just record count.

Suggested mitigations (combine as appropriate):

  1. Reject oversized files up-front:
const stat = await fs.stat(sessionFile);
if (stat.size > 2 * 1024 * 1024) return false; // choose an appropriate cap
  1. Enforce a maximum line length before attempting JSON.parse:
const MAX_LINE_CHARS = 64 * 1024;
for await (const line of rl) {
  if (line.length > MAX_LINE_CHARS) return false; // or break/continue
  ...
  const obj = JSON.parse(line);
}
  1. Optionally, limit total bytes read by wrapping the stream and aborting after a threshold, or use a streaming JSON parser if large records are expected.

These guards prevent a single malicious/accidental huge JSONL record from consuming excessive memory/CPU during fallback checks.


2. 🟡 Unclosed readline/stream in sessionFileHasContent() can leak resources on early return

Property Value
Severity Medium
CWE CWE-772
Location src/agents/command/attempt-execution.ts:59-88

Description

The sessionFileHasContent() helper creates a readline.Interface over a FileHandle stream and returns early when it finds an assistant message, but it never explicitly closes the readline interface or destroys the underlying ReadStream.

Because return true exits the for await (const line of rl) loop immediately, the finally block only closes the file handle (fh.close()), leaving the readline interface and stream to be cleaned up implicitly. This can cause:

  • dangling event listeners / active handles in the event loop
  • errors due to closing the FileHandle while the stream is still reading
  • file descriptor churn or gradual handle leakage if this function is called frequently (availability impact)

Vulnerable code:

const rl = readline.createInterface({ input: fh.createReadStream({ encoding: "utf-8" }) });
...
if ( ... assistant ... ) {
  return true;
}
...
} finally {
  await fh.close();
}

Recommendation

Ensure the readline interface and its underlying stream are explicitly cleaned up in all paths (including early return and record-limit break).

One safe pattern is to keep references to the stream and close/destroy them in a finally that wraps the loop:

const fh = await fs.open(sessionFile, "r");
let rl: readline.Interface | undefined;
let stream: NodeJS.ReadableStream | undefined;
try {
  stream = fh.createReadStream({ encoding: "utf-8" });
  rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

  for await (const line of rl) {
    ...
    if (isAssistant) {
      return true;
    }
  }
  return false;
} finally {
  rl?.close();// @​ts-expect-error: destroy exists on ReadStream
  (stream as any)?.destroy?.();
  await fh.close();
}

This prevents leftover active handles and avoids closing the file handle while the stream is still in use.


Analyzed PR: #55632 at commit e772995

Last updated on: 2026-03-27T13:35:04Z

@openperf openperf force-pushed the fix/55581-subagent-fallback-prompt branch from 2759bad to e730597 Compare March 27, 2026 08:35
openperf added a commit to openperf/moltbot that referenced this pull request Mar 27, 2026
Fixes three medium-severity security issues identified by Aisle Security Analysis on PR openclaw#55632:
- CWE-400: Unbounded session transcript read in sessionFileHasContent()
- CWE-400: Symlink-following in sessionFileHasContent()
- CWE-201: Sensitive prompt replay to a different fallback provider
@openperf
Copy link
Copy Markdown
Contributor Author

All four security issues flagged by Aisle have been addressed across the last two commits:

  1. sessionFileHasContent() no longer slurps the whole file — switched to line-by-line JSONL parsing via readline, capped at 500 records. This fixes both the memory DoS vector (CWE-400) and the false-negative problem where a large user message could push the assistant record past the old 256KB byte cutoff, blocking fallback entirely (CWE-703).
  2. Added an lstat guard before opening the transcript — symlinks are rejected outright, closing the arbitrary-file-read path (CWE-400).
  3. When falling back to a different provider with no persisted session history, we now throw a FailoverError instead of silently forwarding the original prompt to a third party (CWE-201).

The original fix for #55581 is untouched — same-provider fallback still works exactly as before. Tests covering all cases have been added, including one with a 300KB+ user message to verify the JSONL parser finds the assistant record regardless of content size.

@openperf openperf force-pushed the fix/55581-subagent-fallback-prompt branch 2 times, most recently from e772995 to 0229484 Compare March 27, 2026 14:20
@obviyus
Copy link
Copy Markdown
Contributor

obviyus commented Mar 29, 2026

Pushed a small follow-up to the PR branch.

In simple terms: the real rule here is whether the session already has saved history. If it does not, the fallback still needs the original task prompt. That should stay true even when the fallback switches providers.

So the follow-up removes the cross-provider special case and keeps the fix focused on transcript history only.

Verified with the targeted test, pnpm tsgo, and pnpm check.

@obviyus obviyus self-assigned this Mar 29, 2026
openperf and others added 5 commits March 29, 2026 18:32
…llback retry

Address Greptile review feedback: replace the static !isNewSession flag
with a dynamic sessionFileHasContent() check that reads the on-disk
transcript before each fallback retry. This correctly handles the edge
case where the primary model completes at least one assistant-response
turn (flushing the user message to disk) before failing - the fallback
now sends the recovery prompt instead of duplicating the original body.

The !isNewSession short-circuit is kept as a fast path so existing
sessions skip the file read entirely.
Fixes three medium-severity security issues identified by Aisle Security Analysis on PR openclaw#55632:
- CWE-400: Unbounded session transcript read in sessionFileHasContent()
- CWE-400: Symlink-following in sessionFileHasContent()
- CWE-201: Sensitive prompt replay to a different fallback provider
Replace bounded byte-prefix substring matching in sessionFileHasContent()
with line-by-line JSONL record parsing. The previous approach could miss
an assistant message when the preceding user content exceeded the 256KB
read limit, causing a false negative that blocks cross-provider fallback
entirely.
@obviyus obviyus force-pushed the fix/55581-subagent-fallback-prompt branch from 6b0f325 to f62b574 Compare March 29, 2026 13:04
Copy link
Copy Markdown
Contributor

@obviyus obviyus left a comment

Choose a reason for hiding this comment

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

Reviewed latest changes; landing now.

@obviyus obviyus merged commit 2c8c4e4 into openclaw:main Mar 29, 2026
8 checks passed
@obviyus
Copy link
Copy Markdown
Contributor

obviyus commented Mar 29, 2026

Landed on main.

Thanks @openperf.

alexjiang1 pushed a commit to alexjiang1/openclaw that referenced this pull request Mar 31, 2026
…nclaw#55632) (thanks @openperf)

* fix(agents): preserve original task prompt on model fallback for new sessions

* fix(agents): use dynamic transcript check for sessionHasHistory on fallback retry

Address Greptile review feedback: replace the static !isNewSession flag
with a dynamic sessionFileHasContent() check that reads the on-disk
transcript before each fallback retry. This correctly handles the edge
case where the primary model completes at least one assistant-response
turn (flushing the user message to disk) before failing - the fallback
now sends the recovery prompt instead of duplicating the original body.

The !isNewSession short-circuit is kept as a fast path so existing
sessions skip the file read entirely.

* fix(agents): address security vulnerabilities in session fallback logic

Fixes three medium-severity security issues identified by Aisle Security Analysis on PR openclaw#55632:
- CWE-400: Unbounded session transcript read in sessionFileHasContent()
- CWE-400: Symlink-following in sessionFileHasContent()
- CWE-201: Sensitive prompt replay to a different fallback provider

* fix(agents): use JSONL parsing for session history detection (CWE-703)

Replace bounded byte-prefix substring matching in sessionFileHasContent()
with line-by-line JSONL record parsing. The previous approach could miss
an assistant message when the preceding user content exceeded the 256KB
read limit, causing a false negative that blocks cross-provider fallback
entirely.

* fix(agents): preserve fallback prompt across providers

---------

Co-authored-by: Ayaan Zaidi <[email protected]>
livingghost pushed a commit to livingghost/openclaw that referenced this pull request Mar 31, 2026
…nclaw#55632) (thanks @openperf)

* fix(agents): preserve original task prompt on model fallback for new sessions

* fix(agents): use dynamic transcript check for sessionHasHistory on fallback retry

Address Greptile review feedback: replace the static !isNewSession flag
with a dynamic sessionFileHasContent() check that reads the on-disk
transcript before each fallback retry. This correctly handles the edge
case where the primary model completes at least one assistant-response
turn (flushing the user message to disk) before failing - the fallback
now sends the recovery prompt instead of duplicating the original body.

The !isNewSession short-circuit is kept as a fast path so existing
sessions skip the file read entirely.

* fix(agents): address security vulnerabilities in session fallback logic

Fixes three medium-severity security issues identified by Aisle Security Analysis on PR openclaw#55632:
- CWE-400: Unbounded session transcript read in sessionFileHasContent()
- CWE-400: Symlink-following in sessionFileHasContent()
- CWE-201: Sensitive prompt replay to a different fallback provider

* fix(agents): use JSONL parsing for session history detection (CWE-703)

Replace bounded byte-prefix substring matching in sessionFileHasContent()
with line-by-line JSONL record parsing. The previous approach could miss
an assistant message when the preceding user content exceeded the 256KB
read limit, causing a false negative that blocks cross-provider fallback
entirely.

* fix(agents): preserve fallback prompt across providers

---------

Co-authored-by: Ayaan Zaidi <[email protected]>
pgondhi987 pushed a commit to pgondhi987/openclaw that referenced this pull request Mar 31, 2026
…nclaw#55632) (thanks @openperf)

* fix(agents): preserve original task prompt on model fallback for new sessions

* fix(agents): use dynamic transcript check for sessionHasHistory on fallback retry

Address Greptile review feedback: replace the static !isNewSession flag
with a dynamic sessionFileHasContent() check that reads the on-disk
transcript before each fallback retry. This correctly handles the edge
case where the primary model completes at least one assistant-response
turn (flushing the user message to disk) before failing - the fallback
now sends the recovery prompt instead of duplicating the original body.

The !isNewSession short-circuit is kept as a fast path so existing
sessions skip the file read entirely.

* fix(agents): address security vulnerabilities in session fallback logic

Fixes three medium-severity security issues identified by Aisle Security Analysis on PR openclaw#55632:
- CWE-400: Unbounded session transcript read in sessionFileHasContent()
- CWE-400: Symlink-following in sessionFileHasContent()
- CWE-201: Sensitive prompt replay to a different fallback provider

* fix(agents): use JSONL parsing for session history detection (CWE-703)

Replace bounded byte-prefix substring matching in sessionFileHasContent()
with line-by-line JSONL record parsing. The previous approach could miss
an assistant message when the preceding user content exceeded the 256KB
read limit, causing a false negative that blocks cross-provider fallback
entirely.

* fix(agents): preserve fallback prompt across providers

---------

Co-authored-by: Ayaan Zaidi <[email protected]>
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: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Subagent loses original task when model fallback triggers, receives "Continue where you left off" instead

2 participants