Skip to content

🙋 feat: ask_user_question - agent-initiated questions with durable pause/resume - #14139

Merged
danny-avila merged 37 commits into
devfrom
feat/ask-user-question-tool
Jul 8, 2026
Merged

danny-avila merged 37 commits into
devfrom
feat/ask-user-question-tool

Conversation

@danny-avila

Copy link
Copy Markdown
Collaborator

Summary

The HITL runtime merged in #13942 / #14024 / #14025 / #14123 already ships the entire ask_user_question lifecycle — payload-agnostic handleRunInterrupt, resume validation (mapAskUserAnswer), reconnect/cross-replica rehydration, and a production-ready client question card (AskUserQuestion.tsx: option buttons + free-text + expiry states). What was missing: nothing ever raised the interrupt.

This PR ships the producer: an LLM-callable ask_user_question tool whose body calls the SDK's askUserQuestion() helper (a LangGraph interrupt()), so an agent can pause mid-run, ask the user a structured question, and resume with the answer as the tool result.

⚠️ Blocked on SDK release

Requires @librechat/agents > 3.2.57 — agents#289 adds the host-suppliable AgentInputs.graphTools seam this PR wires. Draft until that merges + releases; then this bumps the dep, updates the lockfile, and un-drafts. Until the bump, the new event-driven e2e will fail CI (it passes locally against the packed #289 build); with 3.2.57 at runtime the feature is inert (tool absent), never broken.

Why graphTools (the load-bearing design point)

The agents endpoint loads tools definitions-only, which flips the SDK ToolNode to event-driven dispatch — and the host ON_TOOL_EXECUTE handler executes tool bodies outside the Pregel task frame (under runOutsideTracing), where interrupt() throws Called interrupt() outside the context of a graph and becomes an error ToolMessage. So the ask tool never rides toolDefinitions/toolRegistry: on HITL-capable top-level agents a real instance is supplied via graphTools — bound to the model, executed in-process on the direct path, where the interrupt genuinely pauses the run. Everywhere else (OpenAI-compat/Responses controllers, subagent children, admin-disabled) it is stripped fail-closed.

What's in here

Tool + registration (tools-dialog plugin pattern)

  • packages/api/src/agents/hitl/askUserQuestionTool.ts — factory + zod schema with length caps (question ≤2000, description ≤4000, ≤12 options) mirroring the SDK's AskUserQuestionRequest; JSON-schema twin for the registry; description mandates ONE question per turn, never in parallel (LangGraph re-runs the batch on resume)
  • manifest entry (+ icon) → agent builder Tools dialog; agentToolDefinitions; basicToolInstances; handleTools constructor branch
  • New agentsOnly manifest flag: scoped out of the Assistants tools dialog and the legacy plugins endpoint — those runtimes execute tools with no run to pause

