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.
This commit is contained in:
Danny Avila 2026-07-06 15:38:01 -04:00
parent 27358c47c5
commit 381dbb1753
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
8 changed files with 349 additions and 21 deletions

View file

@ -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": []
},
{

View file

@ -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 &&

View file

@ -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': {

View file

@ -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()}`;

View file

@ -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);
}

View file

@ -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),
);

View file

@ -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<string, unknown>;
}>;
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<string, unknown>).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<string, unknown>),
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<string, unknown>;
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<string, unknown>).has(ASK)).toBe(false);
});
it('an includedTools allowlist disables the tool unless listed (allowlist precedence)', async () => {
const withoutTool = {
...(plainAppConfig as unknown as Record<string, unknown>),
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<string, unknown>;
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<string, unknown>),
// 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<string, unknown>;
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: {},

View file

@ -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<string, unknown>;
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 };