Skip to content

🔖 fix: Preserve Ephemeral Agent Params and Identity for ask_user_question Resume - #14254

Merged
danny-avila merged 7 commits into
LibreChat-AI:devfrom
jjhidalgar-celonis:fix/agents/ask-user-question-ephemeral-resume
Jul 14, 2026
Merged

danny-avila merged 7 commits into
LibreChat-AI:devfrom
jjhidalgar-celonis:fix/agents/ask-user-question-ephemeral-resume

Conversation

@jjhidalgar-celonis

@jjhidalgar-celonis jjhidalgar-celonis commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

ask_user_question (#14139) fails to resume on ephemeral agents (reported on a
custom endpoint with a modelSpec, but the mechanism applies to any provider
endpoint; persisted agents are unaffected since their id and params are DB-sourced).
Three independent bugs were diagnosed in #14253; Bug 3 (the RedisJobStore
disposed-graph crash) landed separately in #14258 with a Redis regression test, so
this PR fixes the remaining two, plus the wider parameter-fidelity gap behind Bug 1.
The fixes live in the shared resume helpers in packages/api (where
sanitizeResumeModelParameters / RESUME_CONTEXT_KEYS already own this logic), so
both persisted paths stay covered by unit tests:

  1. missing_model on answer — the resolved Anthropic thinking param is an
    object ({ type, budget_tokens }), but parseCompactConvo only accepts a
    boolean. Replaying it onto the resume body fails the compact-convo schema, which
    silently returns {} and drops model/spec, so the ephemeral agent rebuilds
    with no model. Fix: sanitizeResumeModelParameters normalizes the object back to
    the request-body form (thinking: boolean + thinkingBudget + thinkingDisplay)
    and lifts adaptive effort out of invocationKwargs.output_config.

  2. Silent no-op resume — the ephemeral agent id (LangGraph node name → HITL
    checkpoint namespace) embeds sender = model_parameters.modelLabel ?? modelSpec.label. modelLabel is stripped from the resolved llmConfig captured
    for resume, so resume rebuilds a different id, can't re-enter the interrupt
    checkpoint, and continues with an empty graph. Fix: modelLabel joins
    RESUME_CONTEXT_KEYS, so the original request's value is captured at pause and
    replayed on resume (the delete-absent half of applyResumeContext blocks a
    crafted resume from injecting one).

  3. Silent param resets on resume (same class as Bug 1) — replaying only the
    resolved llmConfig loses any param the provider resolution renames, relocates, or
    retypes: Anthropic maxOutputTokens → maxTokens and stop → stopSequences,
    the snake_case family on the OpenAI path (top_p, presence_penalty,
    max_tokens, reasoning_effort → reasoning), and maxContextTokens (never in
    the llmConfig at all) — all silently reset to defaults on resume. Fix:
    captureResumeModelParameters captures the paused request body's generation
    params as the primary source (UI-form by construction, so replay can't trip the
    schema); the resolved llmConfig only fills gaps, still sanitized/normalized. The
    replay key set derives from the compact-convo schemas' own shapes — exactly the
    fields that can influence the rebuilt run.

  4. reading 'slice' / contentData is not iterable — fixed in 👻 fix: Guard Redis Stream Resume Against Disposed HITL Graph #14258
    (readCachedGraph guard + Redis regression test), now merged into dev.

Fixes #14253
Related: #14139, #14258

Test plan

  • Unit tests: packages/api — policy.spec.ts covers the thinking
    normalization (object → boolean + budget + display), the effort lift, the
    modelLabel capture/replay/anti-injection, and captureResumeModelParameters
    (body-first precedence, gap-fill, identity-field exclusion, sanitization);
    load.spec.ts documents the Bug 2 mechanism (ephemeral id drifts when
    modelLabel is lost, stable when kept).
  • Manual: custom endpoint + modelSpec with askUserQuestion: true and a
    preset.modelLabel, using Claude Opus/Sonnet 4+. Trigger ask_user_question,
    tap an option:
    • no missing_model error,
    • the agent continues the conversation from the answer,
    • generation params (max output tokens, stop sequences, effort) match the
      paused turn.
  • Regression: normal (non-HITL) agent turns and persisted-agent resume unaffected.

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review

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

ℹ️ 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".

