diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index 5b5b140e8a..df01b6bbb2 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -95,6 +95,7 @@ "pluginKey": "ask_user_question", "description": "Let the agent pause mid-run to ask you a clarifying question and wait for your answer.", "icon": "assets/ask-user-question.svg", + "agentsOnly": true, "authConfig": [] }, { diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index 7bb21a7c58..15e952cfb8 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -22,6 +22,11 @@ const getAvailablePluginsController = async (req, res) => { /** includedTools takes precedence — filteredTools ignored when both are set. */ const plugins = []; for (const plugin of uniquePlugins) { + /** Agents-runtime-only tools (e.g. ask_user_question) never work on the + * legacy plugins endpoint — no run to pause, no resume surface. */ + if (plugin.agentsOnly === true) { + continue; + } if (includeSet.size > 0) { if (!includeSet.has(plugin.pluginKey)) { continue; @@ -66,8 +71,21 @@ const getAvailableTools = async (req, res) => { const toolDefKeysList = toolDefinitions ? Object.keys(toolDefinitions) : null; const toolDefKeys = toolDefKeysList ? new Set(toolDefKeysList) : null; + /** + * `getAvailableTools` serves BOTH tool dialogs — /api/agents/tools and + * /api/assistants/tools. Tools flagged `agentsOnly` in the manifest (e.g. + * ask_user_question, which pauses an agents run via a LangGraph interrupt) + * cannot work on the assistants runtime: it executes tools directly with no + * run to pause and no resume surface, so attaching one there guarantees a + * permanent tool error. Scope them out of the assistants listing by route. + */ + const isAssistantsRoute = req.baseUrl?.includes('/assistants') === true; + const toolsOutput = []; for (const plugin of uniquePlugins) { + if (plugin.agentsOnly === true && isAssistantsRoute) { + continue; + } const isToolDefined = toolDefKeys?.has(plugin.pluginKey) === true; const isToolkit = plugin.toolkit === true && diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js index b392ab575d..d5d2369b0b 100644 --- a/api/server/controllers/PluginController.spec.js +++ b/api/server/controllers/PluginController.spec.js @@ -90,6 +90,18 @@ describe('PluginController', () => { expect(responseData[0].authenticated).toBeUndefined(); }); + it('excludes agentsOnly plugins from the legacy plugins endpoint (no run to pause)', async () => { + require('~/app/clients/tools').availableTools.push( + { name: 'Ask User', pluginKey: 'ask_user_question', description: 'q', agentsOnly: true }, + { name: 'Plugin2', pluginKey: 'key2', description: 'Second' }, + ); + + await getAvailablePluginsController(mockReq, mockRes); + + const responseData = mockRes.json.mock.calls[0][0]; + expect(responseData.map((p) => p.pluginKey)).toEqual(['key2']); + }); + it('should filter plugins based on includedTools', async () => { const mockPlugins = [ { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, @@ -153,6 +165,36 @@ describe('PluginController', () => { }); describe('getAvailableTools', () => { + it('scopes agentsOnly plugins out of the ASSISTANTS listing but keeps them for agents', async () => { + const cached = { + ask_user_question: { + type: 'function', + function: { name: 'ask_user_question', description: 'q', parameters: {} }, + }, + }; + require('~/app/clients/tools').availableTools.push({ + name: 'Ask User', + pluginKey: 'ask_user_question', + description: 'q', + agentsOnly: true, + }); + + // Agents route: listed. + getCachedTools.mockResolvedValueOnce(cached); + mockReq.baseUrl = '/api/agents/tools'; + await getAvailableTools(mockReq, mockRes); + expect(mockRes.json.mock.calls[0][0].map((t) => t.pluginKey)).toContain('ask_user_question'); + + // Assistants route: the runtime executes tools with no run to pause — excluded. + mockRes.json.mockClear(); + getCachedTools.mockResolvedValueOnce(cached); + mockReq.baseUrl = '/api/assistants/v2/tools'; + await getAvailableTools(mockReq, mockRes); + expect(mockRes.json.mock.calls[0][0].map((t) => t.pluginKey)).not.toContain( + 'ask_user_question', + ); + }); + it('should use filterUniquePlugins to deduplicate combined tools', async () => { const mockUserTools = { 'user-tool': { diff --git a/api/server/controllers/agents/__tests__/askUserQuestion.e2e.spec.js b/api/server/controllers/agents/__tests__/askUserQuestion.e2e.spec.js index a77a34069a..1976ae69a6 100644 --- a/api/server/controllers/agents/__tests__/askUserQuestion.e2e.spec.js +++ b/api/server/controllers/agents/__tests__/askUserQuestion.e2e.spec.js @@ -121,6 +121,44 @@ async function buildAskRun({ saver, responses, toolCalls, runId }) { return run; } +/** + * Build a REAL run in the PRODUCTION shape: the agents endpoint loads tools + * definitions-only, so the run is EVENT-DRIVEN (`toolDefinitions` non-empty flips + * the SDK ToolNode to event dispatch) and the ask tool rides `graphTools` — the + * SDK's in-graph direct-tool seam (agents#289, > 3.2.57) — because an event- + * dispatched tool body executes in the host handler outside the Pregel task + * frame, where `interrupt()` throws instead of pausing. This is the mode + * `createRun` produces via `buildAgentInput`; the traditional-mode harness above + * covers runs with zero toolDefinitions. + */ +async function buildAskRunEventMode({ saver, responses, toolCalls, runId }) { + const run = await Run.create({ + runId, + graphConfig: { + type: 'standard', + agents: [ + { + agentId: 'agent-ask-event', + provider: Providers.OPENAI, + clientOptions: { model: 'gpt-4o-mini', streaming: true, streamUsage: false }, + instructions: 'You are a helpful assistant.', + maxContextTokens: 8000, + toolDefinitions: [{ name: 'dummy_event_tool', description: 'host-executed event tool' }], + graphTools: [askTool], + }, + ], + compileOptions: { checkpointer: saver }, + }, + returnContent: true, + customHandlers: {}, + tokenCounter: (text) => String(text ?? '').length, + indexTokenCountMap: {}, + eagerEventToolExecution: { enabled: true, excludeToolNames: [ASK_TOOL] }, + }); + run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls }); + return run; +} + const runConfig = (conversationId) => ({ runName: 'AgentRun', configurable: { thread_id: conversationId, user_id: USER_ID }, @@ -308,6 +346,101 @@ describe('ask_user_question lifecycle (full wiring, approval policy disabled)', expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 }); }); + test('EVENT-DRIVEN mode (production shape): the graphTools ask tool pauses and resumes over the REAL /resume controller', async () => { + const conversationId = `ask-e2e-event-${Date.now()}`; + const responseMessageId = 'resp-ask-event-1'; + + const run = await buildAskRunEventMode({ + saver, + responses: ['Let me check with you.'], + toolCalls: [ + { + name: ASK_TOOL, + args: { question: 'Proceed with the migration?' }, + id: 'tc_ask_ev1', + type: 'tool_call', + }, + ], + runId: responseMessageId, + }); + await run.processStream( + { messages: [new HumanMessage('run the migration')] }, + runConfig(conversationId), + ); + + const interrupt = run.getInterrupt(); + expect(interrupt?.payload?.type).toBe('ask_user_question'); + expect(interrupt.payload.question).toEqual({ question: 'Proceed with the migration?' }); + expect(bodyRuns).toBe(1); + expect((await checkpointCounts(conversationId)).checkpoints).toBeGreaterThan(0); + + await GenerationJobManager.createJob(conversationId, USER_ID, conversationId); + await GenerationJobManager.updateMetadata(conversationId, { + endpoint: 'agents', + agent_id: 'agent-ask-e2e', + responseMessageId, + }); + const pendingAction = buildPendingAction(interrupt.payload, { + streamId: conversationId, + conversationId, + runId: responseMessageId, + responseMessageId, + ttlMs: 60_000, + }); + expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true); + + const thinClient = { + contentParts: [], + artifactPromises: [], + conversationId, + responseMessageId, + pendingApproval: null, + async resumeCompletion({ resumeValue, abortController }) { + const resumed = await buildAskRunEventMode({ + saver, + responses: ['Migration underway.'], + runId: responseMessageId, + }); + await resumed.resume(resumeValue, { + ...runConfig(conversationId), + signal: (abortController ?? new AbortController()).signal, + }); + this.contentParts.push({ type: 'text', text: 'Migration underway.' }); + return resumed; + }, + }; + const initializeClient = jest.fn(async () => ({ client: thinClient })); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: USER_ID }; + req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} }; + next(); + }); + app.post('/api/agents/chat/resume', (req, res, next) => + ResumeAgentController(req, res, next, initializeClient, jest.fn()), + ); + + const response = await request(app).post('/api/agents/chat/resume').send({ + conversationId, + actionId: pendingAction.actionId, + agent_id: 'agent-ask-e2e', + endpoint: 'agents', + answer: 'yes, proceed', + }); + + expect(response.status).toBe(200); + await waitFor(async () => { + const liveJob = await GenerationJobManager.getJob(conversationId); + return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running'; + }); + + expect(bodyRuns).toBe(2); + expect(resolvedAnswers).toEqual(['yes, proceed']); + await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0); + }); + test('a second question raised after resume re-pauses with a fresh ask_user_question interrupt', async () => { const conversationId = `ask-e2e-seq-${Date.now()}`; diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 9581e27c5c..1ebf4d5aba 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -42,6 +42,7 @@ const { getApprovalTtlMs, isHITLEnabled, deleteAgentCheckpoint, + agentRequestsAskUserQuestion, getRequestMemories, createMemoryProcessor, agentHasInlineMemoryTools, @@ -1625,7 +1626,14 @@ class AgentClient extends BaseClient { // a Redis flag) can go stale across replicas/restarts and skip the prune // exactly when an orphan exists, while these are two indexed, usually-empty // deleteMany ops — correctness over a micro-optimization. - if (streamId && isHITLEnabled(agentsEConfig?.toolApproval)) { + // The gate mirrors createRun's checkpointer condition: the approval policy + // OR an ask_user_question-capable agent (which attaches a checkpointer + // WITHOUT the approval policy) — an ask pause abandoned via job replacement + // or Stop would otherwise rehydrate here and silently duplicate context. + if ( + streamId && + (isHITLEnabled(agentsEConfig?.toolApproval) || agents.some(agentRequestsAskUserQuestion)) + ) { await deleteAgentCheckpoint(this.conversationId, agentsEConfig?.checkpointer); } diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 5776b31e9e..f479ab4b3d 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -298,9 +298,12 @@ router.post('/chat/abort', configMiddleware, async (req, res) => { // HITL: prune the durable checkpoint of a run aborted while paused, so a new turn // in this conversation can't rehydrate the stale interrupt before the Mongo TTL // reclaims it (thread_id is the stable conversationId). Idempotent / no-op when - // HITL is off or nothing was written. + // HITL is off or nothing was written. The pendingAction check covers ask-only + // pauses (ask_user_question attaches a checkpointer WITHOUT the approval policy): + // a job aborted while paused still carries its pendingAction in metadata, which is + // exactly the case whose checkpoint would otherwise go stale. const agentsCfg = req.config?.endpoints?.agents; - if (isHITLEnabled(agentsCfg?.toolApproval)) { + if (isHITLEnabled(agentsCfg?.toolApproval) || job.metadata?.pendingAction != null) { await deleteAgentCheckpoint(jobStreamId, agentsCfg?.checkpointer).catch((err) => logger.error(`[AgentStream] Failed to prune checkpoint on abort: ${jobStreamId}`, err), ); diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 858bde9d6b..b224c714dd 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -2056,8 +2056,11 @@ describe('ask_user_question run wiring', () => { expect(config).not.toHaveProperty('humanInTheLoop'); expect(config).not.toHaveProperty('hooks'); expect(getCheckpointer(config)).toBeDefined(); - // The tool itself survives on the HITL-capable path. - expect((firstAgent(config).tools as Array<{ name: string }>).map((t) => t.name)).toContain(ASK); + const agent = firstAgent(config); + // The tool rides the in-graph direct path (graphTools) — never the + // event-dispatched surfaces, where interrupt() cannot pause the run. + expect((agent.graphTools as Array<{ name: string }>).map((t) => t.name)).toEqual([ASK]); + expect((agent.tools as Array<{ name: string }>).map((t) => t.name)).not.toContain(ASK); }); it('detects the tool via toolRegistry / toolDefinitions too', async () => { @@ -2115,15 +2118,18 @@ describe('ask_user_question run wiring', () => { subagentAgentConfigs: [child], }); const config = await runAndGetConfig(parent, { hitlCapable: true }); - // Parent keeps the tool (and gets the checkpointer)… - expect((firstAgent(config).tools as Array<{ name: string }>).map((t) => t.name)).toContain(ASK); + // Parent keeps the tool — as an in-graph direct tool — and gets the checkpointer… + expect((firstAgent(config).graphTools as Array<{ name: string }>).map((t) => t.name)).toEqual([ + ASK, + ]); expect(getCheckpointer(config)).toBeDefined(); - // …the child copy is stripped everywhere. + // …the child copy is stripped everywhere, with no graphTools replacement. const subagentConfigs = firstAgent(config).subagentConfigs as Array<{ agentInputs: Record; }>; expect(subagentConfigs).toHaveLength(1); const childInputs = subagentConfigs[0].agentInputs; + expect(childInputs.graphTools).toBeUndefined(); expect((childInputs.tools as Array<{ name: string }>).map((t) => t.name)).not.toContain(ASK); expect((childInputs.toolDefinitions as Array<{ name: string }>).map((d) => d.name)).toEqual([]); expect((childInputs.toolRegistry as Map).has(ASK)).toBe(false); @@ -2145,6 +2151,72 @@ describe('ask_user_question run wiring', () => { expect(eager.excludeToolNames).toContain(ASK); }); + it('admin filteredTools is a real kill switch: strips the tool and blocks the checkpointer even on an HITL-capable run', async () => { + const filteredConfig = { + ...(plainAppConfig as unknown as Record), + filteredTools: [ASK], + } as unknown as AppConfig; + await createRun({ + agents: [ + makeAgent({ + tools: [askToolInstance], + toolDefinitions: [{ name: ASK }], + toolRegistry: new Map([[ASK, { name: ASK }]]), + }), + ] as never, + signal: new AbortController().signal, + appConfig: filteredConfig, + streaming: true, + streamUsage: true, + hitlCapable: true, + }); + const config = (Run.create as jest.Mock).mock.calls[0][0] as Record; + expect(getCheckpointer(config)).toBeUndefined(); + const agent = firstAgent(config); + expect((agent.tools as Array<{ name: string }>).map((t) => t.name)).toEqual([]); + expect((agent.toolDefinitions as Array<{ name: string }>).map((d) => d.name)).toEqual([]); + expect((agent.toolRegistry as Map).has(ASK)).toBe(false); + }); + + it('an includedTools allowlist disables the tool unless listed (allowlist precedence)', async () => { + const withoutTool = { + ...(plainAppConfig as unknown as Record), + includedTools: ['calculator'], + } as unknown as AppConfig; + await createRun({ + agents: [makeAgent({ tools: [askToolInstance] })] as never, + signal: new AbortController().signal, + appConfig: withoutTool, + streaming: true, + streamUsage: true, + hitlCapable: true, + }); + let config = (Run.create as jest.Mock).mock.calls[0][0] as Record; + expect(getCheckpointer(config)).toBeUndefined(); + expect((firstAgent(config).tools as Array<{ name: string }>).map((t) => t.name)).toEqual([]); + + jest.clearAllMocks(); + const withTool = { + ...(plainAppConfig as unknown as Record), + // includedTools wins over filteredTools — same precedence as loadAndFormatTools. + includedTools: [ASK], + filteredTools: [ASK], + } as unknown as AppConfig; + await createRun({ + agents: [makeAgent({ tools: [askToolInstance] })] as never, + signal: new AbortController().signal, + appConfig: withTool, + streaming: true, + streamUsage: true, + hitlCapable: true, + }); + config = (Run.create as jest.Mock).mock.calls[0][0] as Record; + expect(getCheckpointer(config)).toBeDefined(); + expect((firstAgent(config).graphTools as Array<{ name: string }>).map((t) => t.name)).toEqual([ + ASK, + ]); + }); + it('composes with the approval policy: both humanInTheLoop and the checkpointer attach', async () => { const approvalConfig = { config: {}, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 9086e50c69..e96f5d58b3 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -34,8 +34,11 @@ import type { BaseMessage } from '@librechat/agents/langchain/messages'; import type { AppConfig, IUser } from '@librechat/data-schemas'; import type { SubagentUsageEvent } from '~/agents/usage'; import type * as t from '~/types'; +import { + ASK_USER_QUESTION_TOOL_NAME, + createAskUserQuestionTool, +} from '~/agents/hitl/askUserQuestionTool'; import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm'; -import { ASK_USER_QUESTION_TOOL_NAME } from '~/agents/hitl/askUserQuestionTool'; import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools'; import { getProviderConfig } from '~/endpoints/config/providers'; import { resolveToolApprovalPolicy } from '~/agents/hitl/policy'; @@ -738,8 +741,16 @@ function anyAgentHasCodeEnv(agents: RunAgent[]): boolean { * Checked against TOP-LEVEL agents only (not subagents — the tool is stripped from * child configs in `buildAgentInput`, since a child graph executing outside the parent * run's stream cannot pause the parent). + * + * Exported for AgentClient's pre-turn orphan-checkpoint prune gate: the prune must + * fire whenever THIS turn may attach a checkpointer, which since the ask tool is no + * longer coupled to `toolApproval.enabled` includes ask-capable runs. */ -function agentRequestsAskUserQuestion(agent: RunAgent): boolean { +export function agentRequestsAskUserQuestion(agent: { + tools?: unknown[]; + toolRegistry?: Map; + toolDefinitions?: Array<{ name: string }>; +}): boolean { return ( agent.tools?.some( (tool) => (tool as { name?: string } | undefined)?.name === ASK_USER_QUESTION_TOOL_NAME, @@ -749,6 +760,22 @@ function agentRequestsAskUserQuestion(agent: RunAgent): boolean { ); } +/** + * Whether the admin tool filter (`includedTools` allowlist, else `filteredTools` + * exclude list — same precedence as `loadAndFormatTools`) disables + * `ask_user_question`. Enforced at RUN BUILD, not just in the tools-dialog listing: + * agents saved before an admin filtered the tool out would otherwise keep exposing + * it to the model, attaching checkpointers, and pausing runs — for a run-pausing + * tool the filter must be an actual kill switch. + */ +function isAskUserQuestionAdminDisabled(appConfig?: AppConfig): boolean { + const included = appConfig?.includedTools; + if (included != null && included.length > 0) { + return !included.includes(ASK_USER_QUESTION_TOOL_NAME); + } + return appConfig?.filteredTools?.includes(ASK_USER_QUESTION_TOOL_NAME) === true; +} + /** * Whether any agent reachable in the run — primary, handoff/parallel, or a * nested subagent — opts into cross-turn `reasoning_content` reconstruction. @@ -990,6 +1017,9 @@ export async function createRun({ } } + /** Admin kill switch for the ask tool — see {@link isAskUserQuestionAdminDisabled}. */ + const askToolAdminDisabled = isAskUserQuestionAdminDisabled(appConfig); + const buildAgentInput = (agent: RunAgent, opts: { isSubagent?: boolean } = {}): AgentInputs => { const isSubagent = opts.isSubagent === true; const provider = @@ -1112,17 +1142,24 @@ export async function createRun({ } /** - * `ask_user_question` can only pause on runs whose caller implements the HITL - * pause/resume lifecycle, so strip it fail-closed everywhere else: non-HITL - * callers (the OpenAI-compatible + Responses controllers) have no pending-action - * surface or resume endpoint, and subagent child graphs execute outside the - * parent run's stream, so an interrupt raised inside one could not pause the - * parent (v1: stripped; revisit with subagent HITL). Clone-before-mutate, - * matching the registry-clone discipline above — `agent.toolRegistry` may be - * shared with other builds of the same agent. + * `ask_user_question` pauses via a LangGraph `interrupt()` raised from its own + * tool body, so it must execute IN-PROCESS inside the graph's ToolNode — the + * event-dispatched path runs tool bodies in the host handler outside the Pregel + * task frame, where `interrupt()` throws and becomes an error ToolMessage. The + * tool therefore never rides the schema-only `toolDefinitions`/`toolRegistry` + * surfaces: on every path it is REMOVED from them (clone-before-mutate, + * matching the registry-clone discipline above), and on the one path where it + * can actually work — an HITL-capable caller's top-level agent, with the admin + * filter allowing it — a real instance is supplied via `graphTools`, the SDK's + * in-graph direct-tool seam (bound to the model, executed inside the task + * frame; requires `@librechat/agents` > 3.2.57, older versions ignore the + * field). Everywhere else (OpenAI-compatible + Responses controllers with no + * resume surface, subagent child graphs that compile without a checkpointer, + * admin-disabled) it is stripped fail-closed with no replacement. */ let tools = agent.tools; - if ((!hitlCapable || isSubagent) && agentRequestsAskUserQuestion(agent)) { + let askGraphTools: GenericTool[] | undefined; + if (agentRequestsAskUserQuestion(agent)) { tools = tools?.filter( (tool) => (tool as { name?: string } | undefined)?.name !== ASK_USER_QUESTION_TOOL_NAME, ); @@ -1131,6 +1168,9 @@ export async function createRun({ toolRegistry = new Map(toolRegistry); toolRegistry.delete(ASK_USER_QUESTION_TOOL_NAME); } + if (hitlCapable && !isSubagent && !askToolAdminDisabled) { + askGraphTools = [createAskUserQuestionTool() as unknown as GenericTool]; + } } const effectiveMaxContextTokens = computeEffectiveMaxContextTokens( @@ -1140,7 +1180,7 @@ export async function createRun({ ); const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint, agent.reasoningKey); - return { + const agentInput: AgentInputs = { provider, reasoningKey, toolDefinitions, @@ -1161,6 +1201,16 @@ export async function createRun({ contextPruningConfig: summarization.contextPruning, maxToolResultChars: agent.maxToolResultChars, }; + if (askGraphTools) { + /** + * Typed structurally — not as `AgentInputs['graphTools']` — because the + * field ships in `@librechat/agents` > 3.2.57 (agents#289); older SDK + * 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; + } + return agentInput; }; const agentInputs: AgentInputs[] = []; @@ -1253,7 +1303,8 @@ export async function createRun({ * above, so a checkpointer here always has a resume surface. The LazyMongoSaver only * persists when a run actually pauses, so attaching it is near-zero overhead. */ - const asksUserQuestions = hitlCapable && agents.some(agentRequestsAskUserQuestion); + const asksUserQuestions = + hitlCapable && !askToolAdminDisabled && agents.some(agentRequestsAskUserQuestion); if (hitl || asksUserQuestions) { const checkpointer = await getAgentCheckpointer(agentsEndpointConfig?.checkpointer); graphConfig.compileOptions = { ...graphConfig.compileOptions, checkpointer };