Run gating (packages/api/src/agents/run.ts)

  • Checkpointer now attaches for hitlCapable runs whose agents carry the ask tool even with toolApproval disabled (the interrupt needs only durability — verified e2e; humanInTheLoop/hooks stay off)
  • Admin filteredTools/includedTools enforced at run build (strip + no checkpointer): a real kill switch for already-saved agents, not just dialog hiding
  • Excluded from eager event execution (an interrupt outside the task frame can't pause)

Orphan-checkpoint hygiene (new failure mode: ask pause abandoned via job replacement or Stop, with approval policy off — nothing pruned the interrupted checkpoint, so later turns silently rehydrated duplicated context)

  • Pre-turn prune gate widened: isHITLEnabled(toolApproval) || agents.some(agentRequestsAskUserQuestion)
  • Abort-route prune fires when the aborted job carries a pendingAction

Hardening

  • 16k answer-length cap at the resume validation site
  • SDK-side (Possibility of access to plugin? #289): buildChildInputs clears graphTools so self-spawned subagents (which shallow-spread the parent's inputs) can never inherit an interrupt-capable tool into a checkpointer-less child graph

Tests

  • askUserQuestion.e2e.spec.js (real Run + FakeChatModel + LazyMongoSaver + supertest resume): traditional mode, event-driven production shape (toolDefinitions + graphTools), and sequential re-pause — pause → durable checkpoint → HTTP answer → rebuilt-run resume → tool body re-runs once → answer becomes the tool result → checkpoint pruned
  • run-summarization.test.ts: 13 gating cases (checkpointer decoupling, fail-closed strips incl. subagent configs, clone-before-mutate, admin filter/allowlist precedence, eager exclusion, approval-policy composition)
  • askUserQuestionTool.spec.ts (schema caps/contract), PluginController.spec.js (agentsOnly scoping), resume.spec.js (length cap)

Review provenance

Built with a spike-first plan (interrupt-from-tool-body verified before any wiring), then a 12-agent adversarial review (4 finders → per-finding refutation) before opening: 8 findings, 5 confirmed (2 critical), all fixed in the second commit — including the critical one where the initial design was dead on the production path because the e2e's instance-mode harness masked event-driven dispatch.

Copilot AI review requested due to automatic review settings July 6, 2026 19:38

Copilot AI left a comment

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.

Pull request overview

This PR introduces an LLM-callable ask_user_question tool that can pause an agents run via a LangGraph interrupt() raised from the tool body, enabling durable pause/resume and structured question/answer collection mid-run.

Changes:

  • Adds the ask_user_question tool (schema + JSON-schema definition) and registers it in the tool registry and tools manifest with agentsOnly scoping.
  • Updates agents run construction to strip the tool from event-dispatched surfaces and instead provide it via the in-graph graphTools seam, while attaching a checkpointer when ask is available (even if tool-approval policy is disabled).
  • Adds resume hardening (16k answer cap), orphan-checkpoint cleanup gating, and comprehensive unit/e2e coverage for pause → resume → re-entry behavior.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/api/src/tools/registry/definitions.ts Registers ask_user_question in the schema-only tool registry.
packages/api/src/agents/run.ts Strips ask tool from event-dispatched surfaces, supplies it via graphTools, and attaches checkpointer when ask-capable.
packages/api/src/agents/hitl/index.ts Re-exports ask tool module from HITL exports.
packages/api/src/agents/hitl/askUserQuestionTool.ts Implements tool name/description, zod schema + JSON-schema twin, and tool factory calling askUserQuestion().
packages/api/src/agents/hitl/askUserQuestionTool.spec.ts Unit tests validating schema/definition contract and cap alignment.
packages/api/src/agents/tests/run-summarization.test.ts Tests run wiring/gating behavior for ask tool across HITL/non-HITL, admin filters, and subagents.
client/public/assets/ask-user-question.svg Adds icon asset for the tool in the UI manifest.
api/server/services/start/tools.js Adds ask tool to the globally cached tool definitions alongside other basic tool instances.
api/server/routes/agents/index.js Ensures abort-path checkpoint pruning also triggers for ask-only pauses via pendingAction metadata.
api/server/controllers/PluginController.spec.js Adds tests for agentsOnly filtering on legacy plugins endpoint and Assistants tools listing.
api/server/controllers/PluginController.js Filters agentsOnly tools out of legacy plugins endpoint and out of Assistants tools listing by route.
api/server/controllers/agents/resume.js Adds a 16k length cap for ask answers before mapping into resume value.
api/server/controllers/agents/client.js Expands orphan-checkpoint prune gate to include ask-capable runs, not only approval-enabled runs.
api/server/controllers/agents/tests/resume.spec.js Tests 400 response when ask answer exceeds the new max length.
api/server/controllers/agents/tests/askUserQuestion.e2e.spec.js Full-wiring e2e for ask pause/resume in both legacy and event-driven (production-shape) modes.
api/app/clients/tools/util/handleTools.js Adds ask tool to the generic tool loader for requested tools.
api/app/clients/tools/manifest.json Adds Ask User tool manifest entry with agentsOnly: true.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 88 to 93
const basicToolInstances = [
new Calculator(),
createAskUserQuestionTool(),
...Object.values(oaiToolkit),
...Object.values(geminiToolkit),
];
@danny-avila
danny-avila marked this pull request as ready for review July 7, 2026 10:51
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@danny-avila danny-avila changed the title 🙋 feat: ask_user_question — agent-initiated questions with durable pause/resume 🙋 feat: ask_user_question - agent-initiated questions with durable pause/resume Jul 7, 2026

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

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

* versions ignore it at runtime (the tool is then simply absent, never
* broken). Inline the field in the literal once the dependency is bumped.
*/
(agentInput as AgentInputs & { graphTools?: GenericTool[] }).graphTools = askGraphTools;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bump agents before relying on graphTools

In a clean install from this repo, package-lock.json still resolves @librechat/agents to 3.2.57, but this branch strips ask_user_question out of both tools and toolDefinitions and only re-adds it through graphTools. The comment here says graphTools ships in > 3.2.57 and older SDKs ignore it, so npm ci deployments will silently remove the tool from the model surface: agents can attach Ask User in the UI, but the run never binds it and cannot pause. Please bump the agents dependency/lockfile or keep a compatible binding path with this change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved by 8b87c8ce8 (danny's bump): both api/package.json and packages/api/package.json now pin @librechat/agents ^3.2.58 with the lockfile updated — the published 3.2.58 carries the AgentInputs.graphTools support (agents#289). Verified locally against the published package: the event-driven e2e passes (98 api + 291 packages/api tests green), and CI is re-running on the bumped head.


const basicToolInstances = [
new Calculator(),
createAskUserQuestionTool(),

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 Reject Ask User before assistant creation

Although PluginController hides agentsOnly tools from /assistants/tools, this line adds ask_user_question to the shared cached tool map. The v1/v2 assistant create/update handlers resolve arbitrary posted tool strings from getCachedTools, so a REST client or stale saved payload can still attach ask_user_question to an Assistant; when OpenAI later requests that function, processRequiredActions loads and executes it outside the LangGraph run frame where interrupt() cannot pause, yielding a tool error. Keep it out of the shared assistant cache or add a server-side reject in the assistant controllers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c760e37e6 — new manifest-driven isAgentsOnlyTool(tool) (handles both the string pluginKey and OpenAI function-object shapes) drops agents-only tools with a warn at all four assistant resolution sites: v1 + v2, create + update. The tool stays in the shared cache (the agents Tools dialog listing depends on it), but an assistants payload can no longer attach it. Spec pins the helper against the real manifest entry.

Comment on lines +1171 to +1172
if (hitlCapable && !isSubagent && !askToolAdminDisabled) {
askGraphTools = [createAskUserQuestionTool() as unknown as GenericTool];

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 Strip Ask User from self-spawn configs

When an agent has subagents enabled with the default self-spawn target, buildSubagentConfigs creates a self: true config that reuses this top-level agentInput for an isolated child run. Because this branch only checks !isSubagent, the parent input still gets graphTools: [ask_user_question], so the self-delegated child can invoke the tool even though the surrounding code intentionally strips it from explicit child configs because subagent runs have no usable pause/resume surface. In that self-delegation scenario, an ask call will fail to surface a question rather than pausing the parent run; the self config needs the same stripping or should be disabled for this tool.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved by the bump to 3.2.58 (8b87c8ce8): this was fixed SDK-side in agents#289 — buildChildInputs scrubs graphTools from self-spawn configs, whose agentInputs are the shallow spread of the parent's _sourceInputs (graphTools: config.self === true ? void 0 : agentInputs.graphTools, verified present in the published dist at SubagentExecutor.cjs:803, with an SDK test covering the self-spawn shape). Explicit child configs are stripped host-side in buildAgentInput (isSubagent gate) as before — so no child graph, self-spawned or explicit, ever sees the tool. The finding was correct for the reviewed commit (lockfile still at 3.2.57, where the scrub didn't exist).

@danny-avila
danny-avila force-pushed the feat/ask-user-question-tool branch from d0303c9 to 3459ca6 Compare July 7, 2026 11:06
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: c760e37e6e

ℹ️ 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 Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 6e6580d735

ℹ️ 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 Author

@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: 082ac6fe4b

ℹ️ 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 on lines +259 to +263
if (askActive && liveAsk) {
const text = data.text.trim();
if (text.length > 0) {
submitAskAnswer(liveAsk.actionId, text);
methods.reset();

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 Enable composer submission while ask pause is active

In the ask pause state the SSE handler keeps isSubmitting true after ON_PENDING_ACTION, so ChatForm continues to show the Stop button instead of the Send button and useTextarea returns early on Enter while submitting. That means this new composer onSubmit path is effectively unreachable for free-form answers typed into the “Something else...” composer; only option buttons/inline cards can submit. Please special-case askActive in the submit controls/Enter handling so the composer answer can actually reach submitAskAnswer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8e40ad2be — resolved by redesign rather than special-casing the submit lock: free-form input moved INTO the popover as an inline 'Other' row (select-then-confirm: rows highlight, Submit ↵ / Enter / double-click fires, Skip dismisses). The composer is a plain composer again — no placeholder swap, no onSubmit interception — so Stop keeps its meaning and the unreachable path no longer exists.

toolRegistry.delete(ASK_USER_QUESTION_TOOL_NAME);
}
if (hitlCapable && !isSubagent && !askToolAdminDisabled) {
askGraphTools = [createAskUserQuestionTool() as unknown as GenericTool];

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 Bypass approval for ask_user_question

When toolApproval.enabled is true without an allow/bypass override, buildHITLRunWiring installs a PreToolUse policy that prompts for every tool. This branch adds ask_user_question as an in-graph tool but leaves it subject to the same approval hooks, so deployments that enable tool approval will first show a tool-approval card for the ask tool itself and only after approval fire the actual ask-user interrupt. Exempt ASK_USER_QUESTION_TOOL_NAME from the approval policy when adding it as graphTools.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8e40ad2be — exemptAskUserQuestionFromApproval adds the tool to the policy's allow list before buildHITLRunWiring unless the admin explicitly mentioned it in allow/ask/deny (explicit entries win in either direction). Unit tests cover the three cases.

Comment on lines +622 to +626
if (pendingAction.payload?.type === 'ask_user_question') {
seedContent = attachAskUserQuestionAnswer(
seedContent,
pendingAction.payload.question,
req.body.answer,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Preserve unanswered ask questions in history

This stamps the lost question args only on the successful answer-resume path, but the pause path also saves an unfinished response and abort/expiry paths can leave that row in history without ever calling attachAskUserQuestionAnswer. Since the comment notes the aggregator dropped the streamed args and no completion event fires, a stopped or expired ask_user_question turn persists with empty args, so AskUserQuestionCall can only render the generic “Asked a question” text instead of the actual question. Stamp the question args when saving/terminalizing unanswered ask pauses as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8e40ad2be — attachAskUserQuestionArgs stamps the pendingAction's question onto the paused ask tool_call's args in handleRunInterrupt (pause time), so abandoned/expired/stopped turns persist the question and the record card renders it; the answer-resume stamp still adds the output. Targets the newest ask part with empty args and no output; pure, spec'd.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@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: 8e40ad2bea

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

// and the streamed args were dropped (name-less chunks) — without this the
// unfinished turn saves an empty ask part the record card can't render.
if (interrupt.payload?.type === 'ask_user_question' && Array.isArray(this.contentParts)) {
const stamped = attachAskUserQuestionArgs(this.contentParts, interrupt.payload.question);

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 Persist ask args for Redis-backed stopped pauses

When Redis job storage is enabled and an ask_user_question pause is stopped or abandoned before /resume, this only patches this.contentParts. The abort save path reads jobStore.getContentParts() (packages/api/src/stream/GenerationJobManager.ts:796), while Redis ignores setContentParts and reconstructs from the graph/chunk log (packages/api/src/stream/implementations/RedisJobStore.ts:860, :900-919), so the saved unfinished message still has the empty args this block is trying to avoid. Please patch the persisted/chunk-derived content, or patch the abort content from pendingAction, so Redis deployments keep the question in the durable card.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8c7eaec81 — the abort route re-stamps the question onto the ask tool_call in the reconstructed abort content (attachAskUserQuestionArgs on abortResult.content when the aborted job's pendingAction is an ask), covering the Redis path where getContentParts rebuilds from the graph/chunk log and never saw the in-process stamp.

Comment on lines +14 to +16
const askAnswerSelectionAtom = atom<number | 'other' | null>({
key: 'askAnswerModeSelection',
default: 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 Reset ask selection when the pending action changes

Because the selected row and Other text live in global atoms, they survive across different ask_user_question actions. If a user selects an option or types an Other answer for one paused question, then switches to another paused question or dismisses and later gets a new one, canSubmit can already be true for the new action and Enter in the empty composer submits the stale choice; key this state by actionId or reset it whenever liveAsk.actionId changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8c7eaec81 — selection resets whenever the live actionId changes, and the stale-able inline-Other state ('other' selection + text atom) is gone entirely: the composer owns free-form now, so the only cross-action state left is the per-action dismissal list.

Comment thread packages/api/src/agents/hitl/resume.ts Outdated
Comment on lines +160 to +162
runStep != null && typeof runStep.index === 'number'
? { ...runStep, index: runStep.index + offset }
: data;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve indices for resumed approval tool calls

This offset is applied to every resumed run, including ordinary tool_approval pauses. In that flow the seeded content already contains the paused tool_call at its original index, and the resumed tool step/completion needs to land on that same slot to fill in the output; shifting the ON_RUN_STEP index sends the completion to a new slot after the seed instead, leaving the original approved tool call unresolved and rendering/saving a duplicate completed one. Please avoid applying the seed offset to the resumed tool-call step that corresponds to an existing seeded tool call, and only offset the post-tool continuation content.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8c7eaec81 — but with a rebind rather than skipping the offset (which would regress the probe-verified text-loss bug that affects approval resumes too): resumed tool_calls steps whose tool_call id matches a seeded UNRESOLVED part now rebind to that seeded slot, so the original part resolves in place (output attaches) and no duplicate appears; message steps keep the offset. Resolved seeded calls are not rebind targets. Covered by a dedicated spec (rebind / resolved-not-target / message-steps-offset).

Comment on lines +47 to +48
const answerLabel =
question?.options?.find((option) => option.value === effectiveOutput)?.label ?? effectiveOutput;

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 Guard malformed ask options before calling find

When a saved or streamed ask_user_question tool call has malformed args where options is present but not an array, this optional chain still tries to call .find on that value and throws while rendering the message. parseAskUserQuestionArgs only checks that question is a string, so malformed model/persisted args such as {"question":"q","options":{}} crash the conversation instead of falling back to the generic label.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8c7eaec81 — parseAskUserQuestionArgs normalizes untrusted shapes instead of passing them through: non-array options are dropped, entries without string label/value are filtered, non-string descriptions are ignored. Spec covers {\"question\":\"q\",\"options\":{}}.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

… pause/resume

The HITL runtime merged in #13942/#14024/#14025/#14123 already ships the full
ask_user_question lifecycle (payload-agnostic handleRunInterrupt, resume
validation via mapAskUserAnswer, reconnect rehydration, and the client question
card) — but nothing ever raised the interrupt. This adds the producer:

- packages/api/agents/hitl/askUserQuestionTool.ts: LLM-callable tool whose func
  calls the SDK askUserQuestion() helper (LangGraph interrupt() from the tool
  body); zod schema with length caps mirroring AskUserQuestionRequest, plus a
  JSON-schema twin for the schema-only registry
- Registration: agentToolDefinitions, manifest.json (Tools dialog, admin
  filteredTools/includedTools kill switch), basicToolInstances, handleTools
  constructor branch
- run.ts gating: checkpointer now attaches for hitlCapable runs whose agents
  carry the ask tool even with the tool-approval policy disabled (the interrupt
  needs only durability, not humanInTheLoop/hooks); the tool is stripped
  fail-closed from non-HITL callers (OpenAI-compat/Responses) and subagent
  child configs; excluded from eager event execution (interrupts must be
  raised inside the Pregel task frame)
- resume.js: 16k length cap on the answer wire field
- e2e (real Run + FakeChatModel + LazyMongoSaver + supertest resume): tool-body
  interrupt pauses durably with NO approval policy, answer round-trips as the
  ToolMessage content, tool body re-runs once on resume, sequential questions
  re-pause
… endpoint scoping, real kill switch

Pre-PR multi-agent review confirmed 5 defects in the initial commit; all fixed:

1. CRITICAL — the tool never paused on the real agents endpoint: production
   loads tools definitions-only, flipping the SDK ToolNode to event-driven
   dispatch, and the host ON_TOOL_EXECUTE handler runs outside the Pregel task
   frame (under runOutsideTracing), where interrupt() throws and becomes an
   error ToolMessage. Reworked: the ask tool never rides toolDefinitions/
   toolRegistry — on HITL-capable top-level agents a real instance is supplied
   via AgentInputs.graphTools (agents#289, requires @librechat/agents > 3.2.57),
   the SDK's in-graph direct-tool seam; new production-shape e2e pins the
   event-driven mode end to end.
2. CRITICAL — ask-only runs left orphaned interrupted checkpoints (silent
   context duplication on every later turn): both orphan prunes were gated on
   toolApproval.enabled. The pre-turn prune now also fires for ask-capable
   agents (exported agentRequestsAskUserQuestion), and the abort-route prune
   fires when the aborted job carries a pendingAction.
3. MAJOR — self-spawned subagents bypassed the strip (self config resolves from
   the parent's _sourceInputs): fixed SDK-side (buildChildInputs clears
   graphTools) and the tool is now never present on child surfaces host-side.
4. MINOR — the manifest entry leaked into the Assistants tools dialog and the
   legacy plugins endpoint, where tools execute with no run to pause: new
   agentsOnly manifest flag, scoped out of both listings.
5. MINOR — filteredTools/includedTools only hid the tool from the dialog:
   now enforced at run build (strip + no checkpointer), making the admin
   filter a real kill switch for already-saved agents.
The tools-dialog scoping keeps ask_user_question out of the assistants
LISTING, but the v1/v2 create/update handlers resolve arbitrary posted tool
strings from the shared getCachedTools map — a REST client or stale saved
payload could still attach it, and the assistants runtime executes tools with
no run to pause, so every call would error. New isAgentsOnlyTool(tool)
(manifest-driven, handles string and function-object shapes) drops such tools
with a warn at all four resolution sites (v1+v2, create+update).
A resumed run rebuilds the graph from the checkpoint, and the fresh graph
numbers content indices from its own empty contentData — starting at 0. The
resume path seeds the (also fresh) content aggregator with the pre-pause
parts at exactly those indices, so the resumed model turn collided with the
seed: type-matching parts silently MERGED (post-resume text appended into a
pre-pause text block), and type-mismatching parts (a reasoning/think part at
index 0 — any Anthropic reasoning agent) dropped EVERY delta with 'Content
type mismatch', losing the entire post-resume output from the live stream
and the saved message.

Latent since #13942 — tool-approval resumes corrupt content the same way
(probe-verified); it surfaced now because ask_user_question makes pausing a
first-class flow and reasoning models make the loss total.

- createContentIndexOffsetHandlers(handlers, offset): wraps ON_RUN_STEP
  (the single point where a content index enters the pipeline — deltas
  resolve through the aggregator's stepMap) and ON_AGENT_UPDATE's inline
  index; every other handler passes through by reference. Probe-validated:
  resumed output now lands as a new part after the paused tool call.
- resumeCompletion wires it with offset = seedContent.length.
- logToolError: a GraphInterrupt unwinding out of a tool body is the HITL
  pause working as designed — no longer logged as a Tool Error.
With resume indices now ABSOLUTE (server continues after the pre-pause
parts), the synthetic ask-user-question card was squatting on exactly the
index the resumed segment streams into: applyAskUserQuestion appends the
card at the end of the message content, so on the answering device every
incoming part at that index was blocked and nothing rendered between the
answer submission and the finalize replacing the message.

removeAskUserQuestionPart(message, actionId) strips the pause-scoped card
on successful answer submission (useResumeSubmit onSuccess) — the durable
record of the Q&A is the ask_user_question tool call itself. Pure helper +
specs; same-reference no-op when nothing matches.
…iter

The store-level strip on answer submit wasn't enough: the SSE step handler
keeps its own in-flight copy of the streaming message, so on the answering
device the synthetic ask-user-question card still occupied the ABSOLUTE index
the resumed segment streams into — every delta warned 'Content type mismatch'
(existing ask_user_question vs incoming text) and nothing rendered between the
pending_action and finalize.

Displace the card inside updateContent when any real part claims its slot —
the same displacement pattern as the OAuth prompt part directly above it.
Covers the streaming handler's own copy, reconnecting tabs, and other devices;
once real content streams, the pause is over by definition. Spec drives a
runStep + text delta into the card's index and pins: no mismatch warn, card
gone, text rendered.
The completed ask call rendered as a generic tool card labeled 'Cancelled'
with raw (and empty) JSON args. Two layers fixed:

Data: the saved tool_call part had args:'' and no output — streamed arg
chunks carry no tool name so the aggregator drops them (normal tools recover
via the completion event, which never fires for a tool that interrupts
mid-execution and resumes on a rebuilt run with no step id). The resume
controller now stamps the paused ask part with the pendingAction's
authoritative question as args and the user's answer as output
(attachAskUserQuestionAnswer — pure, targets the newest unanswered ask part,
so sequential questions each keep their own answer).

UI: Part.tsx routes ask_user_question tool calls to AskUserQuestionCall — a
compact Q&A record ('Asked a question' header, question, description, 'You
answered: <label>' preferring the picked option's label, or 'No answer was
given' for an abandoned pause) instead of the generic card. New i18n keys;
parseAskUserQuestionArgs degrades to null on malformed model args.
Two live-turn issues with the new durable Q&A card:

1. Duplicate question on ask: during a live pause the message carries BOTH the
   ask tool_call part (now rendered by AskUserQuestionCall, showing a
   misleading 'No answer was given' while paused) and the synthetic
   interactive card. The durable card now defers while the turn is live and
   unanswered (isSubmitting) — the interactive card owns the question UI until
   it's answered; an abandoned pause still shows its no-answer state once the
   turn settles.

2. 'No answer was given' after answering: the server stamps the answer onto
   the part at resume seed, but the client only received that at finalize. No
   stream emission needed — the client knows the answer it just submitted:
   resolveAskUserQuestionPart (replacing the plain strip on submit success)
   removes the synthetic card AND stamps output/progress onto the newest
   unanswered ask tool_call, seeding args from the synthetic part's question
   when the streamed args were lost — mirroring the server-side
   attachAskUserQuestionAnswer, so the Q&A record shows the answer the moment
   the user submits.
The optimistic output stamp lives in the message store, but the SSE step
handler evolves its own cached copy of the streaming message (created at turn
start) — the first resumed event overwrites the store with that copy, wiping
the stamp, so the Q&A card blinked out during streaming and only returned at
finalize.

Render-layer fallback instead of fighting the handler's copy: submitted
answers are recorded by ask tool_call id when resolveAskUserQuestionPart
stamps the part, and AskUserQuestionCall reads the recorded answer whenever
the part's own output is missing — the record survives any message-copy churn
until finalize delivers the server-stamped part.
It ships with the app and pauses the run like a first-class feature, so it
belongs with the builtins (Run Code, Web Search, Memory, ...) rather than in
the third-party plugin list — while its mechanics stay exactly a plugin's:

- BuiltinId += 'ask_user_question' (documented exception: a native TOOL, not
  a capability; selection reads agent.tools, the toggle emits tool-add/remove
  patches instead of a capability field)
- buildCatalog surfaces it as a builtin gated on the same signals as before
  (tools capability on + the server lists the plugin, i.e. not admin-filtered)
  and skips it in the plugin loop so it never double-lists
- On-theme icon: lucide MessageCircleQuestion in a teal chip via the builtin
  icon map, matching the other native entries; the bespoke purple SVG and the
  manifest icon field are gone
- i18n'd name/description keys like the other builtins
F7 (composer status lock): the ask submit status lived on ApprovalContext,
a React context mounted only around message content (ContentParts). The
PRIMARY answer surface — the composer in ChatForm — renders outside it, so
useApprovalContext returned the inert FALLBACK: status was always 'idle',
setStatus a no-op. The in-flight double-submit guard (round 4) and the
expired-exits-answer-mode fix (round 5) therefore never engaged for the
composer. Move ask submit status to a global Recoil atom (useAskSubmitStatus)
read/written by the composer, the popover, and the card alike, so a fast
double-click/Enter is actually blocked and expired/error surfaces on every
surface. Tool-approval status stays on the context (unchanged).

F5 (abort stamp before emit): the abort route re-stamped a paused
ask_user_question's args AFTER GenerationJobManager.abortJob had already
emitted the final SSE from the unstamped content, so a Redis/cross-replica
Stop left the live client showing an empty question until reload. abortJob
now takes an optional transformAbortContent applied to the persistable
content BEFORE the final event is built (and returned), so the live client
and the saved message agree. New abort.spec case + updated call assertions.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Add a first-class AgentCapabilities.ask_user_question (in defaultAgentCapabilities,
on by default) so admins can enable/disable questions independently via
endpoints.agents.capabilities, exactly like execute_code / web_search — not
lumped under the generic tools capability.

- ToolService: both filteredTools predicates (definitions-only and instance
  loaders) gate ask_user_question on checkCapability(ask_user_question) before
  the generic tools fallthrough. When off, the tool is dropped from
  toolDefinitions/toolRegistry, so run.ts's agentRequestsAskUserQuestion (which
  keys on the loaded surface) declines to install it and attach a checkpointer —
  the capability is enforced end-to-end at the loader, no run.ts change needed.
- Tools dialog catalog: surface the ask builtin under its own capability rather
  than the generic tools one, so the UI matches the backend gate.
- Tests: ToolService capability on/off filtering + defaults membership; catalog
  builtin visibility keyed on the dedicated capability.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@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: 5c9f7ac789

ℹ️ 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 on lines +52 to +53
if (popoverVisible && isLivePause) {
return 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 Show ask-answer errors while the popover is open

When an ask answer submission fails (for example the server rejects an over-16k answer or the network request fails), useResumeSubmit sets the ask status to error, but this early return hides the only component that renders com_ui_approval_error whenever the popover is visible. The popover itself does not render the status, so users answering from the composer/popover get no visible feedback or retry guidance unless they manually collapse/dismiss it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4e083da. Exposed an errored flag from useAskAnswerMode (status === 'error') and the popover now renders a warning line (com_ui_ask_answer_error — "Your answer couldn't be sent. Try again.") when a submission fails. This is the deliberate counterpart to the round-5 expired handling: expired exits answer mode (dead question → card shows it), but error stays active and retryable, so instead of closing the popover it surfaces the error inline for retry guidance — the message clears automatically when the user retries (status flips to submitting). The error state is also in the Playwright matrix I'll build once the review converges.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved in 4e083da. The popover now renders an error banner (TriangleAlert + com_ui_ask_answer_error, "Your answer could not be sent. Try again.") when the submit status is error — the failed submission keeps the question answerable (controls stay enabled, error is retryable), so the popover stays up and surfaces the error inline for retry guidance, rather than the user being stranded with no feedback. Verified: i18n key present, client tsc + eslint clean.

A failed answer submission (16k reject, network error) sets the ask status to
'error', which — unlike 'expired' — deliberately keeps the question active and
retryable. But the chat card that renders the error message is suppressed while
the popover is open, so a composer/popover answer failed silently. Expose an
'errored' flag from useAskAnswerMode and render a warning line
(com_ui_ask_answer_error) in the popover, so the user gets feedback and retry
guidance without having to collapse/dismiss. It clears automatically on retry
(status flips to 'submitting').
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@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: 4e083da51e

ℹ️ 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 on lines +285 to +287
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
return submitText(composerText);

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 Respect IME composition before submitting answers

When answering an ask_user_question with an IME/CJK keyboard, Enter is commonly used to commit the current composition. This new pre-handler runs before useTextarea's existing composition guard, so as soon as the textarea has any composing text it calls preventDefault() and submits the partial answer instead of letting the character commit. Please mirror the normal composer guard (isComposing / key === 'Process' / keyCode === 229) before treating Enter as an answer submit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b45c812. handleComposerKeyDown now bails at the very top when a composition is in progress — e.nativeEvent.isComposing || e.key === 'Process' || e.keyCode === 229 — mirroring the existing useTextarea composer guard (with the Safari key/keyCode fallbacks). Since this pre-handler runs first, the guard has to live here too; placing it before every branch also protects the digit/arrow steering paths, since the composition buffer can leave value empty mid-compose. The committing Enter now falls through to commit the character normally instead of submitting a partial answer.

handleComposerKeyDown runs before useTextarea's composition guard, so with a
CJK/IME keyboard the Enter that commits an in-progress composition was being
intercepted and submitting the partial answer (and the composition buffer can
leave value empty mid-compose, mis-triggering digit/arrow steering too). Bail
at the top when composing — nativeEvent.isComposing, or key==='Process' /
keyCode===229 for Safari's inconsistent reporting — mirroring the existing
composer guard so the character commits normally.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

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

ℹ️ 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 on lines +29 to +30
'turn, and NEVER call this tool in parallel with any other tool call. When the realistic',
'answers are enumerable, provide 2-6 concise options; set multiSelect to true only when',

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 Enforce ask_user_question as an exclusive tool call

Relying on the tool description is not enough to prevent batched calls. If a model emits ask_user_question in the same tool batch as another side-effecting tool, LangGraph re-enters the interrupted batch on resume, and sibling tool promises are not cancelled when the interrupt unwinds, so the sibling can run before the pause and then run again after the user answers. Please enforce exclusivity in the runtime (for example, disable parallel tool calls while this tool is available or reject batches containing this tool plus siblings) to avoid duplicated actions/file writes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the same exclusivity concern from the earlier review (thread above) — re-surfaced because it is still deferred by design, and I want to be straight that it is a real footgun, not a false positive. Recap of the trade-offs so danny can make the call:

  1. SDK-level (correct fix): make the interrupted batch idempotent on resume (skip re-executing already-completed siblings) or schedule the interrupting tool first — in @librechat/agents ToolNode. Tracked as a follow-up; it is the same class of work as the sibling agents PR Error message: fetch failed/about proxy and reverse proxy #292.
  2. Host-level parallel disable: set parallel_tool_calls: false / disable_parallel_tool_use on the LLM config whenever ask_user_question is available, so the model cannot batch it with a sibling. Feasible but provider-specific and forces sequential tool calls for every ask-enabled agent (a real perf/behaviour cost even for turns that never ask).
  3. Accept prompt mitigation: the description already forbids parallel/batched calls; in practice models comply (sequential questions, not parallel batches).

Deferring the runtime enforcement to option 1 keeps this PR scoped, but it does mean this P2 stays open until the SDK lands. @danny-avila to decide whether to block on a host-side option-2 guard now or ship with the prompt mitigation + SDK follow-up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed the concern is real (and it's the same one tracked on the earlier thread — sibling side-effects can run pre-pause and again on resume). Your concrete suggestion here — disabling parallel tool calls while the ask tool is available — is more tractable than the batch-rejection I described before: parallel_tool_calls: false is a first-class param on the OpenAI/Responses path. The catch is it's provider-specific (Anthropic gates this via tool_choice.disable_parallel_tool_use, others differ or don't expose it), and it removes tool parallelism for the whole run of any agent that has the ask tool enabled — a behavioral trade-off, not a pure bug fix. Because that's a cross-provider design call with a UX cost, I'm deferring it to the maintainer to decide whether to land the parallel-disable in this PR vs. the SDK-level idempotent-resume/ordering fix I filed as a follow-up. Flagging it to danny now; not self-merging a behavioral change under review automation.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: b45c812030

ℹ️ 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 added a commit to LibreChat-AI/agents that referenced this pull request Jul 8, 2026
…errupts mid-batch (#294)

* feat(hitl): schedule interrupting tools first to guard sibling double-execution

A tool whose body raises a LangGraph `interrupt()` mid-execution — the
`ask_user_question` shape, which suspends the run to collect a human
answer — shares its in-process `Promise.all` with sibling direct tools.
When it interrupts, LangGraph's resume contract re-runs the whole
interrupted node from the top, so a non-idempotent sibling (send_email,
billing) that already completed on the first pass runs a SECOND time on
resume, duplicating the side effect.

A runnable dist probe over the batch shapes (pause→resume, MemorySaver)
shows the exposure is narrow: only a sibling that shares the
interrupter's in-process `Promise.all` double-executes. An
event-dispatched sibling is already safe — the ToolNode awaits the whole
direct group (where a body interrupt unwinds) before it dispatches event
tools, so a dispatched sibling never runs on the first pass. This is why
the fix is scheduling within the direct group rather than idempotent
resume (which would have to persist per-call completion into checkpoint
state — the exact machinery the tool-error-resume work took several
rounds to stabilize, unjustified for this narrow, opt-in case).

Fix: add an opt-in `RunConfig.interruptingToolNames` (threaded through
Graph → ToolNode, mirroring `codeSessionToolNames`). Tools named there
are (1) always executed in-process — a body `interrupt()` only fires for
a tool that runs inside the graph node, never for one dispatched to the
host — and (2) within a batch, scheduled as their own awaited group
BEFORE their non-interrupting direct siblings. If one interrupts, the
batch unwinds before any sibling runs, so the sibling executes exactly
once (on resume). Opt-out is byte-for-byte the prior behavior: when the
set is empty or no batch call matches, direct-batch execution is an
unchanged single `Promise.all`.

Empirically validated (dist probe): without the set the exposed direct
shapes still double-execute (2x); with it they run exactly once (1x),
order-independent, in both event-driven and legacy modes; the
event-dispatched sibling stays safe. New spec
`src/specs/ask-user-question-batch.test.ts` pins [ask_user_question,
side_effect] through pause→resume asserting the sibling runs exactly
once, plus the unguarded baseline and the event-sibling-safe shape.

Guards the double-execution Codex flagged on LibreChat PR #14139
(LibreChat-AI/LibreChat#14139); the host opts in by passing the
ask_user_question tool name in `interruptingToolNames`.

* fix(hitl): don't force interruptingToolNames onto the direct path (Codex #294)

Codex flagged that folding `interruptingToolNames` into direct-tool
classification breaks a self-spawned child that scrubs inherited
`graphTools` (SubagentExecutor.buildChildInputs, `config.self === true`)
but keeps the event `toolDefinition`. There the name resolves only to a
schema-only stub; forcing it direct invokes the stub, which throws
"should not be invoked directly in event-driven mode" instead of
dispatching ON_TOOL_EXECUTE to the host.

Root cause: the fold-in was wrong in principle. `interruptingToolNames`
should only REORDER tools that are already independently direct — a real
in-process graphTool is the only kind whose body can reach `interrupt()`.
A name that is merely an event `toolDefinition` has no in-process
implementation to run, so promoting it to the direct path can only ever
hit the stub. In the real host (LibreChat) `ask_user_question` is a
graphTool and is therefore already in `directToolNames`, so the guard
works without the fold-in — `runDirectBatchInterruptSafe` still schedules
it ahead of its direct siblings.

Fix: remove the fold-in at both classification sites (mixed-batch
partition and the Send-input `isLocalTool` check). The child-graph
propagation is now safe (it only reorders the child's direct group, a
no-op when the child has no executable instance for the name), so it
stays. Docs on `RunConfig`/`ToolNodeOptions.interruptingToolNames`,
`HumanInTheLoopConfig`, and the ToolNode field are corrected to say the
set reorders rather than promotes, and the tool must independently be
direct for the guard to apply.

New spec case pins it: a name in `interruptingToolNames` that is only a
schema-only event stub is dispatched to the host (HOST-HANDLED), never
invoked directly.
Previously a number key (1..N) only selected an option from the empty
composer (handleComposerKeyDown on the textarea) — if focus moved into the
popover (a row/Skip/Submit button clicked or tabbed to), the number keys went
dead. Add handlePopoverKeyDown, wired to the popover container's onKeyDown so
it catches digits bubbling from the focused control: a digit activates its
option exactly like a click (single-select submits, multi toggles). No
highlight/Enter dance on this path — the options are buttons whose action is
the click, and intercepting Enter would fight the focused button. Gated on
active && !locked so it no-ops while a submit is in flight.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 85a6d5f842

ℹ️ 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 merged commit 988a14a into dev Jul 8, 2026
32 checks passed
@danny-avila
danny-avila deleted the feat/ask-user-question-tool branch July 8, 2026 19:31
ThomasVuNguyen pushed a commit to ThomasVuNguyen/LibreChat that referenced this pull request Jul 15, 2026
…use/resume (LibreChat-AI#14139)

* feat: ask_user_question tool — agent-initiated questions with durable pause/resume

The HITL runtime merged in LibreChat-AI#13942/LibreChat-AI#14024/LibreChat-AI#14025/LibreChat-AI#14123 already ships the full
ask_user_question lifecycle (payload-agnostic handleRunInterrupt, resume
validation via mapAskUserAnswer, reconnect rehydration, and the client question
card) — but nothing ever raised the interrupt. This adds the producer:

- packages/api/agents/hitl/askUserQuestionTool.ts: LLM-callable tool whose func
  calls the SDK askUserQuestion() helper (LangGraph interrupt() from the tool
  body); zod schema with length caps mirroring AskUserQuestionRequest, plus a
  JSON-schema twin for the schema-only registry
- Registration: agentToolDefinitions, manifest.json (Tools dialog, admin
  filteredTools/includedTools kill switch), basicToolInstances, handleTools
  constructor branch
- run.ts gating: checkpointer now attaches for hitlCapable runs whose agents
  carry the ask tool even with the tool-approval policy disabled (the interrupt
  needs only durability, not humanInTheLoop/hooks); the tool is stripped
  fail-closed from non-HITL callers (OpenAI-compat/Responses) and subagent
  child configs; excluded from eager event execution (interrupts must be
  raised inside the Pregel task frame)
- resume.js: 16k length cap on the answer wire field
- e2e (real Run + FakeChatModel + LazyMongoSaver + supertest resume): tool-body
  interrupt pauses durably with NO approval policy, answer round-trips as the
  ToolMessage content, tool body re-runs once on resume, sequential questions
  re-pause

* fix: adversarial-review findings — in-graph execution, orphan prunes, endpoint scoping, real kill switch

Pre-PR multi-agent review confirmed 5 defects in the initial commit; all fixed:

1. CRITICAL — the tool never paused on the real agents endpoint: production
   loads tools definitions-only, flipping the SDK ToolNode to event-driven
   dispatch, and the host ON_TOOL_EXECUTE handler runs outside the Pregel task
   frame (under runOutsideTracing), where interrupt() throws and becomes an
   error ToolMessage. Reworked: the ask tool never rides toolDefinitions/
   toolRegistry — on HITL-capable top-level agents a real instance is supplied
   via AgentInputs.graphTools (agents#289, requires @librechat/agents > 3.2.57),
   the SDK's in-graph direct-tool seam; new production-shape e2e pins the
   event-driven mode end to end.
2. CRITICAL — ask-only runs left orphaned interrupted checkpoints (silent
   context duplication on every later turn): both orphan prunes were gated on
   toolApproval.enabled. The pre-turn prune now also fires for ask-capable
   agents (exported agentRequestsAskUserQuestion), and the abort-route prune
   fires when the aborted job carries a pendingAction.
3. MAJOR — self-spawned subagents bypassed the strip (self config resolves from
   the parent's _sourceInputs): fixed SDK-side (buildChildInputs clears
   graphTools) and the tool is now never present on child surfaces host-side.
4. MINOR — the manifest entry leaked into the Assistants tools dialog and the
   legacy plugins endpoint, where tools execute with no run to pause: new
   agentsOnly manifest flag, scoped out of both listings.
5. MINOR — filteredTools/includedTools only hid the tool from the dialog:
   now enforced at run build (strip + no checkpointer), making the admin
   filter a real kill switch for already-saved agents.

* chore: update @librechat/agents dependency to version 3.2.58 in package-lock.json and package.json files

* fix: reject agents-only tools at assistant create/update (Codex round 1)

The tools-dialog scoping keeps ask_user_question out of the assistants
LISTING, but the v1/v2 create/update handlers resolve arbitrary posted tool
strings from the shared getCachedTools map — a REST client or stale saved
payload could still attach it, and the assistants runtime executes tools with
no run to pause, so every call would error. New isAgentsOnlyTool(tool)
(manifest-driven, handles string and function-object shapes) drops such tools
with a warn at all four resolution sites (v1+v2, create+update).

* fix: offset resumed-run content indices past the pre-pause seed

A resumed run rebuilds the graph from the checkpoint, and the fresh graph
numbers content indices from its own empty contentData — starting at 0. The
resume path seeds the (also fresh) content aggregator with the pre-pause
parts at exactly those indices, so the resumed model turn collided with the
seed: type-matching parts silently MERGED (post-resume text appended into a
pre-pause text block), and type-mismatching parts (a reasoning/think part at
index 0 — any Anthropic reasoning agent) dropped EVERY delta with 'Content
type mismatch', losing the entire post-resume output from the live stream
and the saved message.

Latent since LibreChat-AI#13942 — tool-approval resumes corrupt content the same way
(probe-verified); it surfaced now because ask_user_question makes pausing a
first-class flow and reasoning models make the loss total.

- createContentIndexOffsetHandlers(handlers, offset): wraps ON_RUN_STEP
  (the single point where a content index enters the pipeline — deltas
  resolve through the aggregator's stepMap) and ON_AGENT_UPDATE's inline
  index; every other handler passes through by reference. Probe-validated:
  resumed output now lands as a new part after the paused tool call.
- resumeCompletion wires it with offset = seedContent.length.
- logToolError: a GraphInterrupt unwinding out of a tool body is the HITL
  pause working as designed — no longer logged as a Tool Error.

* fix: unblock live streaming of the resumed segment after an answer

With resume indices now ABSOLUTE (server continues after the pre-pause
parts), the synthetic ask-user-question card was squatting on exactly the
index the resumed segment streams into: applyAskUserQuestion appends the
card at the end of the message content, so on the answering device every
incoming part at that index was blocked and nothing rendered between the
answer submission and the finalize replacing the message.

removeAskUserQuestionPart(message, actionId) strips the pause-scoped card
on successful answer submission (useResumeSubmit onSuccess) — the durable
record of the Q&A is the ask_user_question tool call itself. Pure helper +
specs; same-reference no-op when nothing matches.

* fix: displace the synthetic question card in the streaming content writer

The store-level strip on answer submit wasn't enough: the SSE step handler
keeps its own in-flight copy of the streaming message, so on the answering
device the synthetic ask-user-question card still occupied the ABSOLUTE index
the resumed segment streams into — every delta warned 'Content type mismatch'
(existing ask_user_question vs incoming text) and nothing rendered between the
pending_action and finalize.

Displace the card inside updateContent when any real part claims its slot —
the same displacement pattern as the OAuth prompt part directly above it.
Covers the streaming handler's own copy, reconnecting tabs, and other devices;
once real content streams, the pause is over by definition. Spec drives a
runStep + text delta into the card's index and pins: no mismatch warn, card
gone, text rendered.

* feat: dedicated UI + durable data for completed ask_user_question calls

The completed ask call rendered as a generic tool card labeled 'Cancelled'
with raw (and empty) JSON args. Two layers fixed:

Data: the saved tool_call part had args:'' and no output — streamed arg
chunks carry no tool name so the aggregator drops them (normal tools recover
via the completion event, which never fires for a tool that interrupts
mid-execution and resumes on a rebuilt run with no step id). The resume
controller now stamps the paused ask part with the pendingAction's
authoritative question as args and the user's answer as output
(attachAskUserQuestionAnswer — pure, targets the newest unanswered ask part,
so sequential questions each keep their own answer).

UI: Part.tsx routes ask_user_question tool calls to AskUserQuestionCall — a
compact Q&A record ('Asked a question' header, question, description, 'You
answered: <label>' preferring the picked option's label, or 'No answer was
given' for an abandoned pause) instead of the generic card. New i18n keys;
parseAskUserQuestionArgs degrades to null on malformed model args.

* fix: single question UI per pause + immediate answer display

Two live-turn issues with the new durable Q&A card:

1. Duplicate question on ask: during a live pause the message carries BOTH the
   ask tool_call part (now rendered by AskUserQuestionCall, showing a
   misleading 'No answer was given' while paused) and the synthetic
   interactive card. The durable card now defers while the turn is live and
   unanswered (isSubmitting) — the interactive card owns the question UI until
   it's answered; an abandoned pause still shows its no-answer state once the
   turn settles.

2. 'No answer was given' after answering: the server stamps the answer onto
   the part at resume seed, but the client only received that at finalize. No
   stream emission needed — the client knows the answer it just submitted:
   resolveAskUserQuestionPart (replacing the plain strip on submit success)
   removes the synthetic card AND stamps output/progress onto the newest
   unanswered ask tool_call, seeding args from the synthetic part's question
   when the streamed args were lost — mirroring the server-side
   attachAskUserQuestionAnswer, so the Q&A record shows the answer the moment
   the user submits.

* fix: keep the Q&A record visible while the resumed segment streams

The optimistic output stamp lives in the message store, but the SSE step
handler evolves its own cached copy of the streaming message (created at turn
start) — the first resumed event overwrites the store with that copy, wiping
the stamp, so the Q&A card blinked out during streaming and only returned at
finalize.

Render-layer fallback instead of fighting the handler's copy: submitted
answers are recorded by ask tool_call id when resolveAskUserQuestionPart
stamps the part, and AskUserQuestionCall reads the recorded answer whenever
the part's own output is missing — the record survives any message-copy churn
until finalize delivers the server-stamped part.

* feat: present Ask User as a native builtin in the tools dialog

It ships with the app and pauses the run like a first-class feature, so it
belongs with the builtins (Run Code, Web Search, Memory, ...) rather than in
the third-party plugin list — while its mechanics stay exactly a plugin's:

- BuiltinId += 'ask_user_question' (documented exception: a native TOOL, not
  a capability; selection reads agent.tools, the toggle emits tool-add/remove
  patches instead of a capability field)
- buildCatalog surfaces it as a builtin gated on the same signals as before
  (tools capability on + the server lists the plugin, i.e. not admin-filtered)
  and skips it in the plugin loop so it never double-lists
- On-theme icon: lucide MessageCircleQuestion in a teal chip via the builtin
  icon map, matching the other native entries; the bespoke purple SVG and the
  manifest icon field are gone
- i18n'd name/description keys like the other builtins

* feat: composer popover for answering questions (mentions-style)

Answering moves to the composer, matching the existing mentions/prompts
popover pattern: while an ask_user_question pause is live, a popover anchors
above the textarea with the question as its header, numbered option rows
(hover/click, or ↑/↓ + Enter from the empty composer), and an × to dismiss.
The main textarea doubles as the free-form answer — its placeholder flips to
'Something else...' and form submit routes the text to the paused run as the
answer instead of starting a new turn. Dismissing (× or Escape) restores
normal sends; the inline transcript surfaces stay as before (interactive card
while paused, durable Q&A record after) so the question remains visible in
history.

- findLiveAskUserQuestion (pure, spec'd): newest unanswered synthetic part
  across the conversation IS the popover signal — applied on
  on_pending_action, stripped on answer submit, so visibility tracks the
  pause lifecycle with no extra state
- useLiveAskUserQuestion hook shared by the popover and ChatForm; dismissals
  in a recoil atom so both react
- popover only mounts on the primary composer (index 0), mirroring QuoteButton

* feat: number-key selection + return glyph in the question popover

Pressing 1-9 in the empty composer picks the matching option directly,
mirroring the numbered row chips; the highlighted row shows a return-key
glyph as the Enter affordance. Same empty-composer guard as the arrow keys —
typing a free-form answer is never intercepted.

* refactor: first-class composer answer mode (useAskAnswerMode)

Replaces the bolted-on integration (inline onSubmit interception + raw
capture-phase keydown listeners on the textarea ref) with a single hook that
owns the whole answer mode: live-question derivation, dismissal + highlighted
option (shared recoil state), option selection, free-form submit routing
(submitText returns whether it consumed the submission), and keyboard
handling (handleKeyDown returns whether it consumed the key, composed ahead
of the textarea's normal handler — no more addEventListener).

The popover is now pure rendering off the hook; ChatForm wires placeholder,
onKeyDown, and onSubmit through the same instance. Deliberately scoped to the
composer rather than useSubmitMessage: starters/prompt-commands keep new-turn
semantics (and the existing job-replacement behavior while paused).

* fix: Codex round 2 — inline answer input, approval exemption, pause-time args

F1 (composer submit unreachable while paused — isSubmitting keeps Stop shown
and useTextarea eats Enter): redesigned around it, borrowing Claude Code's
AskUserQuestion semantics. The popover now owns free-form input via an inline
'Other' row (numbered last, 'Something else…'), with select-then-confirm rows
(click/arrows/digits highlight; Submit ↵, Enter, or double-click fires; Skip
dismisses). The composer returns to being a plain composer — no placeholder
swap, no submit interception; Stop keeps meaning stop.

F2: ask_user_question is exempt from the tool-approval prompt unless the
admin explicitly lists it (allow/ask/deny all win) — approving the right to
ask a question was a pure double pause; the tool is side-effect-free.

F3: the question is stamped onto the paused ask tool_call's args at PAUSE
time (attachAskUserQuestionArgs in handleRunInterrupt), so abandoned/expired/
stopped turns persist with the question intact and the record card can render
it — previously only the answer-resume path stamped args.

* fix: fold model-supplied 'Other' options into the inline free-form row

The model can generate its own catch-all option ('Other (type your own)',
value 'other'), duplicating the popover's built-in free-form row — two
other-ish rows, one pickable as a literal answer. Two layers:

- Tool description now tells the model NOT to include catch-all options (the
  answer UI always offers free-form input on its own)
- splitOtherOption (pure, spec'd) folds a catch-all option that arrives
  anyway out of the choice rows and uses its label as the inline input's
  placeholder — conservative match (value 'other', or a label reading as a
  free-form invitation), no false positives on real choices

* fix: single question surface + clean free-form-only popover

Two live-pause confusions: (1) the inline transcript card and the composer
popover both rendered — the card now defers while the popover is up for its
action, returning as the fallback surface when the user dismisses the popover
(and in contexts without a ChatContext, where the popover can't exist);
(2) an options-less question showed a pointless numbered '1 Something else…'
row — free-form-only questions now render the inline input alone, with the
'Type your answer…' placeholder (a folded model 'Other' label still wins).

* feat: the composer is the free-form answer box (like the main chat input)

While a question pause is live, the main chat textarea composes the free-form
answer — placeholder swaps to 'Something else…' (or a folded model 'Other'
label), Enter with text submits the answer through answer-mode key handling
(composed BEFORE useTextarea's submitting-lock, so the lock can't swallow it),
and the Stop button swaps to Send (enabled despite isSubmitting) per the
select-then-confirm design. The popover slims to the question header, numbered
option rows, and Skip/Submit — its inline input is gone since the composer
owns free-form now. Dismissing the popover restores normal composer semantics
(Stop button, normal sends).

* fix: Codex round 3 + real Skip semantics

- Skip now ANSWERS instead of hiding UI (danny): it resumes the run with a
  decline notice ('The user chose not to answer this question.') so the model
  moves on — a client-side dismiss left the run paused until expiry, a hung
  turn. × / Escape remain pure dismiss (switch to the inline card surface).
- P1 (resumed approval tool indices): resumed tool_calls steps whose
  tool_call id matches a seeded UNRESOLVED part now rebind to that seeded
  slot instead of offsetting — the original part resolves in place (output
  attaches) and no duplicate appears; message steps keep the offset, so the
  text-loss fix stands. createContentIndexOffsetHandlers now takes the seed
  array; resolved seeded calls are not rebind targets.
- P2 (stale selection across questions): selection state resets when the
  live actionId changes; the vestigial inline-Other state ('other' selection
  + text atom) is gone — the composer owns free-form.
- P2 (Redis abort path loses the args stamp): the abort route re-stamps the
  question onto the ask tool_call in the reconstructed abort content, so a
  Stop-abandoned question persists with its question intact.
- P2 (malformed args crash): parseAskUserQuestionArgs normalizes untrusted
  shapes (options: {} / non-string entries) instead of throwing in render.

* feat: free-form hint in the question popover footer

Left-aligned in the footer row (opposite Skip/Submit): 'Or type your answer
below' — points open-ended answering at the composer, whose placeholder
already reads 'Something else…'.

* feat: preserve composer drafts across the answer-mode swap

The answer phase gets its own draft key (ask-answer:<actionId>), passed as a
draftId override into useAutoSave — the key change itself drives the existing
save/restore machinery, so the conversation draft (or mid-run PENDING draft)
is stashed when a question pause takes the composer and restored once the
user answers, skips, or dismisses. Ask keys are exempt from the PENDING
migration branch, which would otherwise move-and-delete the stashed draft. A
half-typed answer survives reload/navigation while its question stays live.
Answer submission (option pick, free-form, skip) resets the composer via a
new non-throwing useOptionalChatFormContext, so the swap-back restores into
an empty box even outside ChatView-less render contexts (Share/search).

* fix: rebind resumed steps for ALL seeded tool call ids

The resume controller pre-stamps the user's answer onto the seeded
ask_user_question part, so the unresolved-only rebind predicate treated
it as settled and shifted the tool's re-run step to a fresh offset slot,
leaving a duplicate ask record in streamed/saved content. Tool call ids
are provider-minted per call: a resumed step bearing a seeded id can
only be the interrupted batch re-executing, so rebinding every seeded
id is always correct.

* feat: popover UX round 4 — clickable hint, collapse, click-submit, multiSelect

- Footer hint is a button that focuses the composer; reads 'Type your answer
  below' (no 'Or') when the question has no options.
- Collapse (chevron) hides the popover WITHOUT closing the pause: answer mode
  stays live (placeholder, Enter routing, draft key), the chat card renders
  the question with a ChevronUp affordance to re-expand. x remains dismiss.
- Single-select options submit on a single click; the Submit button renders
  only for multi-select.
- multiSelect end-to-end: tool zod schema + JSON definition twin, wire type,
  client parse, popover check-chips, card toggles, record-card label mapping;
  answer = option values joined ', '; composer Enter and the multi Submit
  button both fold free-form text in with the checked values.
- Hardening from adversarial review: in-flight status guard on every submit
  path (no duplicate resumes on double-click), popover locks while
  submitting, collapsed mode disarms invisible digit/arrow steering, the
  card shares the hook's checked state while the pause is live, the card
  folds catch-all 'Other' options, record mapping is all-or-nothing to avoid
  phantom labels, composer resets only when its text was consumed or the
  draft machinery will restore the stash.

* feat: ask_user_question in model specs and ephemeral agents

A librechat.yaml modelSpec can now equip the tool the same way it equips
webSearch/executeCode/fileSearch/memory:

  modelSpecs:
    list:
      - name: my-spec
        askUserQuestion: true

loadEphemeralAgent pushes the tool name when the spec flag (or the
ephemeralAgent request flag, wired for parity) is set; everything downstream
is the existing persisted-agent machinery — createRun's hitlCapable gating,
graphTools injection, checkpointer attach, subagent strip, and the admin
filteredTools/includedTools kill switch all apply unchanged.

* feat: tense-aware Q&A record label (Asking / Asked)

Shorten the record card header per feedback: 'Asking' while the question is
still unanswered (abandoned/awaiting), 'Asked' once answered — replacing the
single 'Asked a question' label.

* fix: Codex round 4 — added-agent ask parity + preserve answer on failed resume

F1 (added.ts): mirror loadEphemeralAgent's ask_user_question branch in the
added-agent loader so a model spec's askUserQuestion flag (or the ephemeral
request flag) equips added top-level agents too, matching execute_code /
web_search / memory. Two load.spec cases added.

F3 (composer): submitAskAnswer now takes an onSuccess callback and
useAskAnswerMode defers clearing the selection/composer until the resume is
accepted. A failed resume (16k answer-cap 400, expired action, network error)
leaves status re-answerable, so wiping the composer up front lost the user's
only copy of a free-form answer; now it survives for trim/retry.

(F2 — a claimed Tools-capability bypass — was verified NOT reproducible:
agentRequestsAskUserQuestion matches only loaded instances/toolDefinitions/
toolRegistry, all capability-filtered; a raw tools string has no .name and
never triggers the install. Replied on-thread with the probe evidence.)

* fix: Codex round 5 — expired question exits answer mode so its message shows

An expired question (e.g. resume returns the stale-action 409) previously left
the popover open with locked controls and no explanation, because the chat
card — which carries the only 'this action expired' message — was suppressed
by the popover-open guard. Treat 'expired' as no longer active: the popover
closes, the composer reverts to normal, and the card becomes the sole surface
and renders the expired message. 'error' stays active (retryable).

* feat: group ask_user_question calls as their own category

A homogeneous group of ask_user_question tool calls now reads 'Asked N
questions' (present tense 'Asking N questions' while the turn streams) with a
question glyph and no raw-name suffix — mirroring the subagent 'Ran N agents'
category treatment, instead of 'Used N tools — ask_user_question'. Mixed
groups keep 'Used N tools' but humanize the suffix to 'Question' and show a
question icon for the ask entries (TOOL_FRIENDLY_NAME_KEYS + ToolIcon map).
A group only forms at count >= 2, so the plural is always grammatical.
Three ToolCallGroup.test cases cover homogeneous label/icon/suffix, present
tense while streaming, and the mixed-group fallback.

* fix: Codex round 6 — composer submit lock + abort stamp before emit

F7 (composer status lock): the ask submit status lived on ApprovalContext,
a React context mounted only around message content (ContentParts). The
PRIMARY answer surface — the composer in ChatForm — renders outside it, so
useApprovalContext returned the inert FALLBACK: status was always 'idle',
setStatus a no-op. The in-flight double-submit guard (round 4) and the
expired-exits-answer-mode fix (round 5) therefore never engaged for the
composer. Move ask submit status to a global Recoil atom (useAskSubmitStatus)
read/written by the composer, the popover, and the card alike, so a fast
double-click/Enter is actually blocked and expired/error surfaces on every
surface. Tool-approval status stays on the context (unchanged).

F5 (abort stamp before emit): the abort route re-stamped a paused
ask_user_question's args AFTER GenerationJobManager.abortJob had already
emitted the final SSE from the unstamped content, so a Redis/cross-replica
Stop left the live client showing an empty question until reload. abortJob
now takes an optional transformAbortContent applied to the persistable
content BEFORE the final event is built (and returned), so the live client
and the saved message agree. New abort.spec case + updated call assertions.

* feat: gate ask_user_question behind its own agent capability

Add a first-class AgentCapabilities.ask_user_question (in defaultAgentCapabilities,
on by default) so admins can enable/disable questions independently via
endpoints.agents.capabilities, exactly like execute_code / web_search — not
lumped under the generic tools capability.

- ToolService: both filteredTools predicates (definitions-only and instance
  loaders) gate ask_user_question on checkCapability(ask_user_question) before
  the generic tools fallthrough. When off, the tool is dropped from
  toolDefinitions/toolRegistry, so run.ts's agentRequestsAskUserQuestion (which
  keys on the loaded surface) declines to install it and attach a checkpointer —
  the capability is enforced end-to-end at the loader, no run.ts change needed.
- Tools dialog catalog: surface the ask builtin under its own capability rather
  than the generic tools one, so the UI matches the backend gate.
- Tests: ToolService capability on/off filtering + defaults membership; catalog
  builtin visibility keyed on the dedicated capability.

* style: sort imports in ToolCallGroup.test (CI import-order gate)

* fix: Codex round 7 — surface ask-answer errors in the open popover

A failed answer submission (16k reject, network error) sets the ask status to
'error', which — unlike 'expired' — deliberately keeps the question active and
retryable. But the chat card that renders the error message is suppressed while
the popover is open, so a composer/popover answer failed silently. Expose an
'errored' flag from useAskAnswerMode and render a warning line
(com_ui_ask_answer_error) in the popover, so the user gets feedback and retry
guidance without having to collapse/dismiss. It clears automatically on retry
(status flips to 'submitting').

* fix: Codex round 8 — respect IME composition before submitting answers

handleComposerKeyDown runs before useTextarea's composition guard, so with a
CJK/IME keyboard the Enter that commits an in-progress composition was being
intercepted and submitting the partial answer (and the composition buffer can
leave value empty mid-compose, mis-triggering digit/arrow steering too). Bail
at the top when composing — nativeEvent.isComposing, or key==='Process' /
keyCode===229 for Safari's inconsistent reporting — mirroring the existing
composer guard so the character commits normally.

* chore: update `@librechat/agents` to v3.2.60

* 🔧 chore: Update @opentelemetry/core to version 2.9.0 and clean up package-lock.json

* feat: digit shortcuts select options when the popover has focus

Previously a number key (1..N) only selected an option from the empty
composer (handleComposerKeyDown on the textarea) — if focus moved into the
popover (a row/Skip/Submit button clicked or tabbed to), the number keys went
dead. Add handlePopoverKeyDown, wired to the popover container's onKeyDown so
it catches digits bubbling from the focused control: a digit activates its
option exactly like a click (single-select submits, multi toggles). No
highlight/Enter dance on this path — the options are buttons whose action is
the click, and intercepting Enter would fight the focused button. Gated on
active && !locked so it no-ops while a submit is in flight.

* chore: update @librechat/agents to version 3.2.61 and @opentelemetry packages to latest versions
fuuuzzy pushed a commit to fuuuzzy/LibreChat that referenced this pull request Aug 7, 2026
…use/resume (LibreChat-AI#14139)

* feat: ask_user_question tool — agent-initiated questions with durable pause/resume

The HITL runtime merged in LibreChat-AI#13942/LibreChat-AI#14024/LibreChat-AI#14025/LibreChat-AI#14123 already ships the full
ask_user_question lifecycle (payload-agnostic handleRunInterrupt, resume
validation via mapAskUserAnswer, reconnect rehydration, and the client question
card) — but nothing ever raised the interrupt. This adds the producer:

- packages/api/agents/hitl/askUserQuestionTool.ts: LLM-callable tool whose func
  calls the SDK askUserQuestion() helper (LangGraph interrupt() from the tool
  body); zod schema with length caps mirroring AskUserQuestionRequest, plus a
  JSON-schema twin for the schema-only registry
- Registration: agentToolDefinitions, manifest.json (Tools dialog, admin
  filteredTools/includedTools kill switch), basicToolInstances, handleTools
  constructor branch
- run.ts gating: checkpointer now attaches for hitlCapable runs whose agents
  carry the ask tool even with the tool-approval policy disabled (the interrupt
  needs only durability, not humanInTheLoop/hooks); the tool is stripped
  fail-closed from non-HITL callers (OpenAI-compat/Responses) and subagent
  child configs; excluded from eager event execution (interrupts must be
  raised inside the Pregel task frame)
- resume.js: 16k length cap on the answer wire field
- e2e (real Run + FakeChatModel + LazyMongoSaver + supertest resume): tool-body
  interrupt pauses durably with NO approval policy, answer round-trips as the
  ToolMessage content, tool body re-runs once on resume, sequential questions
  re-pause

* fix: adversarial-review findings — in-graph execution, orphan prunes, endpoint scoping, real kill switch

Pre-PR multi-agent review confirmed 5 defects in the initial commit; all fixed:

1. CRITICAL — the tool never paused on the real agents endpoint: production
   loads tools definitions-only, flipping the SDK ToolNode to event-driven
   dispatch, and the host ON_TOOL_EXECUTE handler runs outside the Pregel task
   frame (under runOutsideTracing), where interrupt() throws and becomes an
   error ToolMessage. Reworked: the ask tool never rides toolDefinitions/
   toolRegistry — on HITL-capable top-level agents a real instance is supplied
   via AgentInputs.graphTools (agents#289, requires @librechat/agents > 3.2.57),
   the SDK's in-graph direct-tool seam; new production-shape e2e pins the
   event-driven mode end to end.
2. CRITICAL — ask-only runs left orphaned interrupted checkpoints (silent
   context duplication on every later turn): both orphan prunes were gated on
   toolApproval.enabled. The pre-turn prune now also fires for ask-capable
   agents (exported agentRequestsAskUserQuestion), and the abort-route prune
   fires when the aborted job carries a pendingAction.
3. MAJOR — self-spawned subagents bypassed the strip (self config resolves from
   the parent's _sourceInputs): fixed SDK-side (buildChildInputs clears
   graphTools) and the tool is now never present on child surfaces host-side.
4. MINOR — the manifest entry leaked into the Assistants tools dialog and the
   legacy plugins endpoint, where tools execute with no run to pause: new
   agentsOnly manifest flag, scoped out of both listings.
5. MINOR — filteredTools/includedTools only hid the tool from the dialog:
   now enforced at run build (strip + no checkpointer), making the admin
   filter a real kill switch for already-saved agents.

* chore: update @librechat/agents dependency to version 3.2.58 in package-lock.json and package.json files

* fix: reject agents-only tools at assistant create/update (Codex round 1)

The tools-dialog scoping keeps ask_user_question out of the assistants
LISTING, but the v1/v2 create/update handlers resolve arbitrary posted tool
strings from the shared getCachedTools map — a REST client or stale saved
payload could still attach it, and the assistants runtime executes tools with
no run to pause, so every call would error. New isAgentsOnlyTool(tool)
(manifest-driven, handles string and function-object shapes) drops such tools
with a warn at all four resolution sites (v1+v2, create+update).

* fix: offset resumed-run content indices past the pre-pause seed

A resumed run rebuilds the graph from the checkpoint, and the fresh graph
numbers content indices from its own empty contentData — starting at 0. The
resume path seeds the (also fresh) content aggregator with the pre-pause
parts at exactly those indices, so the resumed model turn collided with the
seed: type-matching parts silently MERGED (post-resume text appended into a
pre-pause text block), and type-mismatching parts (a reasoning/think part at
index 0 — any Anthropic reasoning agent) dropped EVERY delta with 'Content
type mismatch', losing the entire post-resume output from the live stream
and the saved message.

Latent since LibreChat-AI#13942 — tool-approval resumes corrupt content the same way
(probe-verified); it surfaced now because ask_user_question makes pausing a
first-class flow and reasoning models make the loss total.

- createContentIndexOffsetHandlers(handlers, offset): wraps ON_RUN_STEP
  (the single point where a content index enters the pipeline — deltas
  resolve through the aggregator's stepMap) and ON_AGENT_UPDATE's inline
  index; every other handler passes through by reference. Probe-validated:
  resumed output now lands as a new part after the paused tool call.
- resumeCompletion wires it with offset = seedContent.length.
- logToolError: a GraphInterrupt unwinding out of a tool body is the HITL
  pause working as designed — no longer logged as a Tool Error.

* fix: unblock live streaming of the resumed segment after an answer

With resume indices now ABSOLUTE (server continues after the pre-pause
parts), the synthetic ask-user-question card was squatting on exactly the
index the resumed segment streams into: applyAskUserQuestion appends the
card at the end of the message content, so on the answering device every
incoming part at that index was blocked and nothing rendered between the
answer submission and the finalize replacing the message.

removeAskUserQuestionPart(message, actionId) strips the pause-scoped card
on successful answer submission (useResumeSubmit onSuccess) — the durable
record of the Q&A is the ask_user_question tool call itself. Pure helper +
specs; same-reference no-op when nothing matches.

* fix: displace the synthetic question card in the streaming content writer

The store-level strip on answer submit wasn't enough: the SSE step handler
keeps its own in-flight copy of the streaming message, so on the answering
device the synthetic ask-user-question card still occupied the ABSOLUTE index
the resumed segment streams into — every delta warned 'Content type mismatch'
(existing ask_user_question vs incoming text) and nothing rendered between the
pending_action and finalize.

Displace the card inside updateContent when any real part claims its slot —
the same displacement pattern as the OAuth prompt part directly above it.
Covers the streaming handler's own copy, reconnecting tabs, and other devices;
once real content streams, the pause is over by definition. Spec drives a
runStep + text delta into the card's index and pins: no mismatch warn, card
gone, text rendered.

* feat: dedicated UI + durable data for completed ask_user_question calls

The completed ask call rendered as a generic tool card labeled 'Cancelled'
with raw (and empty) JSON args. Two layers fixed:

Data: the saved tool_call part had args:'' and no output — streamed arg
chunks carry no tool name so the aggregator drops them (normal tools recover
via the completion event, which never fires for a tool that interrupts
mid-execution and resumes on a rebuilt run with no step id). The resume
controller now stamps the paused ask part with the pendingAction's
authoritative question as args and the user's answer as output
(attachAskUserQuestionAnswer — pure, targets the newest unanswered ask part,
so sequential questions each keep their own answer).

UI: Part.tsx routes ask_user_question tool calls to AskUserQuestionCall — a
compact Q&A record ('Asked a question' header, question, description, 'You
answered: <label>' preferring the picked option's label, or 'No answer was
given' for an abandoned pause) instead of the generic card. New i18n keys;
parseAskUserQuestionArgs degrades to null on malformed model args.

* fix: single question UI per pause + immediate answer display

Two live-turn issues with the new durable Q&A card:

1. Duplicate question on ask: during a live pause the message carries BOTH the
   ask tool_call part (now rendered by AskUserQuestionCall, showing a
   misleading 'No answer was given' while paused) and the synthetic
   interactive card. The durable card now defers while the turn is live and
   unanswered (isSubmitting) — the interactive card owns the question UI until
   it's answered; an abandoned pause still shows its no-answer state once the
   turn settles.

2. 'No answer was given' after answering: the server stamps the answer onto
   the part at resume seed, but the client only received that at finalize. No
   stream emission needed — the client knows the answer it just submitted:
   resolveAskUserQuestionPart (replacing the plain strip on submit success)
   removes the synthetic card AND stamps output/progress onto the newest
   unanswered ask tool_call, seeding args from the synthetic part's question
   when the streamed args were lost — mirroring the server-side
   attachAskUserQuestionAnswer, so the Q&A record shows the answer the moment
   the user submits.

* fix: keep the Q&A record visible while the resumed segment streams

The optimistic output stamp lives in the message store, but the SSE step
handler evolves its own cached copy of the streaming message (created at turn
start) — the first resumed event overwrites the store with that copy, wiping
the stamp, so the Q&A card blinked out during streaming and only returned at
finalize.

Render-layer fallback instead of fighting the handler's copy: submitted
answers are recorded by ask tool_call id when resolveAskUserQuestionPart
stamps the part, and AskUserQuestionCall reads the recorded answer whenever
the part's own output is missing — the record survives any message-copy churn
until finalize delivers the server-stamped part.

* feat: present Ask User as a native builtin in the tools dialog

It ships with the app and pauses the run like a first-class feature, so it
belongs with the builtins (Run Code, Web Search, Memory, ...) rather than in
the third-party plugin list — while its mechanics stay exactly a plugin's:

- BuiltinId += 'ask_user_question' (documented exception: a native TOOL, not
  a capability; selection reads agent.tools, the toggle emits tool-add/remove
  patches instead of a capability field)
- buildCatalog surfaces it as a builtin gated on the same signals as before
  (tools capability on + the server lists the plugin, i.e. not admin-filtered)
  and skips it in the plugin loop so it never double-lists
- On-theme icon: lucide MessageCircleQuestion in a teal chip via the builtin
  icon map, matching the other native entries; the bespoke purple SVG and the
  manifest icon field are gone
- i18n'd name/description keys like the other builtins

* feat: composer popover for answering questions (mentions-style)

Answering moves to the composer, matching the existing mentions/prompts
popover pattern: while an ask_user_question pause is live, a popover anchors
above the textarea with the question as its header, numbered option rows
(hover/click, or ↑/↓ + Enter from the empty composer), and an × to dismiss.
The main textarea doubles as the free-form answer — its placeholder flips to
'Something else...' and form submit routes the text to the paused run as the
answer instead of starting a new turn. Dismissing (× or Escape) restores
normal sends; the inline transcript surfaces stay as before (interactive card
while paused, durable Q&A record after) so the question remains visible in
history.

- findLiveAskUserQuestion (pure, spec'd): newest unanswered synthetic part
  across the conversation IS the popover signal — applied on
  on_pending_action, stripped on answer submit, so visibility tracks the
  pause lifecycle with no extra state
- useLiveAskUserQuestion hook shared by the popover and ChatForm; dismissals
  in a recoil atom so both react
- popover only mounts on the primary composer (index 0), mirroring QuoteButton

* feat: number-key selection + return glyph in the question popover

Pressing 1-9 in the empty composer picks the matching option directly,
mirroring the numbered row chips; the highlighted row shows a return-key
glyph as the Enter affordance. Same empty-composer guard as the arrow keys —
typing a free-form answer is never intercepted.

* refactor: first-class composer answer mode (useAskAnswerMode)

Replaces the bolted-on integration (inline onSubmit interception + raw
capture-phase keydown listeners on the textarea ref) with a single hook that
owns the whole answer mode: live-question derivation, dismissal + highlighted
option (shared recoil state), option selection, free-form submit routing
(submitText returns whether it consumed the submission), and keyboard
handling (handleKeyDown returns whether it consumed the key, composed ahead
of the textarea's normal handler — no more addEventListener).

The popover is now pure rendering off the hook; ChatForm wires placeholder,
onKeyDown, and onSubmit through the same instance. Deliberately scoped to the
composer rather than useSubmitMessage: starters/prompt-commands keep new-turn
semantics (and the existing job-replacement behavior while paused).

* fix: Codex round 2 — inline answer input, approval exemption, pause-time args

F1 (composer submit unreachable while paused — isSubmitting keeps Stop shown
and useTextarea eats Enter): redesigned around it, borrowing Claude Code's
AskUserQuestion semantics. The popover now owns free-form input via an inline
'Other' row (numbered last, 'Something else…'), with select-then-confirm rows
(click/arrows/digits highlight; Submit ↵, Enter, or double-click fires; Skip
dismisses). The composer returns to being a plain composer — no placeholder
swap, no submit interception; Stop keeps meaning stop.

F2: ask_user_question is exempt from the tool-approval prompt unless the
admin explicitly lists it (allow/ask/deny all win) — approving the right to
ask a question was a pure double pause; the tool is side-effect-free.

F3: the question is stamped onto the paused ask tool_call's args at PAUSE
time (attachAskUserQuestionArgs in handleRunInterrupt), so abandoned/expired/
stopped turns persist with the question intact and the record card can render
it — previously only the answer-resume path stamped args.

* fix: fold model-supplied 'Other' options into the inline free-form row

The model can generate its own catch-all option ('Other (type your own)',
value 'other'), duplicating the popover's built-in free-form row — two
other-ish rows, one pickable as a literal answer. Two layers:

- Tool description now tells the model NOT to include catch-all options (the
  answer UI always offers free-form input on its own)
- splitOtherOption (pure, spec'd) folds a catch-all option that arrives
  anyway out of the choice rows and uses its label as the inline input's
  placeholder — conservative match (value 'other', or a label reading as a
  free-form invitation), no false positives on real choices

* fix: single question surface + clean free-form-only popover

Two live-pause confusions: (1) the inline transcript card and the composer
popover both rendered — the card now defers while the popover is up for its
action, returning as the fallback surface when the user dismisses the popover
(and in contexts without a ChatContext, where the popover can't exist);
(2) an options-less question showed a pointless numbered '1 Something else…'
row — free-form-only questions now render the inline input alone, with the
'Type your answer…' placeholder (a folded model 'Other' label still wins).

* feat: the composer is the free-form answer box (like the main chat input)

While a question pause is live, the main chat textarea composes the free-form
answer — placeholder swaps to 'Something else…' (or a folded model 'Other'
label), Enter with text submits the answer through answer-mode key handling
(composed BEFORE useTextarea's submitting-lock, so the lock can't swallow it),
and the Stop button swaps to Send (enabled despite isSubmitting) per the
select-then-confirm design. The popover slims to the question header, numbered
option rows, and Skip/Submit — its inline input is gone since the composer
owns free-form now. Dismissing the popover restores normal composer semantics
(Stop button, normal sends).

* fix: Codex round 3 + real Skip semantics

- Skip now ANSWERS instead of hiding UI (danny): it resumes the run with a
  decline notice ('The user chose not to answer this question.') so the model
  moves on — a client-side dismiss left the run paused until expiry, a hung
  turn. × / Escape remain pure dismiss (switch to the inline card surface).
- P1 (resumed approval tool indices): resumed tool_calls steps whose
  tool_call id matches a seeded UNRESOLVED part now rebind to that seeded
  slot instead of offsetting — the original part resolves in place (output
  attaches) and no duplicate appears; message steps keep the offset, so the
  text-loss fix stands. createContentIndexOffsetHandlers now takes the seed
  array; resolved seeded calls are not rebind targets.
- P2 (stale selection across questions): selection state resets when the
  live actionId changes; the vestigial inline-Other state ('other' selection
  + text atom) is gone — the composer owns free-form.
- P2 (Redis abort path loses the args stamp): the abort route re-stamps the
  question onto the ask tool_call in the reconstructed abort content, so a
  Stop-abandoned question persists with its question intact.
- P2 (malformed args crash): parseAskUserQuestionArgs normalizes untrusted
  shapes (options: {} / non-string entries) instead of throwing in render.

* feat: free-form hint in the question popover footer

Left-aligned in the footer row (opposite Skip/Submit): 'Or type your answer
below' — points open-ended answering at the composer, whose placeholder
already reads 'Something else…'.

* feat: preserve composer drafts across the answer-mode swap

The answer phase gets its own draft key (ask-answer:<actionId>), passed as a
draftId override into useAutoSave — the key change itself drives the existing
save/restore machinery, so the conversation draft (or mid-run PENDING draft)
is stashed when a question pause takes the composer and restored once the
user answers, skips, or dismisses. Ask keys are exempt from the PENDING
migration branch, which would otherwise move-and-delete the stashed draft. A
half-typed answer survives reload/navigation while its question stays live.
Answer submission (option pick, free-form, skip) resets the composer via a
new non-throwing useOptionalChatFormContext, so the swap-back restores into
an empty box even outside ChatView-less render contexts (Share/search).

* fix: rebind resumed steps for ALL seeded tool call ids

The resume controller pre-stamps the user's answer onto the seeded
ask_user_question part, so the unresolved-only rebind predicate treated
it as settled and shifted the tool's re-run step to a fresh offset slot,
leaving a duplicate ask record in streamed/saved content. Tool call ids
are provider-minted per call: a resumed step bearing a seeded id can
only be the interrupted batch re-executing, so rebinding every seeded
id is always correct.

* feat: popover UX round 4 — clickable hint, collapse, click-submit, multiSelect

- Footer hint is a button that focuses the composer; reads 'Type your answer
  below' (no 'Or') when the question has no options.
- Collapse (chevron) hides the popover WITHOUT closing the pause: answer mode
  stays live (placeholder, Enter routing, draft key), the chat card renders
  the question with a ChevronUp affordance to re-expand. x remains dismiss.
- Single-select options submit on a single click; the Submit button renders
  only for multi-select.
- multiSelect end-to-end: tool zod schema + JSON definition twin, wire type,
  client parse, popover check-chips, card toggles, record-card label mapping;
  answer = option values joined ', '; composer Enter and the multi Submit
  button both fold free-form text in with the checked values.
- Hardening from adversarial review: in-flight status guard on every submit
  path (no duplicate resumes on double-click), popover locks while
  submitting, collapsed mode disarms invisible digit/arrow steering, the
  card shares the hook's checked state while the pause is live, the card
  folds catch-all 'Other' options, record mapping is all-or-nothing to avoid
  phantom labels, composer resets only when its text was consumed or the
  draft machinery will restore the stash.

* feat: ask_user_question in model specs and ephemeral agents

A librechat.yaml modelSpec can now equip the tool the same way it equips
webSearch/executeCode/fileSearch/memory:

  modelSpecs:
    list:
      - name: my-spec
        askUserQuestion: true

loadEphemeralAgent pushes the tool name when the spec flag (or the
ephemeralAgent request flag, wired for parity) is set; everything downstream
is the existing persisted-agent machinery — createRun's hitlCapable gating,
graphTools injection, checkpointer attach, subagent strip, and the admin
filteredTools/includedTools kill switch all apply unchanged.

* feat: tense-aware Q&A record label (Asking / Asked)

Shorten the record card header per feedback: 'Asking' while the question is
still unanswered (abandoned/awaiting), 'Asked' once answered — replacing the
single 'Asked a question' label.

* fix: Codex round 4 — added-agent ask parity + preserve answer on failed resume

F1 (added.ts): mirror loadEphemeralAgent's ask_user_question branch in the
added-agent loader so a model spec's askUserQuestion flag (or the ephemeral
request flag) equips added top-level agents too, matching execute_code /
web_search / memory. Two load.spec cases added.

F3 (composer): submitAskAnswer now takes an onSuccess callback and
useAskAnswerMode defers clearing the selection/composer until the resume is
accepted. A failed resume (16k answer-cap 400, expired action, network error)
leaves status re-answerable, so wiping the composer up front lost the user's
only copy of a free-form answer; now it survives for trim/retry.

(F2 — a claimed Tools-capability bypass — was verified NOT reproducible:
agentRequestsAskUserQuestion matches only loaded instances/toolDefinitions/
toolRegistry, all capability-filtered; a raw tools string has no .name and
never triggers the install. Replied on-thread with the probe evidence.)

* fix: Codex round 5 — expired question exits answer mode so its message shows

An expired question (e.g. resume returns the stale-action 409) previously left
the popover open with locked controls and no explanation, because the chat
card — which carries the only 'this action expired' message — was suppressed
by the popover-open guard. Treat 'expired' as no longer active: the popover
closes, the composer reverts to normal, and the card becomes the sole surface
and renders the expired message. 'error' stays active (retryable).

* feat: group ask_user_question calls as their own category

A homogeneous group of ask_user_question tool calls now reads 'Asked N
questions' (present tense 'Asking N questions' while the turn streams) with a
question glyph and no raw-name suffix — mirroring the subagent 'Ran N agents'
category treatment, instead of 'Used N tools — ask_user_question'. Mixed
groups keep 'Used N tools' but humanize the suffix to 'Question' and show a
question icon for the ask entries (TOOL_FRIENDLY_NAME_KEYS + ToolIcon map).
A group only forms at count >= 2, so the plural is always grammatical.
Three ToolCallGroup.test cases cover homogeneous label/icon/suffix, present
tense while streaming, and the mixed-group fallback.

* fix: Codex round 6 — composer submit lock + abort stamp before emit

F7 (composer status lock): the ask submit status lived on ApprovalContext,
a React context mounted only around message content (ContentParts). The
PRIMARY answer surface — the composer in ChatForm — renders outside it, so
useApprovalContext returned the inert FALLBACK: status was always 'idle',
setStatus a no-op. The in-flight double-submit guard (round 4) and the
expired-exits-answer-mode fix (round 5) therefore never engaged for the
composer. Move ask submit status to a global Recoil atom (useAskSubmitStatus)
read/written by the composer, the popover, and the card alike, so a fast
double-click/Enter is actually blocked and expired/error surfaces on every
surface. Tool-approval status stays on the context (unchanged).

F5 (abort stamp before emit): the abort route re-stamped a paused
ask_user_question's args AFTER GenerationJobManager.abortJob had already
emitted the final SSE from the unstamped content, so a Redis/cross-replica
Stop left the live client showing an empty question until reload. abortJob
now takes an optional transformAbortContent applied to the persistable
content BEFORE the final event is built (and returned), so the live client
and the saved message agree. New abort.spec case + updated call assertions.

* feat: gate ask_user_question behind its own agent capability

Add a first-class AgentCapabilities.ask_user_question (in defaultAgentCapabilities,
on by default) so admins can enable/disable questions independently via
endpoints.agents.capabilities, exactly like execute_code / web_search — not
lumped under the generic tools capability.

- ToolService: both filteredTools predicates (definitions-only and instance
  loaders) gate ask_user_question on checkCapability(ask_user_question) before
  the generic tools fallthrough. When off, the tool is dropped from
  toolDefinitions/toolRegistry, so run.ts's agentRequestsAskUserQuestion (which
  keys on the loaded surface) declines to install it and attach a checkpointer —
  the capability is enforced end-to-end at the loader, no run.ts change needed.
- Tools dialog catalog: surface the ask builtin under its own capability rather
  than the generic tools one, so the UI matches the backend gate.
- Tests: ToolService capability on/off filtering + defaults membership; catalog
  builtin visibility keyed on the dedicated capability.

* style: sort imports in ToolCallGroup.test (CI import-order gate)

* fix: Codex round 7 — surface ask-answer errors in the open popover

A failed answer submission (16k reject, network error) sets the ask status to
'error', which — unlike 'expired' — deliberately keeps the question active and
retryable. But the chat card that renders the error message is suppressed while
the popover is open, so a composer/popover answer failed silently. Expose an
'errored' flag from useAskAnswerMode and render a warning line
(com_ui_ask_answer_error) in the popover, so the user gets feedback and retry
guidance without having to collapse/dismiss. It clears automatically on retry
(status flips to 'submitting').

* fix: Codex round 8 — respect IME composition before submitting answers

handleComposerKeyDown runs before useTextarea's composition guard, so with a
CJK/IME keyboard the Enter that commits an in-progress composition was being
intercepted and submitting the partial answer (and the composition buffer can
leave value empty mid-compose, mis-triggering digit/arrow steering too). Bail
at the top when composing — nativeEvent.isComposing, or key==='Process' /
keyCode===229 for Safari's inconsistent reporting — mirroring the existing
composer guard so the character commits normally.

* chore: update `@librechat/agents` to v3.2.60

* 🔧 chore: Update @opentelemetry/core to version 2.9.0 and clean up package-lock.json

* feat: digit shortcuts select options when the popover has focus

Previously a number key (1..N) only selected an option from the empty
composer (handleComposerKeyDown on the textarea) — if focus moved into the
popover (a row/Skip/Submit button clicked or tabbed to), the number keys went
dead. Add handlePopoverKeyDown, wired to the popover container's onKeyDown so
it catches digits bubbling from the focused control: a digit activates its
option exactly like a click (single-select submits, multi toggles). No
highlight/Enter dance on this path — the options are buttons whose action is
the click, and intercepting Enter would fight the focused button. Gated on
active && !locked so it no-ops while a submit is in flight.

* chore: update @librechat/agents to version 3.2.61 and @opentelemetry packages to latest versions
LogicalAbsurd pushed a commit to LogicalAbsurd/LibreChat that referenced this pull request Aug 27, 2026
…use/resume (LibreChat-AI#14139)

* feat: ask_user_question tool — agent-initiated questions with durable pause/resume

The HITL runtime merged in LibreChat-AI#13942/LibreChat-AI#14024/LibreChat-AI#14025/LibreChat-AI#14123 already ships the full
ask_user_question lifecycle (payload-agnostic handleRunInterrupt, resume
validation via mapAskUserAnswer, reconnect rehydration, and the client question
card) — but nothing ever raised the interrupt. This adds the producer:

- packages/api/agents/hitl/askUserQuestionTool.ts: LLM-callable tool whose func
  calls the SDK askUserQuestion() helper (LangGraph interrupt() from the tool
  body); zod schema with length caps mirroring AskUserQuestionRequest, plus a
  JSON-schema twin for the schema-only registry
- Registration: agentToolDefinitions, manifest.json (Tools dialog, admin
  filteredTools/includedTools kill switch), basicToolInstances, handleTools
  constructor branch
- run.ts gating: checkpointer now attaches for hitlCapable runs whose agents
  carry the ask tool even with the tool-approval policy disabled (the interrupt
  needs only durability, not humanInTheLoop/hooks); the tool is stripped
  fail-closed from non-HITL callers (OpenAI-compat/Responses) and subagent
  child configs; excluded from eager event execution (interrupts must be
  raised inside the Pregel task frame)
- resume.js: 16k length cap on the answer wire field
- e2e (real Run + FakeChatModel + LazyMongoSaver + supertest resume): tool-body
  interrupt pauses durably with NO approval policy, answer round-trips as the
  ToolMessage content, tool body re-runs once on resume, sequential questions
  re-pause

* fix: adversarial-review findings — in-graph execution, orphan prunes, endpoint scoping, real kill switch

Pre-PR multi-agent review confirmed 5 defects in the initial commit; all fixed:

1. CRITICAL — the tool never paused on the real agents endpoint: production
   loads tools definitions-only, flipping the SDK ToolNode to event-driven
   dispatch, and the host ON_TOOL_EXECUTE handler runs outside the Pregel task
   frame (under runOutsideTracing), where interrupt() throws and becomes an
   error ToolMessage. Reworked: the ask tool never rides toolDefinitions/
   toolRegistry — on HITL-capable top-level agents a real instance is supplied
   via AgentInputs.graphTools (agents#289, requires @librechat/agents > 3.2.57),
   the SDK's in-graph direct-tool seam; new production-shape e2e pins the
   event-driven mode end to end.
2. CRITICAL — ask-only runs left orphaned interrupted checkpoints (silent
   context duplication on every later turn): both orphan prunes were gated on
   toolApproval.enabled. The pre-turn prune now also fires for ask-capable
   agents (exported agentRequestsAskUserQuestion), and the abort-route prune
   fires when the aborted job carries a pendingAction.
3. MAJOR — self-spawned subagents bypassed the strip (self config resolves from
   the parent's _sourceInputs): fixed SDK-side (buildChildInputs clears
   graphTools) and the tool is now never present on child surfaces host-side.
4. MINOR — the manifest entry leaked into the Assistants tools dialog and the
   legacy plugins endpoint, where tools execute with no run to pause: new
   agentsOnly manifest flag, scoped out of both listings.
5. MINOR — filteredTools/includedTools only hid the tool from the dialog:
   now enforced at run build (strip + no checkpointer), making the admin
   filter a real kill switch for already-saved agents.

* chore: update @librechat/agents dependency to version 3.2.58 in package-lock.json and package.json files

* fix: reject agents-only tools at assistant create/update (Codex round 1)

The tools-dialog scoping keeps ask_user_question out of the assistants
LISTING, but the v1/v2 create/update handlers resolve arbitrary posted tool
strings from the shared getCachedTools map — a REST client or stale saved
payload could still attach it, and the assistants runtime executes tools with
no run to pause, so every call would error. New isAgentsOnlyTool(tool)
(manifest-driven, handles string and function-object shapes) drops such tools
with a warn at all four resolution sites (v1+v2, create+update).

* fix: offset resumed-run content indices past the pre-pause seed

A resumed run rebuilds the graph from the checkpoint, and the fresh graph
numbers content indices from its own empty contentData — starting at 0. The
resume path seeds the (also fresh) content aggregator with the pre-pause
parts at exactly those indices, so the resumed model turn collided with the
seed: type-matching parts silently MERGED (post-resume text appended into a
pre-pause text block), and type-mismatching parts (a reasoning/think part at
index 0 — any Anthropic reasoning agent) dropped EVERY delta with 'Content
type mismatch', losing the entire post-resume output from the live stream
and the saved message.

Latent since LibreChat-AI#13942 — tool-approval resumes corrupt content the same way
(probe-verified); it surfaced now because ask_user_question makes pausing a
first-class flow and reasoning models make the loss total.

- createContentIndexOffsetHandlers(handlers, offset): wraps ON_RUN_STEP
  (the single point where a content index enters the pipeline — deltas
  resolve through the aggregator's stepMap) and ON_AGENT_UPDATE's inline
  index; every other handler passes through by reference. Probe-validated:
  resumed output now lands as a new part after the paused tool call.
- resumeCompletion wires it with offset = seedContent.length.
- logToolError: a GraphInterrupt unwinding out of a tool body is the HITL
  pause working as designed — no longer logged as a Tool Error.

* fix: unblock live streaming of the resumed segment after an answer

With resume indices now ABSOLUTE (server continues after the pre-pause
parts), the synthetic ask-user-question card was squatting on exactly the
index the resumed segment streams into: applyAskUserQuestion appends the
card at the end of the message content, so on the answering device every
incoming part at that index was blocked and nothing rendered between the
answer submission and the finalize replacing the message.

removeAskUserQuestionPart(message, actionId) strips the pause-scoped card
on successful answer submission (useResumeSubmit onSuccess) — the durable
record of the Q&A is the ask_user_question tool call itself. Pure helper +
specs; same-reference no-op when nothing matches.

* fix: displace the synthetic question card in the streaming content writer

The store-level strip on answer submit wasn't enough: the SSE step handler
keeps its own in-flight copy of the streaming message, so on the answering
device the synthetic ask-user-question card still occupied the ABSOLUTE index
the resumed segment streams into — every delta warned 'Content type mismatch'
(existing ask_user_question vs incoming text) and nothing rendered between the
pending_action and finalize.

Displace the card inside updateContent when any real part claims its slot —
the same displacement pattern as the OAuth prompt part directly above it.
Covers the streaming handler's own copy, reconnecting tabs, and other devices;
once real content streams, the pause is over by definition. Spec drives a
runStep + text delta into the card's index and pins: no mismatch warn, card
gone, text rendered.

* feat: dedicated UI + durable data for completed ask_user_question calls

The completed ask call rendered as a generic tool card labeled 'Cancelled'
with raw (and empty) JSON args. Two layers fixed:

Data: the saved tool_call part had args:'' and no output — streamed arg
chunks carry no tool name so the aggregator drops them (normal tools recover
via the completion event, which never fires for a tool that interrupts
mid-execution and resumes on a rebuilt run with no step id). The resume
controller now stamps the paused ask part with the pendingAction's
authoritative question as args and the user's answer as output
(attachAskUserQuestionAnswer — pure, targets the newest unanswered ask part,
so sequential questions each keep their own answer).

UI: Part.tsx routes ask_user_question tool calls to AskUserQuestionCall — a
compact Q&A record ('Asked a question' header, question, description, 'You
answered: <label>' preferring the picked option's label, or 'No answer was
given' for an abandoned pause) instead of the generic card. New i18n keys;
parseAskUserQuestionArgs degrades to null on malformed model args.

* fix: single question UI per pause + immediate answer display

Two live-turn issues with the new durable Q&A card:

1. Duplicate question on ask: during a live pause the message carries BOTH the
   ask tool_call part (now rendered by AskUserQuestionCall, showing a
   misleading 'No answer was given' while paused) and the synthetic
   interactive card. The durable card now defers while the turn is live and
   unanswered (isSubmitting) — the interactive card owns the question UI until
   it's answered; an abandoned pause still shows its no-answer state once the
   turn settles.

2. 'No answer was given' after answering: the server stamps the answer onto
   the part at resume seed, but the client only received that at finalize. No
   stream emission needed — the client knows the answer it just submitted:
   resolveAskUserQuestionPart (replacing the plain strip on submit success)
   removes the synthetic card AND stamps output/progress onto the newest
   unanswered ask tool_call, seeding args from the synthetic part's question
   when the streamed args were lost — mirroring the server-side
   attachAskUserQuestionAnswer, so the Q&A record shows the answer the moment
   the user submits.

* fix: keep the Q&A record visible while the resumed segment streams

The optimistic output stamp lives in the message store, but the SSE step
handler evolves its own cached copy of the streaming message (created at turn
start) — the first resumed event overwrites the store with that copy, wiping
the stamp, so the Q&A card blinked out during streaming and only returned at
finalize.

Render-layer fallback instead of fighting the handler's copy: submitted
answers are recorded by ask tool_call id when resolveAskUserQuestionPart
stamps the part, and AskUserQuestionCall reads the recorded answer whenever
the part's own output is missing — the record survives any message-copy churn
until finalize delivers the server-stamped part.

* feat: present Ask User as a native builtin in the tools dialog

It ships with the app and pauses the run like a first-class feature, so it
belongs with the builtins (Run Code, Web Search, Memory, ...) rather than in
the third-party plugin list — while its mechanics stay exactly a plugin's:

- BuiltinId += 'ask_user_question' (documented exception: a native TOOL, not
  a capability; selection reads agent.tools, the toggle emits tool-add/remove
  patches instead of a capability field)
- buildCatalog surfaces it as a builtin gated on the same signals as before
  (tools capability on + the server lists the plugin, i.e. not admin-filtered)
  and skips it in the plugin loop so it never double-lists
- On-theme icon: lucide MessageCircleQuestion in a teal chip via the builtin
  icon map, matching the other native entries; the bespoke purple SVG and the
  manifest icon field are gone
- i18n'd name/description keys like the other builtins

* feat: composer popover for answering questions (mentions-style)

Answering moves to the composer, matching the existing mentions/prompts
popover pattern: while an ask_user_question pause is live, a popover anchors
above the textarea with the question as its header, numbered option rows
(hover/click, or ↑/↓ + Enter from the empty composer), and an × to dismiss.
The main textarea doubles as the free-form answer — its placeholder flips to
'Something else...' and form submit routes the text to the paused run as the
answer instead of starting a new turn. Dismissing (× or Escape) restores
normal sends; the inline transcript surfaces stay as before (interactive card
while paused, durable Q&A record after) so the question remains visible in
history.

- findLiveAskUserQuestion (pure, spec'd): newest unanswered synthetic part
  across the conversation IS the popover signal — applied on
  on_pending_action, stripped on answer submit, so visibility tracks the
  pause lifecycle with no extra state
- useLiveAskUserQuestion hook shared by the popover and ChatForm; dismissals
  in a recoil atom so both react
- popover only mounts on the primary composer (index 0), mirroring QuoteButton

* feat: number-key selection + return glyph in the question popover

Pressing 1-9 in the empty composer picks the matching option directly,
mirroring the numbered row chips; the highlighted row shows a return-key
glyph as the Enter affordance. Same empty-composer guard as the arrow keys —
typing a free-form answer is never intercepted.

* refactor: first-class composer answer mode (useAskAnswerMode)

Replaces the bolted-on integration (inline onSubmit interception + raw
capture-phase keydown listeners on the textarea ref) with a single hook that
owns the whole answer mode: live-question derivation, dismissal + highlighted
option (shared recoil state), option selection, free-form submit routing
(submitText returns whether it consumed the submission), and keyboard
handling (handleKeyDown returns whether it consumed the key, composed ahead
of the textarea's normal handler — no more addEventListener).

The popover is now pure rendering off the hook; ChatForm wires placeholder,
onKeyDown, and onSubmit through the same instance. Deliberately scoped to the
composer rather than useSubmitMessage: starters/prompt-commands keep new-turn
semantics (and the existing job-replacement behavior while paused).

* fix: Codex round 2 — inline answer input, approval exemption, pause-time args

F1 (composer submit unreachable while paused — isSubmitting keeps Stop shown
and useTextarea eats Enter): redesigned around it, borrowing Claude Code's
AskUserQuestion semantics. The popover now owns free-form input via an inline
'Other' row (numbered last, 'Something else…'), with select-then-confirm rows
(click/arrows/digits highlight; Submit ↵, Enter, or double-click fires; Skip
dismisses). The composer returns to being a plain composer — no placeholder
swap, no submit interception; Stop keeps meaning stop.

F2: ask_user_question is exempt from the tool-approval prompt unless the
admin explicitly lists it (allow/ask/deny all win) — approving the right to
ask a question was a pure double pause; the tool is side-effect-free.

F3: the question is stamped onto the paused ask tool_call's args at PAUSE
time (attachAskUserQuestionArgs in handleRunInterrupt), so abandoned/expired/
stopped turns persist with the question intact and the record card can render
it — previously only the answer-resume path stamped args.

* fix: fold model-supplied 'Other' options into the inline free-form row

The model can generate its own catch-all option ('Other (type your own)',
value 'other'), duplicating the popover's built-in free-form row — two
other-ish rows, one pickable as a literal answer. Two layers:

- Tool description now tells the model NOT to include catch-all options (the
  answer UI always offers free-form input on its own)
- splitOtherOption (pure, spec'd) folds a catch-all option that arrives
  anyway out of the choice rows and uses its label as the inline input's
  placeholder — conservative match (value 'other', or a label reading as a
  free-form invitation), no false positives on real choices

* fix: single question surface + clean free-form-only popover

Two live-pause confusions: (1) the inline transcript card and the composer
popover both rendered — the card now defers while the popover is up for its
action, returning as the fallback surface when the user dismisses the popover
(and in contexts without a ChatContext, where the popover can't exist);
(2) an options-less question showed a pointless numbered '1 Something else…'
row — free-form-only questions now render the inline input alone, with the
'Type your answer…' placeholder (a folded model 'Other' label still wins).

* feat: the composer is the free-form answer box (like the main chat input)

While a question pause is live, the main chat textarea composes the free-form
answer — placeholder swaps to 'Something else…' (or a folded model 'Other'
label), Enter with text submits the answer through answer-mode key handling
(composed BEFORE useTextarea's submitting-lock, so the lock can't swallow it),
and the Stop button swaps to Send (enabled despite isSubmitting) per the
select-then-confirm design. The popover slims to the question header, numbered
option rows, and Skip/Submit — its inline input is gone since the composer
owns free-form now. Dismissing the popover restores normal composer semantics
(Stop button, normal sends).

* fix: Codex round 3 + real Skip semantics

- Skip now ANSWERS instead of hiding UI (danny): it resumes the run with a
  decline notice ('The user chose not to answer this question.') so the model
  moves on — a client-side dismiss left the run paused until expiry, a hung
  turn. × / Escape remain pure dismiss (switch to the inline card surface).
- P1 (resumed approval tool indices): resumed tool_calls steps whose
  tool_call id matches a seeded UNRESOLVED part now rebind to that seeded
  slot instead of offsetting — the original part resolves in place (output
  attaches) and no duplicate appears; message steps keep the offset, so the
  text-loss fix stands. createContentIndexOffsetHandlers now takes the seed
  array; resolved seeded calls are not rebind targets.
- P2 (stale selection across questions): selection state resets when the
  live actionId changes; the vestigial inline-Other state ('other' selection
  + text atom) is gone — the composer owns free-form.
- P2 (Redis abort path loses the args stamp): the abort route re-stamps the
  question onto the ask tool_call in the reconstructed abort content, so a
  Stop-abandoned question persists with its question intact.
- P2 (malformed args crash): parseAskUserQuestionArgs normalizes untrusted
  shapes (options: {} / non-string entries) instead of throwing in render.

* feat: free-form hint in the question popover footer

Left-aligned in the footer row (opposite Skip/Submit): 'Or type your answer
below' — points open-ended answering at the composer, whose placeholder
already reads 'Something else…'.

* feat: preserve composer drafts across the answer-mode swap

The answer phase gets its own draft key (ask-answer:<actionId>), passed as a
draftId override into useAutoSave — the key change itself drives the existing
save/restore machinery, so the conversation draft (or mid-run PENDING draft)
is stashed when a question pause takes the composer and restored once the
user answers, skips, or dismisses. Ask keys are exempt from the PENDING
migration branch, which would otherwise move-and-delete the stashed draft. A
half-typed answer survives reload/navigation while its question stays live.
Answer submission (option pick, free-form, skip) resets the composer via a
new non-throwing useOptionalChatFormContext, so the swap-back restores into
an empty box even outside ChatView-less render contexts (Share/search).

* fix: rebind resumed steps for ALL seeded tool call ids

The resume controller pre-stamps the user's answer onto the seeded
ask_user_question part, so the unresolved-only rebind predicate treated
it as settled and shifted the tool's re-run step to a fresh offset slot,
leaving a duplicate ask record in streamed/saved content. Tool call ids
are provider-minted per call: a resumed step bearing a seeded id can
only be the interrupted batch re-executing, so rebinding every seeded
id is always correct.

* feat: popover UX round 4 — clickable hint, collapse, click-submit, multiSelect

- Footer hint is a button that focuses the composer; reads 'Type your answer
  below' (no 'Or') when the question has no options.
- Collapse (chevron) hides the popover WITHOUT closing the pause: answer mode
  stays live (placeholder, Enter routing, draft key), the chat card renders
  the question with a ChevronUp affordance to re-expand. x remains dismiss.
- Single-select options submit on a single click; the Submit button renders
  only for multi-select.
- multiSelect end-to-end: tool zod schema + JSON definition twin, wire type,
  client parse, popover check-chips, card toggles, record-card label mapping;
  answer = option values joined ', '; composer Enter and the multi Submit
  button both fold free-form text in with the checked values.
- Hardening from adversarial review: in-flight status guard on every submit
  path (no duplicate resumes on double-click), popover locks while
  submitting, collapsed mode disarms invisible digit/arrow steering, the
  card shares the hook's checked state while the pause is live, the card
  folds catch-all 'Other' options, record mapping is all-or-nothing to avoid
  phantom labels, composer resets only when its text was consumed or the
  draft machinery will restore the stash.

* feat: ask_user_question in model specs and ephemeral agents

A librechat.yaml modelSpec can now equip the tool the same way it equips
webSearch/executeCode/fileSearch/memory:

  modelSpecs:
    list:
      - name: my-spec
        askUserQuestion: true

loadEphemeralAgent pushes the tool name when the spec flag (or the
ephemeralAgent request flag, wired for parity) is set; everything downstream
is the existing persisted-agent machinery — createRun's hitlCapable gating,
graphTools injection, checkpointer attach, subagent strip, and the admin
filteredTools/includedTools kill switch all apply unchanged.

* feat: tense-aware Q&A record label (Asking / Asked)

Shorten the record card header per feedback: 'Asking' while the question is
still unanswered (abandoned/awaiting), 'Asked' once answered — replacing the
single 'Asked a question' label.

* fix: Codex round 4 — added-agent ask parity + preserve answer on failed resume

F1 (added.ts): mirror loadEphemeralAgent's ask_user_question branch in the
added-agent loader so a model spec's askUserQuestion flag (or the ephemeral
request flag) equips added top-level agents too, matching execute_code /
web_search / memory. Two load.spec cases added.

F3 (composer): submitAskAnswer now takes an onSuccess callback and
useAskAnswerMode defers clearing the selection/composer until the resume is
accepted. A failed resume (16k answer-cap 400, expired action, network error)
leaves status re-answerable, so wiping the composer up front lost the user's
only copy of a free-form answer; now it survives for trim/retry.

(F2 — a claimed Tools-capability bypass — was verified NOT reproducible:
agentRequestsAskUserQuestion matches only loaded instances/toolDefinitions/
toolRegistry, all capability-filtered; a raw tools string has no .name and
never triggers the install. Replied on-thread with the probe evidence.)

* fix: Codex round 5 — expired question exits answer mode so its message shows

An expired question (e.g. resume returns the stale-action 409) previously left
the popover open with locked controls and no explanation, because the chat
card — which carries the only 'this action expired' message — was suppressed
by the popover-open guard. Treat 'expired' as no longer active: the popover
closes, the composer reverts to normal, and the card becomes the sole surface
and renders the expired message. 'error' stays active (retryable).

* feat: group ask_user_question calls as their own category

A homogeneous group of ask_user_question tool calls now reads 'Asked N
questions' (present tense 'Asking N questions' while the turn streams) with a
question glyph and no raw-name suffix — mirroring the subagent 'Ran N agents'
category treatment, instead of 'Used N tools — ask_user_question'. Mixed
groups keep 'Used N tools' but humanize the suffix to 'Question' and show a
question icon for the ask entries (TOOL_FRIENDLY_NAME_KEYS + ToolIcon map).
A group only forms at count >= 2, so the plural is always grammatical.
Three ToolCallGroup.test cases cover homogeneous label/icon/suffix, present
tense while streaming, and the mixed-group fallback.

* fix: Codex round 6 — composer submit lock + abort stamp before emit

F7 (composer status lock): the ask submit status lived on ApprovalContext,
a React context mounted only around message content (ContentParts). The
PRIMARY answer surface — the composer in ChatForm — renders outside it, so
useApprovalContext returned the inert FALLBACK: status was always 'idle',
setStatus a no-op. The in-flight double-submit guard (round 4) and the
expired-exits-answer-mode fix (round 5) therefore never engaged for the
composer. Move ask submit status to a global Recoil atom (useAskSubmitStatus)
read/written by the composer, the popover, and the card alike, so a fast
double-click/Enter is actually blocked and expired/error surfaces on every
surface. Tool-approval status stays on the context (unchanged).

F5 (abort stamp before emit): the abort route re-stamped a paused
ask_user_question's args AFTER GenerationJobManager.abortJob had already
emitted the final SSE from the unstamped content, so a Redis/cross-replica
Stop left the live client showing an empty question until reload. abortJob
now takes an optional transformAbortContent applied to the persistable
content BEFORE the final event is built (and returned), so the live client
and the saved message agree. New abort.spec case + updated call assertions.

* feat: gate ask_user_question behind its own agent capability

Add a first-class AgentCapabilities.ask_user_question (in defaultAgentCapabilities,
on by default) so admins can enable/disable questions independently via
endpoints.agents.capabilities, exactly like execute_code / web_search — not
lumped under the generic tools capability.

- ToolService: both filteredTools predicates (definitions-only and instance
  loaders) gate ask_user_question on checkCapability(ask_user_question) before
  the generic tools fallthrough. When off, the tool is dropped from
  toolDefinitions/toolRegistry, so run.ts's agentRequestsAskUserQuestion (which
  keys on the loaded surface) declines to install it and attach a checkpointer —
  the capability is enforced end-to-end at the loader, no run.ts change needed.
- Tools dialog catalog: surface the ask builtin under its own capability rather
  than the generic tools one, so the UI matches the backend gate.
- Tests: ToolService capability on/off filtering + defaults membership; catalog
  builtin visibility keyed on the dedicated capability.

* style: sort imports in ToolCallGroup.test (CI import-order gate)

* fix: Codex round 7 — surface ask-answer errors in the open popover

A failed answer submission (16k reject, network error) sets the ask status to
'error', which — unlike 'expired' — deliberately keeps the question active and
retryable. But the chat card that renders the error message is suppressed while
the popover is open, so a composer/popover answer failed silently. Expose an
'errored' flag from useAskAnswerMode and render a warning line
(com_ui_ask_answer_error) in the popover, so the user gets feedback and retry
guidance without having to collapse/dismiss. It clears automatically on retry
(status flips to 'submitting').

* fix: Codex round 8 — respect IME composition before submitting answers

handleComposerKeyDown runs before useTextarea's composition guard, so with a
CJK/IME keyboard the Enter that commits an in-progress composition was being
intercepted and submitting the partial answer (and the composition buffer can
leave value empty mid-compose, mis-triggering digit/arrow steering too). Bail
at the top when composing — nativeEvent.isComposing, or key==='Process' /
keyCode===229 for Safari's inconsistent reporting — mirroring the existing
composer guard so the character commits normally.

* chore: update `@librechat/agents` to v3.2.60

* 🔧 chore: Update @opentelemetry/core to version 2.9.0 and clean up package-lock.json

* feat: digit shortcuts select options when the popover has focus

Previously a number key (1..N) only selected an option from the empty
composer (handleComposerKeyDown on the textarea) — if focus moved into the
popover (a row/Skip/Submit button clicked or tabbed to), the number keys went
dead. Add handlePopoverKeyDown, wired to the popover container's onKeyDown so
it catches digits bubbling from the focused control: a digit activates its
option exactly like a click (single-select submits, multi toggles). No
highlight/Enter dance on this path — the options are buttons whose action is
the click, and intercepting Enter would fight the focused button. Gated on
active && !locked so it no-ops while a submit is in flight.

* chore: update @librechat/agents to version 3.2.61 and @opentelemetry packages to latest versions
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