Comment thread api/server/routes/agents/chat.js Outdated
Comment on lines +73 to +74
if (typeof req.body.thinking !== 'boolean') {
delete req.body.thinking;

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 Preserve disabled thinking when replaying resume params

When the paused run used an Anthropic model where the resolved config stores thinking as { type: 'disabled' } (for example Sonnet 5 with thinking turned off), this branch deletes that object before buildEndpointOption reruns. The Anthropic config path documents that omitted thinking defaults back to adaptive/on for these models and only preserves the off state by seeing the disabled object (packages/api/src/endpoints/anthropic/helpers.ts:103-110, packages/api/src/endpoints/anthropic/llm.ts:149-157), so the resumed segment can continue with thinking enabled instead of the paused settings. Convert disabled objects to false (and preserve display/other user choices) rather than dropping every non-boolean object.

Useful? React with 👍 / 👎.

Comment thread api/server/routes/agents/chat.js Outdated
// the rebuilt id matches the paused one. No-op when the spec has no preset.modelLabel
// (both turns then already agree on modelSpec.label).
const spec = req.body?.spec;
if (spec && req.body.modelLabel == null) {

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 Overwrite client modelLabel during resume replay

When the resume payload still includes modelLabel—including an empty string from client defaults or a stale label—this guard skips the server-side replay and leaves that value authoritative. For custom-endpoint modelSpec ephemeral agents, loadEphemeralAgent uses model_parameters.modelLabel ?? modelSpec.label to form the ephemeral id/checkpoint namespace, while the resume fingerprint does not include modelLabel; a stale/empty label therefore rebuilds a different id and the ask_user_question/tool checkpoint still cannot be re-entered. The replay should overwrite or clear client-supplied modelLabel, not only fill null/undefined.

Useful? React with 👍 / 👎.

@danny-avila

Copy link
Copy Markdown
Collaborator

Thanks @jjhidalgar-celonis, great diagnosis on all three bugs in #14253. Heads up: I'm about to push to this branch (maintainer edits) to reconcile it with what already landed:

  • Bug 3 (RedisJobStore null-deref) was fixed in 👻 fix: Guard Redis Stream Resume Against Disposed HITL Graph #14258 with a Redis regression test, so I'll drop the duplicated hunk here and rebase on dev (also retargeting the PR base to dev per repo convention).
  • Bugs 1 and 2 will move into the shared resume helpers in packages/api (where sanitizeResumeModelParameters / RESUME_CONTEXT_KEYS live) with unit tests, keeping this PR as the vehicle so it lands with your authorship.

More shortly.

@danny-avila
danny-avila changed the base branch from main to dev July 14, 2026 17:33
@danny-avila

Copy link
Copy Markdown
Collaborator

Pushed (9fd0a4d) and retargeted to dev:

  • Merged dev (brings in the 👻 fix: Guard Redis Stream Resume Against Disposed HITL Graph #14258 readCachedGraph guard) and dropped the duplicated RedisJobStore.ts hunk, keeping your original commit in history.
  • Moved Bug 1 into sanitizeResumeModelParameters (packages/api/src/agents/hitl/policy.ts): the resolved object thinking is normalized back to thinking: boolean + thinkingBudget at capture time, so the replay round-trips parseCompactConvo and the budget survives (rather than deleting the field at replay time).
  • Moved Bug 2 into RESUME_CONTEXT_KEYS: the original request's top-level modelLabel is captured at pause and replayed on resume. This also covers the no-spec case (a user-set modelLabel without a modelSpec), and the delete-absent half of applyResumeContext prevents a crafted resume from injecting one.
  • Added unit tests: policy.spec.ts (thinking normalization + modelLabel capture/replay) and load.spec.ts (ephemeral id drifts when modelLabel is lost, stable when kept).

tsc, eslint, and the specs (50 passing) are green locally. Updated the PR description to match.

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review

@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: 9fd0a4d58c

ℹ️ 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".

type?: unknown;
budget_tokens?: unknown;
};
params.thinking = type !== 'disabled';

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 Preserve thinking display when normalizing

When resuming Anthropic adaptive-thinking runs where the resolved config is thinking: { type: 'adaptive', display: 'omitted' }, this collapses the object to thinking: true without copying display to thinkingDisplay. The Anthropic initializer explicitly relies on either options.modelOptions.thinkingDisplay or the persisted object’s .display to avoid demoting an explicit user choice back to the default (packages/api/src/endpoints/anthropic/llm.ts lines 133-170), so after this replay path the resumed run can switch Opus 4.7+/Sonnet 5+ from omitted thoughts to the default summarized display.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Valid — fixed in aa1301e. normalizeThinkingParam now copies the adaptive object's display to top-level thinkingDisplay (when not already set), so an explicit omitted choice survives the resume round-trip instead of demoting to the Opus 4.7+ default. Covered by a new test in policy.spec.ts.

}
return sanitizeParamValue(params, 0) as Record<string, unknown>;
const sanitized = sanitizeParamValue(params, 0) as Record<string, unknown>;
normalizeThinkingParam(sanitized);

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 Replay adaptive effort in UI form

For native Anthropic adaptive-thinking runs with a non-default effort, the resolved llmConfig stores that choice as invocationKwargs.output_config.effort (configureReasoning writes it there), while the resume parser only accepts a top-level effort field. This sanitizer now makes those runs parseable by converting the thinking object, but it returns invocationKwargs unchanged; restoreResumeContext assigns it to req.body and parseCompactConvo drops it, so the resumed turn silently falls back to the default effort instead of the paused run’s setting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Valid — fixed in aa1301e. New normalizeEffortParam lifts invocationKwargs.output_config.effort back to the top-level effort field the request-body schema accepts (existing top-level value wins), and drops invocationKwargs from the replayed params since the compact-convo schema would discard it anyway. Covered by a new test in policy.spec.ts.

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: aa1301ed24

ℹ️ 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".

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review

@danny-avila danny-avila changed the title fix: durable ask_user_question resume for custom-endpoint ephemeral agents 🔖 fix: Preserve Ephemeral Agent Identity for Custom-Endpoint ask_user_question Resume Jul 14, 2026
@danny-avila danny-avila changed the title 🔖 fix: Preserve Ephemeral Agent Identity for Custom-Endpoint ask_user_question Resume 🔖 fix: Preserve Ephemeral Agent Identity for Custom-Endpoint ask_user_question Resume Jul 14, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: d5c31b78ad

ℹ️ 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".

@danny-avila

Copy link
Copy Markdown
Collaborator

Pushed 0e0c4de, closing out a broader gap in the same mechanism: replaying only the RESOLVED llmConfig loses any param the provider resolution renames, relocates, or retypes. The thinking object was just the one case that crashed the parse; audit found silent losses too — Anthropic maxOutputTokens → maxTokens and stop → stopSequences, the whole snake_case family on the OpenAI path (top_p, presence_penalty, max_tokens, reasoning_effort → reasoning), and maxContextTokens (never in the llmConfig at all) — all silently reset to defaults on resume.

Fix: captureResumeModelParameters captures the paused REQUEST BODY's generation params as the primary source — they're UI-form by construction (they already round-tripped parseCompactConvo on the original turn), so replaying them can't trip the schema. The resolved llmConfig only fills gaps, still sanitized and normalized. The replay key set is derived from the compact-convo schemas' own shapes, which is exactly the set of fields that can influence the rebuilt run (buildOptions derives model_parameters from the parsed body). Unit tests cover precedence, gap-fill, identity-field exclusion, and sanitization.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 0e0c4dec8a

ℹ️ 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".

@danny-avila danny-avila changed the title 🔖 fix: Preserve Ephemeral Agent Identity for Custom-Endpoint ask_user_question Resume 🔖 fix: Preserve Ephemeral Agent Params and Identity for ask_user_question Resume Jul 14, 2026
@danny-avila
danny-avila merged commit e813934 into LibreChat-AI:dev Jul 14, 2026
8 checks passed
fuuuzzy pushed a commit to fuuuzzy/LibreChat that referenced this pull request Aug 7, 2026
…tion Resume (LibreChat-AI#14254)

* fix: durable ask_user_question resume for ephemeral agents

* 🤖 refactor: Drop chat.js resume hunks in favor of shared packages/api helpers

* 🤖 fix: Normalize resume thinking param and replay modelLabel (LibreChat-AI#14253 Bugs 1&2)

* 🤖 fix: Preserve adaptive thinking display and effort across HITL resume

* 🔤 style: Sort load.spec.ts imports (repo import-order)

* 🤖 fix: Replay paused request body params on HITL resume (UI-form source of truth)

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
LogicalAbsurd pushed a commit to LogicalAbsurd/LibreChat that referenced this pull request Aug 27, 2026
…tion Resume (LibreChat-AI#14254)

* fix: durable ask_user_question resume for ephemeral agents

* 🤖 refactor: Drop chat.js resume hunks in favor of shared packages/api helpers

* 🤖 fix: Normalize resume thinking param and replay modelLabel (LibreChat-AI#14253 Bugs 1&2)

* 🤖 fix: Preserve adaptive thinking display and effort across HITL resume

* 🔤 style: Sort load.spec.ts imports (repo import-order)

* 🤖 fix: Replay paused request body params on HITL resume (UI-form source of truth)

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants