🧵 feat: Background Tool Calls for Agents & Model Specs (#14197)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* 🧵 feat: Background Tool Calls for Agents & Model Specs

Opt-in, poll-based background tool execution. The model marks an eligible tool
call with `run_in_background: true`; the host executor registers a task, returns
a handle immediately (so the graph turn resolves), runs the tool as a detached
promise, and the model retrieves the result via a new `check_background_task`
poll tool. Host-side only — no `@librechat/agents` change.

- Opt-in mirrors `deferred_tools`: admin capability `run_in_background`
  (off by default) + per-tool `tool_options.run_in_background`.
- Model specs / ephemeral agents: `TModelSpec.runInBackground` /
  `TEphemeralAgent.run_in_background` synthesize per-tool options; both paths
  converge at `initializeAgent`.
- In-process task registry: scoped per user+conversation, idempotent by
  toolCallId (safe across resume/replay), capped, TTL-swept.
- Excludes direct-path / host-special / code-session tools. Subagents and push
  notifications are deferred follow-ups.

* 🩹 fix: Harden background tool calls (Codex review)

- Reliable per-agent execution gate: thread the injected `run_in_background`
  tool names from `initializeAgent` through `configurable.backgroundToolNames`
  (`toolRegistry` only reaches the executor for PTC/tool_search), fixing the
  silent no-op + unstripped-arg leak for ordinary event-driven tools.
- Enforce the per-tool opt-in at execution (`backgroundToolSet.has(name)`) so a
  non-opted-in tool can't be backgrounded via an extra arg.
- Gate the `check_background_task` interception on the run actually enabling
  background, so a user tool sharing that name still executes.
- Forward `backgroundToolsAvailable` to added-convo (multi-convo) agents.
- Exclude `web_search`/`file_search` from eligibility — their results are turned
  into user-visible attachments/citations only by the foreground toolEndCallback.

* 🩹 fix: Address Codex round 2 on background tool calls

- Idempotency scoped to run+turn: provider tool-call ids repeat across turns
  (e.g. `call_0`), so key the dedupe map by `runId::toolCallId` and sweep
  orphaned mappings — a later turn no longer collides with a retained task.
- Artifacts preserved: a backgrounded tool's artifact is processed through the
  same `toolEndCallback` as the foreground path (images/files/citations no
  longer silently dropped), best-effort/guarded.
- Forward the `run_in_background` capability to connected-agent discovery and
  subagent `processAgent` init, so a child agent's own event-driven tools work
  the same as when it runs as primary.
- Strip the injected flag on foreground calls of background-capable tools
  (the model may emit it as `false`) so strict MCP/action schemas don't reject.
- `check_background_task` list path returns metadata only (result_available /
  result_chars), never full results — prevents context overflow; the full
  result is returned only when a specific id is requested.

* 🩹 fix: Address Codex round 3 on background tool calls

- Exclude background-capable tools from eager execution (run.ts): a speculative
  eager dispatch of a `run_in_background` call could launch the detached task
  with partial/stale args, and that side effect can't be canceled.
- Reserve the `check_background_task` name: overwrite a colliding user/MCP tool
  with the host poll schema (with a warning) so the advertised schema matches
  the executor's interception instead of hijacking a mismatched tool.
- Don't inject background schemas into pure subagents (spawn-tool child graphs)
  whose tools don't reach the host interceptor; keep it for primary/added/
  connected agents. Subagent background is the durable follow-up.
- Thread `backgroundToolsAvailable` + `backgroundToolNames` through the
  OpenAI-compatible and Responses agent routes (was chat-only), so the same
  agent/model spec behaves consistently across surfaces.
- Exclude image-generation built-ins (dalle/flux/gemini_image_gen/image_gen_oai/
  image_edit_oai) — artifact-first tools whose files can't reliably attach to an
  already-saved turn when backgrounded.

* 🩹 fix: Address Codex round 4 on background tool calls

- Sanitize self-spawn subagent inputs: strip `run_in_background` + the
  `check_background_task` def from the parent AgentInputs reused for self-spawn,
  so the isolated child (direct/child-graph path) doesn't advertise a background
  schema it can't honor. The SDK resolver keeps a provided `agentInputs` even
  with `self: true`.
- Exclude `check_background_task` from PTC (`run_tools_with_code`) tool
  definitions — it's host-only and not callable from generated code.
- Parse stringified JSON args before deciding background dispatch and before
  stripping the flag, so string-delivered `run_in_background` is honored and
  never leaks to strict object-schema tools.
- Skip injection for tools that already declare their own `run_in_background`
  param (would otherwise hijack/strip it), and for non-object (string-input)
  schemas (would otherwise rewrite the input contract).

* 🩹 fix: Address Codex round 5 on background tool calls

- check_background_task now parses stringified JSON args, so providers that
  deliver args as a string can retrieve a specific task by id (not just list).
- Include agentId in the background dedupe key (`agentId::runId::toolCallId`):
  two agents in the same run emitting the same provider id (e.g. `call_0`) now
  launch independent tasks instead of colliding.
- Self-spawn sanitization also strips the background entries from the reused
  toolRegistry (not just toolDefinitions), so a child using tool_search/deferred
  loading can't rediscover the host-only run_in_background / check_background_task.

* 🩹 fix: Strip run_in_background from PTC target tool schemas (Codex round 6)

The PTC path already filtered out the host-only check_background_task poll tool
but still exposed target tool schemas with the injected `run_in_background` param
(the shared toolRegistry entries were mutated by applyBackgroundToolCalls). PTC
codegen doesn't go through the host background interceptor, so it could pass the
flag to an MCP/action tool (strict-schema rejection or silent foreground with no
poll). Sanitize the PTC toolDefs like the self-spawn path does.

* 🩹 fix: Sanitize background from explicit subagent inputs (Codex round 7)

A child agent reachable as a top-level/handoff agent is initialized WITH the
background capability, then reused as an explicit subagent via buildSubagentConfigs.
Round 4 only sanitized the self-spawn case; this now applies the same
stripBackgroundFromToolDefinitions/Registry to explicit child agentInputs when
`child.backgroundToolNames` is non-empty, so an isolated child graph doesn't
advertise a run_in_background / check_background_task contract it can't honor.

* 🩹 fix: Reap stuck/expired background tasks (Codex round 8)

- get() now sweeps before returning, so repeatedly polling a known
  background_task_id can't keep an expired completed task (and its retained
  result, up to 100k chars) alive past the one-hour completed TTL.
- sweep() now reaps `running` tasks older than a 30-min running TTL, marking
  them errored. Previously a detached call that never settled (hung network /
  lost MCP connection) held a running slot forever, exhausting the
  per-conversation cap and rejecting every later dispatch.

* 🩹 fix: Evict oldest settled tasks instead of blocking at the cap (Codex round 9)

Only the running-task cap gates dispatch now. The total-tasks cap
(MAX_TASKS_PER_BUCKET) bounds memory but no longer rejects new background calls:
when full, it evicts the oldest settled (completed/error) tasks to make room.
Previously 200 quick background calls in one conversation would block all new
dispatches for up to the completed-task TTL, since polling doesn't remove settled
tasks. Running is already capped, so room always frees.

* 📝 docs: Frame background tool calls as within-turn (Codex P1 contract)

Codex escalated the request-lifecycle findings to P1 on the grounds that the
advertised "poll later" contract can't be honored for genuinely long-running
calls (request-scoped MCP connections + the run abort signal are torn down at
turn end). Align the model-facing contract with what the same-run implementation
actually delivers: the run_in_background param, check_background_task, and the
dispatch handle now instruct the model to collect the result WITHIN THE SAME TURN
(backgrounded work isn't guaranteed to survive past the turn). This is
within-turn parallelism; cross-turn survival of long-running calls remains the
deliberate durable subagent follow-up. Copy/comment-only; no behavior change.

* ♻️ refactor: Cross-turn background tool calls, leak-free

Extend background tool calls from within-turn to cross-turn on a single
process, since the mechanism already supports it: the run's abort signal
never reaches the detached invoke (the graph forwards only configurable/
metadata to the tool-execute handler), so the floating promise keeps
running past turn completion and its result stays in the in-process
registry for a later turn to poll (get/list key only on
user::conversation + id, never the dispatch run/turn).

Guarantee no connection leak: ephemeral request-scoped MCP tools (runtime
{{LIBRECHAT_BODY_*}} placeholders) capture their request-scoped store at
creation and fall back to it, so config manipulation can't redirect them;
their connection is torn down at request end. Tag such tools in
createToolInstance and run them in the foreground instead of backgrounding
them. Pooled/app-level MCP and structured tools are unaffected and survive
cross-turn via their managed pools.

Reword the model-facing contract (run_in_background, check_background_task,
handle message, fileoverview) from within-turn to cross-turn on this server
(not across restart/replica, which stays the durable follow-up).

Tests: cross-turn poll retrieval; ephemeral MCP tool runs foreground.

* 🐛 fix: Guard ephemeral MCP tag against a null server config

createToolInstance can be reached with a null/stale capturedServerConfig
(cached availableTools + getServerConfig returns null, as several MCP unit
tests construct tools). The new unconditional requiresEphemeralUserConnection
call then dereferenced config.source and threw during tool construction
(CI: Tests api shard 2/3). Guard with the same serverConfig ? ... : false
pattern the other callers use; a missing config is not request-scoped.

* 🎨 fix: Deliver backgrounded tool artifacts on the poll turn

A slow backgrounded MCP/action tool resolves after its dispatch turn is
finalized: createToolEndCallback only appends to that turn's artifactPromises
(already awaited) and writes to a closed stream, so the artifact (file/citation/
UI resource) was silently dropped — check_background_task recorded only the
hasArtifact boolean. The cross-turn contract made this the common case.

Hold the artifact on the task and deliver it through the LIVE poll turn's
toolEndCallback the first time check_background_task collects that id (once,
then cleared to free memory), attributed to the original tool. Same-turn and
cross-turn now share this path since the model must poll to collect any result.

Tests: registry claim-once; artifact delivered on poll not dispatch, idempotent.

* ✨ feat: Agent-builder toggle for background tool calls + cap tool descriptions

Add a per-MCP-tool "run in background" toggle in the agent builder, mirroring
the programmatic/deferred pattern: gated on the admin `run_in_background`
capability via useAgentCapabilities, read/written on tool_options[id]
.run_in_background through useMCPToolOptions (per-tool + bulk mark-all), and
rendered as a Zap toggle in MCPToolItem and McpSection with new locale keys.

Also cap the section tool/server descriptions (McpSection, ToolSection,
SkillSection) with max-h-40 overflow-y-auto so a long description scrolls
instead of overflowing the dialog, matching MCPToolItem's existing cap.

Tests: MCPToolItem renders/toggles the background button only when enabled.

* 🧪 fix: Mock new background hook functions in McpSection spec

* 🎨 fix: Restore background artifact when poll-turn delivery fails

* 🛡️ fix: Harden background tool call edges from review findings

- Error immediately (matching foreground) when a background-requested tool
  failed to load, instead of returning a success handle for a dead task
- Exclude ephemeral request-scoped MCP tools at injection time so the model
  never sees a run_in_background param the executor would silently downgrade;
  flip the execute-time tag to fail closed on a missing server config
- Source image-tool background exclusions from the shared imageGenTools set
  (adds missing stable-diffusion, an artifact-first live tool) instead of a
  hand-copied list
- Add check_background_task to the eager-execution exclusion list: artifact
  collection is a one-shot claim that must not fire from a speculative
  snapshot the SDK may discard
- Strip an imitated run_in_background arg on tools the executing agent never
  opted in (multi-agent history bleed), unless the tool's own schema declares
  the parameter
- Truncate oversized stored results with an explicit marker via the shared
  truncateMiddle (moved to utils/text) instead of a silent slice
- Document the at-most-once artifact delivery semantics honestly (the
  callback's downstream persistence is fire-and-forget, as in foreground)

* ♻️ refactor: Deduplicate background tool-call plumbing and tighten types

- Use the SDK's JsonSchemaType instead of a local duplicate; drop all
  as-unknown casts and type the poll-tool serializer explicitly
- Drop derivable BackgroundTask state (progress, hasArtifact) and the dead
  `enabled` param/return on applyBackgroundToolCalls (guarded at the call
  site), which also skips the defs pass when nothing opted in
- Fold the enable expression into synthesizeBackgroundToolOptions so the
  three load/added call sites can't drift
- Throttle the registry's all-buckets sweep and always sweep the accessed
  bucket, so a hot poll loop is no longer O(total tasks server-wide); bound
  retained artifact memory with a size cap
- Single-pass stripBackgroundFromToolDefinitions; pass metadata through to
  the poll-turn callback instead of a no-op reconstruction
- Collapse the client's copy-pasted boolean option families into a keyed
  factory (also removes the shared-object mutation in the bulk toggles) and
  the six toggle-button copies into one OptionToggle component

* 🧪 test: e2e coverage for cross-turn background tool calls

Proves the full contract through the real pipeline (mock harness): an agent
opts an MCP tool in via tool_options.run_in_background, the model dispatches
it detached and receives the synthetic handle while the tool is still running
(status=running in the rendered ack — the non-blocking guarantee without
timing assertions), the tool completes after its turn finalized, and a later
user turn recovers the task id from replayed history, polls
check_background_task, and renders the collected result.

- fake-mcp-server: slow_echo fixture tool (delayed echo)
- fake-model: E2E_BACKGROUND_DISPATCH / E2E_BACKGROUND_COLLECT markers
- e2e yaml: agents capabilities = defaults + run_in_background

* 🔧 fix: Close two background capability gaps from review

- Thread backgroundToolsAvailable through the OpenAI-compatible service
  (derived from app capabilities like codeEnvAvailable/statefulSessions),
  so agents with tool_options.run_in_background keep the feature on that
  route; fold the three capability derivations into one helper
- Index ephemeral MCP servers by normalizeServerName when excluding tools
  from background injection: tool names embed the normalized server name
  while mcpConfig keys the original, so exotic server names previously
  escaped the injection-time exclusion

* 🛂 fix: Fall back to configurable user identity for background task scoping

The in-repo routes merge req into the tool-execute configurable, but external
hosts of the exported OpenAI-compatible service inject their own loadTools and
may not — tasks would then register under an empty user id, collapsing
registry isolation to conversationId alone. Resolve the scoping id from
req.user.id, then configurable.user_id / user, and cover the isolation with a
foreign-user not_found test.

* 🧹 chore: Apply repo import sorter to PR-touched files
This commit is contained in:
Danny Avila 2026-07-13 12:51:36 -04:00 • committed by GitHub
parent b3f9cddbef
commit 520af663bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3334 additions and 216 deletions

View file

@ -335,6 +335,7 @@ const OpenAIChatCompletionController = async (req, res) => {
ephemeralSkillsToggle,
}),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
@ -415,6 +416,7 @@ const OpenAIChatCompletionController = async (req, res) => {
defaultActiveOnShare,
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
@ -500,6 +502,7 @@ const OpenAIChatCompletionController = async (req, res) => {
agent: ctx.agent ?? agent,
signal: abortController.signal,
toolRegistry: ctx.toolRegistry,
backgroundToolNames: ctx.backgroundToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,
requestScopedConnections: ctx.requestScopedConnections,
userMCPAuthMap: ctx.userMCPAuthMap,

View file

@ -459,6 +459,7 @@ const createResponse = async (req, res) => {
ephemeralSkillsToggle,
}),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
@ -539,6 +540,7 @@ const createResponse = async (req, res) => {
defaultActiveOnShare,
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
@ -720,6 +722,7 @@ const createResponse = async (req, res) => {
agent: ctx.agent ?? agent,
signal: abortController.signal,
toolRegistry: ctx.toolRegistry,
backgroundToolNames: ctx.backgroundToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,
requestScopedConnections: ctx.requestScopedConnections,
userMCPAuthMap: ctx.userMCPAuthMap,
@ -900,6 +903,7 @@ const createResponse = async (req, res) => {
agent: ctx.agent ?? agent,
signal: abortController.signal,
toolRegistry: ctx.toolRegistry,
backgroundToolNames: ctx.backgroundToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,
requestScopedConnections: ctx.requestScopedConnections,
userMCPAuthMap: ctx.userMCPAuthMap,

View file

@ -81,6 +81,7 @@ const processAddedConvo = async ({
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
}) => {
@ -177,6 +178,7 @@ const processAddedConvo = async ({
ephemeralSkillsToggle,
}),
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,

View file

@ -149,6 +149,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
const backgroundToolsAvailable = enabledCapabilities.has(AgentCapabilities.run_in_background);
const statefulSessionsAvailable = enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
);
@ -230,6 +231,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
toolNames,
agent: ctx.agent,
toolRegistry: ctx.toolRegistry,
backgroundToolNames: ctx.backgroundToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,
requestScopedConnections: ctx.requestScopedConnections,
userMCPAuthMap: ctx.userMCPAuthMap,
@ -408,6 +410,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
accessibleSkillIds: primaryScopedSkillIds,
skillAuthoringAvailable: primarySkillAuthoringAvailable,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
@ -485,6 +488,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
},
@ -558,6 +562,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
});
@ -698,6 +703,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
* false`, so `bash_tool` / `read_file` sandbox fallback are
* silently gated off even though the seed walk found it. */
codeEnvAvailable,
/* Background tool calls are intentionally NOT enabled for pure
* subagents (spawn-tool child graphs): their tools don't reach the
* host ON_TOOL_EXECUTE background interceptor, so injecting the
* schema would advertise a poll tool the host can't honor. Backgrounding
* subagent work is the durable follow-up. */
statefulSessionsAvailable,
memoryAvailable,
skillStates,

View file

@ -278,6 +278,7 @@ function buildAgentToolContext({ agent, config }) {
* the producing agent's config in multi-endpoint graphs. */
endpointTokenConfig: config.endpointTokenConfig,
toolRegistry: config.toolRegistry,
backgroundToolNames: config.backgroundToolNames,
mcpAvailableTools: config.mcpAvailableTools,
requestScopedConnections: config.requestScopedConnections,
userMCPAuthMap: config.userMCPAuthMap,

View file

@ -887,6 +887,14 @@ function createToolInstance({
});
toolInstance.mcp = true;
toolInstance.mcpRawServerName = serverName;
// Ephemeral request-scoped servers (runtime body placeholders) tear their
// connection down at request end, so they must never be backgrounded. A
// missing/stale config means the server's lifetime is unknowable, so fail
// closed (foreground) rather than risk a detached call against a torn-down
// connection.
toolInstance.mcpRequiresEphemeralConnection = capturedServerConfig
? requiresEphemeralUserConnection(capturedServerConfig)
: true;
// On Google/Vertex, propagate the union-flattened schema so definitions extracted
// from this instance don't reach the Gemini converter with unsupported unions.
toolInstance.mcpJsonSchema = isGoogle ? schema : parameters;

View file

@ -1450,6 +1450,7 @@ async function loadToolsForExecution({
agent,
toolNames,
toolRegistry,
backgroundToolNames,
mcpAvailableTools,
requestScopedConnections,
userMCPAuthMap,
@ -1461,6 +1462,12 @@ async function loadToolsForExecution({
const allLoadedTools = [];
const mcpRequestScopedConnections = requestScopedConnections ?? getMCPRequestContext(req, res);
const configurable = { userMCPAuthMap, requestScopedConnections: mcpRequestScopedConnections };
/** Per-agent set of tools that received the injected `run_in_background`
* param; the event-driven executor gates background dispatch and the
* `check_background_task` poll tool on this reliable per-agent channel. */
if (backgroundToolNames?.length) {
configurable.backgroundToolNames = backgroundToolNames;
}
const isToolSearch = toolNames.includes(AgentConstants.TOOL_SEARCH);
const ptcToolNames = [

View file

@ -1,7 +1,7 @@
import { useState } from 'react';
import { TooltipAnchor } from '@librechat/client';
import { Check, Clock, Code2, Info } from 'lucide-react';
import { Check, Clock, Code2, Info, Zap } from 'lucide-react';
import type { AgentToolType } from 'librechat-data-provider';
import OptionToggle from './OptionToggle';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -10,11 +10,14 @@ interface MCPToolItemProps {
isSelected: boolean;
isDeferred: boolean;
isProgrammatic: boolean;
isBackground: boolean;
deferredToolsEnabled: boolean;
programmaticToolsEnabled: boolean;
backgroundToolsEnabled: boolean;
onToggleSelect: () => void;
onToggleDefer: () => void;
onToggleProgrammatic: () => void;
onToggleBackground: () => void;
}
const iconButton =
@ -28,8 +31,11 @@ export default function MCPToolItem({
onToggleSelect,
isProgrammatic,
onToggleProgrammatic,
isBackground,
onToggleBackground,
deferredToolsEnabled,
programmaticToolsEnabled,
backgroundToolsEnabled,
}: MCPToolItemProps) {
const localize = useLocalize();
const [expanded, setExpanded] = useState(false);
@ -65,45 +71,33 @@ export default function MCPToolItem({
</button>
<div className="flex shrink-0 items-center gap-0.5">
{deferredToolsEnabled && (
<TooltipAnchor
description={localize('com_ui_mcp_click_to_defer')}
side="top"
render={
<button
type="button"
onClick={onToggleDefer}
aria-pressed={isDeferred}
aria-label={localize('com_ui_mcp_defer_loading')}
className={cn(
iconButton,
isDeferred ? 'text-amber-500' : 'text-text-secondary hover:text-text-primary',
)}
>
<Clock className="size-4" aria-hidden="true" />
</button>
}
<OptionToggle
icon={Clock}
pressed={isDeferred}
label={localize('com_ui_mcp_defer_loading')}
tooltip={localize('com_ui_mcp_click_to_defer')}
activeClass="text-amber-500"
onToggle={onToggleDefer}
/>
)}
{programmaticToolsEnabled && (
<TooltipAnchor
description={localize('com_ui_mcp_click_to_programmatic')}
side="top"
render={
<button
type="button"
onClick={onToggleProgrammatic}
aria-pressed={isProgrammatic}
aria-label={localize('com_ui_mcp_programmatic')}
className={cn(
iconButton,
isProgrammatic
? 'text-violet-500'
: 'text-text-secondary hover:text-text-primary',
)}
>
<Code2 className="size-4" aria-hidden="true" />
</button>
}
<OptionToggle
icon={Code2}
pressed={isProgrammatic}
label={localize('com_ui_mcp_programmatic')}
tooltip={localize('com_ui_mcp_click_to_programmatic')}
activeClass="text-violet-500"
onToggle={onToggleProgrammatic}
/>
)}
{backgroundToolsEnabled && (
<OptionToggle
icon={Zap}
pressed={isBackground}
label={localize('com_ui_mcp_background')}
tooltip={localize('com_ui_mcp_click_to_background')}
activeClass="text-sky-500"
onToggle={onToggleBackground}
/>
)}
<button

View file

@ -0,0 +1,51 @@
import { TooltipAnchor } from '@librechat/client';
import type { LucideIcon } from 'lucide-react';
import { cn } from '~/utils';
interface OptionToggleProps {
icon: LucideIcon;
pressed: boolean;
label: string;
/** Defaults to `label` (bulk toggles use the same text for both). */
tooltip?: string;
/** Text color applied when pressed (e.g. `text-amber-500`). */
activeClass: string;
onToggle: () => void;
size?: 'sm' | 'md';
}
/**
* Icon toggle for a per-tool option (defer / programmatic / background), shared
* between the per-tool row (`sm`) and the section-header bulk action (`md`).
*/
export default function OptionToggle({
icon: Icon,
pressed,
label,
tooltip,
activeClass,
onToggle,
size = 'sm',
}: OptionToggleProps) {
return (
<TooltipAnchor
description={tooltip ?? label}
side="top"
render={
<button
type="button"
onClick={onToggle}
aria-pressed={pressed}
aria-label={label}
className={cn(
'flex items-center justify-center rounded-md transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
size === 'sm' ? 'size-6' : 'size-7',
pressed ? activeClass : 'text-text-secondary hover:text-text-primary',
)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
}
/>
);
}

View file

@ -44,12 +44,16 @@ jest.mock('~/hooks', () => ({
useMCPToolOptions: () => ({
isToolDeferred: () => false,
isToolProgrammatic: () => false,
isToolBackground: () => false,
toggleToolDefer: jest.fn(),
toggleToolProgrammatic: jest.fn(),
toggleToolBackground: jest.fn(),
areAllToolsDeferred: () => false,
areAllToolsProgrammatic: () => false,
areAllToolsBackground: () => false,
toggleDeferAll: jest.fn(),
toggleProgrammaticAll: jest.fn(),
toggleBackgroundAll: jest.fn(),
}),
}));

View file

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { Clock, Code2 } from 'lucide-react';
import { Clock, Code2, Zap } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Button, Spinner, Checkbox, Skeleton, TooltipAnchor } from '@librechat/client';
import { Button, Spinner, Checkbox, Skeleton } from '@librechat/client';
import type { MouseEvent } from 'react';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { McpItem } from '../../items/types';
@ -18,6 +18,7 @@ import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import McpOAuthDialog from '~/components/MCP/McpOAuthDialog';
import { useAgentPanelContext } from '~/Providers';
import { getIconForItem } from '../../items/icons';
import OptionToggle from '../../../OptionToggle';
import MCPToolItem from '../../../MCPToolItem';
import { Collapse } from '~/components/ui';
import { useLocalize } from '~/hooks';
@ -75,18 +76,21 @@ export default function McpSection({ item }: Props) {
const [autoSelectPending, setAutoSelectPending] = useState(false);
const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext();
const { agentsConfig } = useGetAgentsConfig();
const { deferredToolsEnabled, programmaticToolsEnabled } = useAgentCapabilities(
agentsConfig?.capabilities,
);
const { deferredToolsEnabled, programmaticToolsEnabled, backgroundToolsEnabled } =
useAgentCapabilities(agentsConfig?.capabilities);
const {
isToolDeferred,
isToolProgrammatic,
isToolBackground,
toggleToolDefer,
toggleToolProgrammatic,
toggleToolBackground,
areAllToolsDeferred,
areAllToolsProgrammatic,
areAllToolsBackground,
toggleDeferAll,
toggleProgrammaticAll,
toggleBackgroundAll,
} = useMCPToolOptions();
const serverName = item.server.serverName;
@ -149,6 +153,7 @@ export default function McpSection({ item }: Props) {
const allSelected = hasTools && selectedTools.length === tools.length;
const allDeferred = areAllToolsDeferred(tools);
const allProgrammatic = areAllToolsProgrammatic(tools);
const allBackground = areAllToolsBackground(tools);
const statusIconProps = getServerStatusIconProps(serverName);
const configDialogProps = getConfigDialogProps();
const connectionState = statusIconProps?.serverStatus?.connectionState;
@ -246,7 +251,9 @@ export default function McpSection({ item }: Props) {
return (
<div className="flex flex-col gap-5">
{item.description && (
<p className="text-sm leading-relaxed text-text-secondary">{item.description}</p>
<p className="max-h-40 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-text-secondary">
{item.description}
</p>
)}
<div className="flex flex-col">
@ -297,68 +304,42 @@ export default function McpSection({ item }: Props) {
{hasTools && (
<div className="flex items-center gap-0.5">
{deferredToolsEnabled && (
<TooltipAnchor
description={
allDeferred
? localize('com_ui_mcp_undefer_all')
: localize('com_ui_mcp_defer_all')
}
side="top"
render={
<button
type="button"
onClick={() => toggleDeferAll(tools)}
aria-pressed={allDeferred}
aria-label={
allDeferred
? localize('com_ui_mcp_undefer_all')
: localize('com_ui_mcp_defer_all')
}
className={cn(
'flex size-7 items-center justify-center rounded-md transition-colors',
'hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
allDeferred
? 'text-amber-600 dark:text-amber-500'
: 'text-text-secondary hover:text-text-primary',
)}
>
<Clock className="size-4" aria-hidden="true" />
</button>
}
<OptionToggle
icon={Clock}
size="md"
pressed={allDeferred}
label={localize(allDeferred ? 'com_ui_mcp_undefer_all' : 'com_ui_mcp_defer_all')}
activeClass="text-amber-600 dark:text-amber-500"
onToggle={() => toggleDeferAll(tools)}
/>
)}
{programmaticToolsEnabled && (
<TooltipAnchor
description={
<OptionToggle
icon={Code2}
size="md"
pressed={allProgrammatic}
label={localize(
allProgrammatic
? localize('com_ui_mcp_unprogrammatic_all')
: localize('com_ui_mcp_programmatic_all')
}
side="top"
render={
<button
type="button"
onClick={() => toggleProgrammaticAll(tools)}
aria-pressed={allProgrammatic}
aria-label={
allProgrammatic
? localize('com_ui_mcp_unprogrammatic_all')
: localize('com_ui_mcp_programmatic_all')
}
className={cn(
'flex size-7 items-center justify-center rounded-md transition-colors',
'hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
allProgrammatic
? 'text-violet-600 dark:text-violet-500'
: 'text-text-secondary hover:text-text-primary',
)}
>
<Code2 className="size-4" aria-hidden="true" />
</button>
}
? 'com_ui_mcp_unprogrammatic_all'
: 'com_ui_mcp_programmatic_all',
)}
activeClass="text-violet-600 dark:text-violet-500"
onToggle={() => toggleProgrammaticAll(tools)}
/>
)}
{(deferredToolsEnabled || programmaticToolsEnabled) && (
{backgroundToolsEnabled && (
<OptionToggle
icon={Zap}
size="md"
pressed={allBackground}
label={localize(
allBackground ? 'com_ui_mcp_unbackground_all' : 'com_ui_mcp_background_all',
)}
activeClass="text-sky-600 dark:text-sky-500"
onToggle={() => toggleBackgroundAll(tools)}
/>
)}
{(deferredToolsEnabled || programmaticToolsEnabled || backgroundToolsEnabled) && (
<span className="mx-1 h-4 w-px bg-border-light" aria-hidden="true" />
)}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs text-text-secondary">
@ -394,11 +375,14 @@ export default function McpSection({ item }: Props) {
isSelected={selectedTools.includes(tool.tool_id)}
isDeferred={deferredToolsEnabled && isToolDeferred(tool.tool_id)}
isProgrammatic={programmaticToolsEnabled && isToolProgrammatic(tool.tool_id)}
isBackground={backgroundToolsEnabled && isToolBackground(tool.tool_id)}
deferredToolsEnabled={deferredToolsEnabled}
programmaticToolsEnabled={programmaticToolsEnabled}
backgroundToolsEnabled={backgroundToolsEnabled}
onToggleSelect={() => toggleToolSelect(tool.tool_id)}
onToggleDefer={() => toggleToolDefer(tool.tool_id)}
onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)}
onToggleBackground={() => toggleToolBackground(tool.tool_id)}
/>
))}
</div>

View file

@ -10,7 +10,9 @@ export default function SkillSection({ item }: Props) {
return (
<div className="flex flex-col gap-5">
{item.description ? (
<p className="text-sm leading-relaxed text-text-secondary">{item.description}</p>
<p className="max-h-40 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-text-secondary">
{item.description}
</p>
) : (
<p className="text-sm italic text-text-tertiary">
{localize('com_ui_tools_no_description')}

View file

@ -62,7 +62,9 @@ export default function ToolSection({ item }: Props) {
return (
<div className="flex flex-col gap-5">
{item.description ? (
<p className="text-sm leading-relaxed text-text-secondary">{item.description}</p>
<p className="max-h-40 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-text-secondary">
{item.description}
</p>
) : (
<p className="text-sm italic text-text-tertiary">
{localize('com_ui_tools_no_description')}

View file

@ -22,11 +22,14 @@ function setup(overrides: Partial<React.ComponentProps<typeof MCPToolItem>> = {}
isSelected: false,
isDeferred: false,
isProgrammatic: false,
isBackground: false,
deferredToolsEnabled: false,
programmaticToolsEnabled: false,
backgroundToolsEnabled: false,
onToggleSelect: jest.fn(),
onToggleDefer: jest.fn(),
onToggleProgrammatic: jest.fn(),
onToggleBackground: jest.fn(),
...overrides,
};
render(<MCPToolItem {...props} />);
@ -91,4 +94,16 @@ describe('MCPToolItem', () => {
fireEvent.click(programmaticButton);
expect(props.onToggleProgrammatic).toHaveBeenCalledTimes(1);
});
test('background is an inline button rendered only when enabled', () => {
const props = setup({ backgroundToolsEnabled: true });
const backgroundButton = screen.getByRole('button', { name: 'com_ui_mcp_background' });
fireEvent.click(backgroundButton);
expect(props.onToggleBackground).toHaveBeenCalledTimes(1);
});
test('background button is absent when background tools are disabled', () => {
setup();
expect(screen.queryByRole('button', { name: 'com_ui_mcp_background' })).not.toBeInTheDocument();
});
});

View file

@ -14,6 +14,7 @@ interface AgentCapabilitiesResult {
memoryEnabled: boolean;
deferredToolsEnabled: boolean;
programmaticToolsEnabled: boolean;
backgroundToolsEnabled: boolean;
}
export default function useAgentCapabilities(
@ -79,6 +80,11 @@ export default function useAgentCapabilities(
[capabilities],
);
const backgroundToolsEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.run_in_background) ?? false,
[capabilities],
);
return {
ocrEnabled,
codeEnabled,
@ -92,5 +98,6 @@ export default function useAgentCapabilities(
fileSearchEnabled,
deferredToolsEnabled,
programmaticToolsEnabled,
backgroundToolsEnabled,
};
}

View file

@ -1,62 +1,129 @@
import { useCallback } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import type { AgentToolOptions, AllowedCaller, AgentToolType } from 'librechat-data-provider';
import type { UseFormGetValues, UseFormSetValue } from 'react-hook-form';
import type { AgentForm } from '~/common';
type BooleanToolOptionKey = 'defer_loading' | 'run_in_background';
interface BooleanOptionHandlers {
isSet: (toolId: string) => boolean;
toggle: (toolId: string) => void;
areAllSet: (tools: AgentToolType[]) => boolean;
toggleAll: (tools: AgentToolType[]) => void;
}
interface ToolOptionsFormContext {
formToolOptions: AgentToolOptions | undefined;
getValues: UseFormGetValues<AgentForm>;
setValue: UseFormSetValue<AgentForm>;
}
interface UseMCPToolOptionsReturn {
formToolOptions: AgentToolOptions | undefined;
isToolDeferred: (toolId: string) => boolean;
isToolProgrammatic: (toolId: string) => boolean;
isToolBackground: (toolId: string) => boolean;
toggleToolDefer: (toolId: string) => void;
toggleToolProgrammatic: (toolId: string) => void;
toggleToolBackground: (toolId: string) => void;
areAllToolsDeferred: (tools: AgentToolType[]) => boolean;
areAllToolsProgrammatic: (tools: AgentToolType[]) => boolean;
areAllToolsBackground: (tools: AgentToolType[]) => boolean;
toggleDeferAll: (tools: AgentToolType[]) => void;
toggleProgrammaticAll: (tools: AgentToolType[]) => void;
toggleBackgroundAll: (tools: AgentToolType[]) => void;
}
/**
* Sets or clears a boolean flag on one tool's options without mutating the
* previous objects (react-hook-form still holds them); dropping the last flag
* removes the tool's entry entirely.
*/
function withBooleanOption(
options: AgentToolOptions,
toolId: string,
key: BooleanToolOptionKey,
set: boolean,
): AgentToolOptions {
const updatedOptions: AgentToolOptions = { ...options };
const currentToolOptions = updatedOptions[toolId];
if (set) {
updatedOptions[toolId] = { ...currentToolOptions, [key]: true };
return updatedOptions;
}
if (!currentToolOptions) {
return updatedOptions;
}
const { [key]: _omit, ...restOptions } = currentToolOptions;
if (Object.keys(restOptions).length === 0) {
delete updatedOptions[toolId];
} else {
updatedOptions[toolId] = restOptions;
}
return updatedOptions;
}
/** Read/toggle handlers for one boolean per-tool option key (single + bulk). */
function useBooleanToolOption(
key: BooleanToolOptionKey,
{ formToolOptions, getValues, setValue }: ToolOptionsFormContext,
): BooleanOptionHandlers {
const isSet = useCallback(
(toolId: string): boolean => formToolOptions?.[toolId]?.[key] === true,
[formToolOptions, key],
);
const toggle = useCallback(
(toolId: string) => {
const currentOptions = getValues('tool_options') || {};
const set = currentOptions[toolId]?.[key] !== true;
setValue('tool_options', withBooleanOption(currentOptions, toolId, key, set), {
shouldDirty: true,
});
},
[getValues, setValue, key],
);
const areAllSet = useCallback(
(tools: AgentToolType[]): boolean =>
tools.length > 0 && tools.every((tool) => formToolOptions?.[tool.tool_id]?.[key] === true),
[formToolOptions, key],
);
const toggleAll = useCallback(
(tools: AgentToolType[]) => {
if (tools.length === 0) {
return;
}
const set = !areAllSet(tools);
let updatedOptions = getValues('tool_options') || {};
for (const tool of tools) {
updatedOptions = withBooleanOption(updatedOptions, tool.tool_id, key, set);
}
setValue('tool_options', updatedOptions, { shouldDirty: true });
},
[areAllSet, getValues, setValue, key],
);
return { isSet, toggle, areAllSet, toggleAll };
}
export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
const { getValues, setValue, control } = useFormContext<AgentForm>();
const formToolOptions = useWatch({ control, name: 'tool_options' });
const formContext: ToolOptionsFormContext = { formToolOptions, getValues, setValue };
const isToolDeferred = useCallback(
(toolId: string): boolean => formToolOptions?.[toolId]?.defer_loading === true,
[formToolOptions],
);
const defer = useBooleanToolOption('defer_loading', formContext);
const background = useBooleanToolOption('run_in_background', formContext);
/** `allowed_callers` is array-valued, so the programmatic family stays bespoke. */
const isToolProgrammatic = useCallback(
(toolId: string): boolean =>
formToolOptions?.[toolId]?.allowed_callers?.includes('code_execution') === true,
[formToolOptions],
);
const toggleToolDefer = useCallback(
(toolId: string) => {
const currentOptions = getValues('tool_options') || {};
const currentToolOptions = currentOptions[toolId] || {};
const newDeferred = !currentToolOptions.defer_loading;
const updatedOptions: AgentToolOptions = { ...currentOptions };
if (newDeferred) {
updatedOptions[toolId] = {
...currentToolOptions,
defer_loading: true,
};
} else {
const { defer_loading: _, ...restOptions } = currentToolOptions;
if (Object.keys(restOptions).length === 0) {
delete updatedOptions[toolId];
} else {
updatedOptions[toolId] = restOptions;
}
}
setValue('tool_options', updatedOptions, { shouldDirty: true });
},
[getValues, setValue],
);
const toggleToolProgrammatic = useCallback(
(toolId: string) => {
const currentOptions = getValues('tool_options') || {};
@ -93,13 +160,6 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
[getValues, setValue],
);
const areAllToolsDeferred = useCallback(
(tools: AgentToolType[]): boolean =>
tools.length > 0 &&
tools.every((tool) => formToolOptions?.[tool.tool_id]?.defer_loading === true),
[formToolOptions],
);
const areAllToolsProgrammatic = useCallback(
(tools: AgentToolType[]): boolean =>
tools.length > 0 &&
@ -110,57 +170,33 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
[formToolOptions],
);
const toggleDeferAll = useCallback(
(tools: AgentToolType[]) => {
if (tools.length === 0) return;
const shouldDefer = !areAllToolsDeferred(tools);
const currentOptions = getValues('tool_options') || {};
const updatedOptions: AgentToolOptions = { ...currentOptions };
for (const tool of tools) {
if (shouldDefer) {
updatedOptions[tool.tool_id] = {
...(updatedOptions[tool.tool_id] || {}),
defer_loading: true,
};
} else {
if (updatedOptions[tool.tool_id]) {
delete updatedOptions[tool.tool_id].defer_loading;
if (Object.keys(updatedOptions[tool.tool_id]).length === 0) {
delete updatedOptions[tool.tool_id];
}
}
}
}
setValue('tool_options', updatedOptions, { shouldDirty: true });
},
[getValues, setValue, areAllToolsDeferred],
);
const toggleProgrammaticAll = useCallback(
(tools: AgentToolType[]) => {
if (tools.length === 0) return;
if (tools.length === 0) {
return;
}
const shouldBeProgrammatic = !areAllToolsProgrammatic(tools);
const currentOptions = getValues('tool_options') || {};
const updatedOptions: AgentToolOptions = { ...currentOptions };
for (const tool of tools) {
const currentToolOptions = updatedOptions[tool.tool_id] || {};
const currentToolOptions = updatedOptions[tool.tool_id];
if (shouldBeProgrammatic) {
updatedOptions[tool.tool_id] = {
...currentToolOptions,
allowed_callers: ['code_execution'] as AllowedCaller[],
};
continue;
}
if (!currentToolOptions) {
continue;
}
const { allowed_callers: _, ...restOptions } = currentToolOptions;
if (Object.keys(restOptions).length === 0) {
delete updatedOptions[tool.tool_id];
} else {
if (updatedOptions[tool.tool_id]) {
delete updatedOptions[tool.tool_id].allowed_callers;
if (Object.keys(updatedOptions[tool.tool_id]).length === 0) {
delete updatedOptions[tool.tool_id];
}
}
updatedOptions[tool.tool_id] = restOptions;
}
}
@ -171,13 +207,17 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
return {
formToolOptions,
isToolDeferred,
isToolDeferred: defer.isSet,
isToolProgrammatic,
toggleToolDefer,
isToolBackground: background.isSet,
toggleToolDefer: defer.toggle,
toggleToolProgrammatic,
areAllToolsDeferred,
toggleToolBackground: background.toggle,
areAllToolsDeferred: defer.areAllSet,
areAllToolsProgrammatic,
toggleDeferAll,
areAllToolsBackground: background.areAllSet,
toggleDeferAll: defer.toggleAll,
toggleProgrammaticAll,
toggleBackgroundAll: background.toggleAll,
};
}

View file

@ -1321,6 +1321,9 @@
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
"com_ui_mcp_background": "Background",
"com_ui_mcp_background_all": "Mark all as background",
"com_ui_mcp_click_to_background": "Runs this tool in the background: it returns immediately and the agent polls for the result, so it can keep working while the tool runs.",
"com_ui_mcp_click_to_defer": "Loads this tool only when the agent needs it instead of keeping it active the whole time. Useful when a server has many tools.",
"com_ui_mcp_click_to_programmatic": "The agent calls this tool by writing code, not as a direct tool call. Best for tools meant to run in code.",
"com_ui_mcp_defer": "Defer",
@ -1365,6 +1368,7 @@
"com_ui_mcp_transport": "Transport",
"com_ui_mcp_type_sse": "SSE",
"com_ui_mcp_type_streamable_http": "Streamable HTTPS",
"com_ui_mcp_unbackground_all": "Unmark all as background",
"com_ui_mcp_undefer": "Undefer",
"com_ui_mcp_undefer_all": "Undefer all tools",
"com_ui_mcp_unprogrammatic_all": "Unmark all as programmatic",

View file

@ -41,6 +41,25 @@ mcpServers:
timeout: 30000
endpoints:
# Default capabilities plus run_in_background (off by default upstream) so the
# background tool-call e2e can opt an MCP tool into detached dispatch.
agents:
capabilities:
- deferred_tools
- execute_code
- file_search
- web_search
- artifacts
- subagents
- actions
- context
- skills
- memory
- ask_user_question
- tools
- chain
- ocr
- run_in_background
custom:
- name: 'Mock Provider A'
apiKey: 'e2e-mock-key-a'

View file

@ -43,6 +43,29 @@ server.registerTool(
}),
);
server.registerTool(
'slow_echo',
{
description:
'Echoes text after a delay; used to verify background tool dispatch in mock e2e tests.',
inputSchema: {
text: z.string(),
delay_ms: z.number().optional(),
},
},
async ({ text, delay_ms = 1500 }) => {
await new Promise((resolve) => setTimeout(resolve, delay_ms));
return {
content: [
{
type: 'text',
text: `E2E slow echo: ${text}`,
},
],
};
},
);
async function main() {
await server.connect(new StdioServerTransport());
}

View file

@ -29,6 +29,8 @@ const SLOW_COUNTED_REPLY_MARKER = 'E2E_SLOW_COUNTED_REPLY:';
const RESUME_ICON_REPLY_MARKER = 'E2E_RESUME_ICON_REPLY:';
const FORCED_ERROR_MARKER = 'E2E_FORCED_ERROR:';
const MARKDOWN_REPLY_MARKER = 'E2E_MARKDOWN_REPLY';
const BACKGROUND_DISPATCH_MARKER = 'E2E_BACKGROUND_DISPATCH:';
const BACKGROUND_COLLECT_MARKER = 'E2E_BACKGROUND_COLLECT:';
const CREATE_FILE_AUTHORING_FINAL_TEXT = 'E2E file authoring complete';
const EDIT_FILE_AUTHORING_FINAL_TEXT = 'E2E file edit complete';
const MODEL_SPEC_SKILL_ASSERTION_FINAL_TEXT = 'E2E model spec skill assertion passed';
@ -45,6 +47,10 @@ const BASH_TOOL_NAME = 'bash_tool';
const SKILL_TOOL_NAME = 'skill';
const CREATE_SKILL_TOOL_CALL_ID = 'call_e2e_create_skill';
const EDIT_SKILL_TOOL_CALL_ID = 'call_e2e_edit_skill';
const BACKGROUND_TOOL_NAME = 'slow_echo_mcp_e2e-memory';
const CHECK_BACKGROUND_TASK_TOOL_NAME = 'check_background_task';
const BACKGROUND_DISPATCH_TOOL_CALL_ID = 'call_e2e_background_dispatch';
const BACKGROUND_COLLECT_TOOL_CALL_ID = 'call_e2e_background_collect';
const MODEL_SPEC_ACCESSIBLE_SKILL = 'e2e-model-spec-allowed';
const MODEL_SPEC_MISSING_SKILL = 'e2e-model-spec-missing';
const MODEL_SPEC_INACCESSIBLE_SKILL = 'e2e-model-spec-inaccessible';
@ -593,6 +599,112 @@ function fileAuthoringResponses(operation, toolNames) {
};
}
function findLastToolMessageText(messages, requiredToken) {
for (let index = (messages ?? []).length - 1; index >= 0; index--) {
const message = messages[index];
if (!message || messageType(message) !== 'tool') {
continue;
}
const content = getContentText(message.content);
if (content.includes(requiredToken)) {
return content;
}
}
return '';
}
/**
* Turn 1 of the background e2e: emit the MCP tool call with the injected
* `run_in_background: true` arg, then (second model invocation, after the
* executor returned the synthetic handle) acknowledge the handle. Streaming
* `status=running` from the handle proves the dispatch returned before the
* tool finished — the non-blocking contract — without timing assertions.
*/
function backgroundDispatchResponses(name, toolNames) {
if (!toolNames.has(BACKGROUND_TOOL_NAME)) {
return {
responses: [`E2E background unavailable: ${BACKGROUND_TOOL_NAME} was not advertised.`],
};
}
if (!toolNames.has(CHECK_BACKGROUND_TASK_TOOL_NAME)) {
return {
responses: [
`E2E background unavailable: ${CHECK_BACKGROUND_TASK_TOOL_NAME} was not advertised.`,
],
};
}
return {
responses: ['', ''],
toolCalls: [
{
id: BACKGROUND_DISPATCH_TOOL_CALL_ID,
name: BACKGROUND_TOOL_NAME,
args: { text: `bg-${name}`, delay_ms: 1500, run_in_background: true },
type: 'tool_call',
},
],
resolveOnStream: (streamMessages) => {
const handleText = findLastToolMessageText(streamMessages, 'background_task_id');
if (!handleText) {
return null;
}
const taskId = handleText.match(/"background_task_id":"([^"]+)"/)?.[1] ?? 'missing';
const status = handleText.match(/"status":"(\w+)"/)?.[1] ?? 'missing';
return { responses: [`E2E background dispatched id=${taskId} status=${status}`] };
},
};
}
/**
* Turn 2 of the background e2e: recover the task id from the replayed turn-1
* handle in history, poll `check_background_task` with it, and stream the
* collected status + echoed text — proving the detached result survived turn
* end and was retrieved cross-turn.
*/
function backgroundCollectResponses(messages, toolNames) {
if (!toolNames.has(CHECK_BACKGROUND_TASK_TOOL_NAME)) {
return {
responses: [
`E2E background unavailable: ${CHECK_BACKGROUND_TASK_TOOL_NAME} was not advertised.`,
],
};
}
const historyText = collectPromptText((messages ?? []).map((message) => message?.content)).join(
'\n',
);
const taskIds = [...historyText.matchAll(/"background_task_id":"([^"]+)"/g)].map(
(match) => match[1],
);
const taskId = taskIds[taskIds.length - 1];
if (!taskId) {
return {
responses: ['E2E background collect failed: no background_task_id found in history.'],
};
}
return {
responses: ['', ''],
toolCalls: [
{
id: BACKGROUND_COLLECT_TOOL_CALL_ID,
name: CHECK_BACKGROUND_TASK_TOOL_NAME,
args: { background_task_id: taskId },
type: 'tool_call',
},
],
resolveOnStream: (streamMessages) => {
/** Only the poll result (`serializeTask`) carries a `progress` key — the
* replayed dispatch handle in history does not. */
const pollText = findLastToolMessageText(streamMessages, '"progress"');
if (!pollText) {
return null;
}
const status = pollText.match(/"status":"(\w+)"/)?.[1] ?? 'missing';
const echo = pollText.match(/E2E slow echo: (bg-[\w-]+)/)?.[1] ?? 'missing';
return { responses: [`E2E background collected status=${status} echo=${echo}`] };
},
};
}
function resolveResponses({ agents, messages, text, toolNames }) {
const reply = replyResponses(text);
if (reply) {
@ -635,6 +747,15 @@ function resolveResponses({ agents, messages, text, toolNames }) {
);
}
const backgroundDispatchName = getMarkerValue(text, BACKGROUND_DISPATCH_MARKER);
if (backgroundDispatchName) {
return backgroundDispatchResponses(backgroundDispatchName, toolNames);
}
if (text.includes(BACKGROUND_COLLECT_MARKER)) {
return backgroundCollectResponses(messages, toolNames);
}
const editSkillName = getRequestedSkillName(text, EDIT_SKILL_MARKER);
if (editSkillName) {
return fileAuthoringResponses(

View file

@ -0,0 +1,114 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { AgentDetail } from './agents.helpers';
import { cleanupAgent, openAgentBuilder, uniqueAgentName } from './agents.helpers';
import {
MOCK_ENDPOINTS,
fetchJson,
getAccessToken,
messagesView,
requestJson,
sendMessage,
} from './helpers';
const MCP_SERVER_NAME = 'e2e-memory';
const BACKGROUND_TOOL_ID = `slow_echo_mcp_${MCP_SERVER_NAME}`;
const MCP_SERVER_TOOL_ID = `sys__server__sys_mcp_${MCP_SERVER_NAME}`;
const DESCRIPTION = 'Verifies background (detached) MCP tool calls in mock e2e tests.';
type MCPToolsResponse = {
servers?: Record<string, { tools?: Array<{ pluginKey: string }> }>;
};
async function waitForBackgroundTool(page: Page) {
const token = await getAccessToken(page);
let latestTools: MCPToolsResponse | null = null;
for (let attempt = 0; attempt < 20; attempt++) {
latestTools = await fetchJson<MCPToolsResponse>(page, '/api/mcp/tools', token);
const serverTools = latestTools.servers?.[MCP_SERVER_NAME]?.tools ?? [];
if (serverTools.some((tool) => tool.pluginKey === BACKGROUND_TOOL_ID)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(
latestTools?.servers?.[MCP_SERVER_NAME]?.tools,
`Expected ${MCP_SERVER_NAME} to expose ${BACKGROUND_TOOL_ID}`,
).toEqual(expect.arrayContaining([expect.objectContaining({ pluginKey: BACKGROUND_TOOL_ID })]));
}
test.describe('background tool calls', () => {
test('dispatches a tool in the background and collects its result on a later turn', async ({
page,
}) => {
test.setTimeout(120000);
await page.goto('/c/new', { timeout: 10000 });
const agentName = uniqueAgentName('E2E Background Agent');
const runToken = `${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
let createdAgentId: string | undefined;
try {
await waitForBackgroundTool(page);
/** The opt-in contract under test lives in `tool_options`, so the agent is
* created through the same API the builder uses; the toggle UI itself is
* covered by the MCPToolItem jest suite. */
const token = await getAccessToken(page);
const createdAgent = await requestJson<AgentDetail>(page, {
path: '/api/agents',
token,
method: 'POST',
body: {
name: agentName,
description: DESCRIPTION,
instructions: 'Reply through the mock e2e model.',
provider: MOCK_ENDPOINTS[0].label,
model: MOCK_ENDPOINTS[0].model,
tools: [MCP_SERVER_TOOL_ID, BACKGROUND_TOOL_ID],
tool_options: { [BACKGROUND_TOOL_ID]: { run_in_background: true } },
},
});
createdAgentId = createdAgent.id;
expect(createdAgent.tools).toEqual(expect.arrayContaining([BACKGROUND_TOOL_ID]));
const form = await openAgentBuilder(page);
await form.getByRole('combobox', { name: 'Agent', exact: true }).click();
await page.getByRole('option', { name: agentName }).click();
await expect(form.getByLabel('Agent name')).toHaveValue(agentName);
await form.getByRole('button', { name: 'Select Agent' }).click();
/** Turn 1: the model requests the tool with `run_in_background: true`; the
* executor must return a synthetic handle immediately. The fake model
* echoes the handle it saw — `status=running` proves the dispatch did not
* block on the tool (which sleeps 1.5s). */
const dispatch = await sendMessage(page, `E2E_BACKGROUND_DISPATCH:${runToken}`);
expect(dispatch.ok()).toBeTruthy();
const dispatchAck = messagesView(page).getByText(
/E2E background dispatched id=[\w-]+ status=running/,
);
await expect(dispatchAck).toBeVisible({ timeout: 30000 });
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
/** The tool finishes ~1.5s after dispatch, past the end of turn 1 — the
* detached promise survives its originating turn. Give it time to land
* in the registry before the collect turn polls. */
await page.waitForTimeout(2500);
/** Turn 2: the fake model recovers the task id from turn-1 history, calls
* `check_background_task` with it, and echoes what the poll returned —
* status + the tool's output retrieved from the cross-turn registry. */
const collect = await sendMessage(page, `E2E_BACKGROUND_COLLECT:${runToken}`);
expect(collect.ok()).toBeTruthy();
await expect(
messagesView(page).getByText(
new RegExp(`E2E background collected status=completed echo=bg-${runToken}`),
),
).toBeVisible({ timeout: 30000 });
} finally {
await cleanupAgent(page, createdAgentId);
}
});
});

View file

@ -389,6 +389,89 @@ describe('loadAgent', () => {
expect(withoutFlag?.tools).not.toContain('ask_user_question');
});
test('synthesizes background tool_options for eligible MCP tools from the ephemeralAgent flag', async () => {
const { EPHEMERAL_AGENT_ID } = Constants;
mockGetMCPServerTools.mockResolvedValue({ crm_lookup: { name: 'crm_lookup' } });
const result = await loadAgent(
{
req: {
user: { id: 'user123' },
body: {
ephemeralAgent: {
mcp: ['crm'],
web_search: true,
execute_code: true,
run_in_background: true,
} as TEphemeralAgent,
},
},
agent_id: EPHEMERAL_AGENT_ID as string,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' } as unknown as AgentModelParameters,
},
deps,
);
// eligible MCP tool opts in; excluded built-ins (web_search, execute_code) do not
expect(result?.tool_options?.crm_lookup).toEqual({ run_in_background: true });
expect(result?.tool_options?.web_search).toBeUndefined();
expect(result?.tool_options?.execute_code).toBeUndefined();
});
test('synthesizes background tool_options from a model spec (runInBackground: true), and not without it', async () => {
const { EPHEMERAL_AGENT_ID } = Constants;
mockGetMCPServerTools.mockResolvedValue({ crm_lookup: { name: 'crm_lookup' } });
const buildReq = (specName: string, runInBackground: boolean): LoadAgentParams['req'] =>
({
user: { id: 'user123' },
body: {},
config: {
config: {},
fileStrategy: FileSources.local,
imageOutputType: 'png',
modelSpecs: {
list: [
{
name: specName,
label: specName,
preset: { endpoint: 'openai', model: 'gpt-4' },
webSearch: true,
mcpServers: ['crm'],
runInBackground,
},
],
},
},
}) as unknown as LoadAgentParams['req'];
const withFlag = await loadAgent(
{
req: buildReq('bg-on', true),
spec: 'bg-on',
agent_id: EPHEMERAL_AGENT_ID as string,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' } as unknown as AgentModelParameters,
},
deps,
);
expect(withFlag?.tool_options?.crm_lookup).toEqual({ run_in_background: true });
expect(withFlag?.tool_options?.web_search).toBeUndefined();
const withoutFlag = await loadAgent(
{
req: buildReq('bg-off', false),
spec: 'bg-off',
agent_id: EPHEMERAL_AGENT_ID as string,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' } as unknown as AgentModelParameters,
},
deps,
);
expect(withoutFlag?.tool_options).toBeUndefined();
});
test('should enable full skill scope for ephemeral model spec with skills true', async () => {
const { EPHEMERAL_AGENT_ID } = Constants;

View file

@ -7,9 +7,10 @@ import {
appendAgentIdSuffix,
encodeEphemeralAgentId,
} from 'librechat-data-provider';
import type { Agent, TConversation, TModelSpec } from 'librechat-data-provider';
import type { Agent, AgentToolOptions, TConversation, TModelSpec } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
import { ASK_USER_QUESTION_TOOL_NAME } from '~/agents/hitl/askUserQuestionTool';
import { synthesizeBackgroundToolOptions } from '~/agents/background';
import { requiresEphemeralUserConnection } from '~/mcp/utils';
import { getCustomEndpointConfig } from '~/app/config';
@ -119,6 +120,7 @@ export async function loadAddedAgent(
artifacts?: unknown;
memory?: boolean;
ask_user_question?: boolean;
run_in_background?: boolean;
}
| undefined;
@ -156,6 +158,11 @@ export async function loadAddedAgent(
};
applyModelSpecSkills(result, modelSpec);
applyModelSpecSubagents(result, modelSpec);
const primaryBackgroundToolOptions: AgentToolOptions | undefined =
synthesizeBackgroundToolOptions(result.tools as string[], { ephemeralAgent, modelSpec });
if (primaryBackgroundToolOptions) {
result.tool_options = primaryBackgroundToolOptions;
}
return result as unknown as Agent;
}
@ -267,5 +274,13 @@ export async function loadAddedAgent(
applyModelSpecSubagents(result, modelSpec);
applyModelSpecSkills(result, modelSpec);
const backgroundToolOptions: AgentToolOptions | undefined = synthesizeBackgroundToolOptions(
tools,
{ ephemeralAgent, modelSpec },
);
if (backgroundToolOptions) {
result.tool_options = backgroundToolOptions;
}
return result as unknown as Agent;
}

View file

@ -0,0 +1,766 @@
import type { LCTool, LCToolRegistry } from '@librechat/agents';
import {
isBackgroundEligibleToolName,
isBackgroundRequested,
stripRunInBackgroundArg,
injectRunInBackgroundParam,
stripBackgroundFromToolDefinitions,
stripBackgroundFromToolRegistry,
applyBackgroundToolCalls,
synthesizeBackgroundToolOptions,
registerBackgroundTaskTool,
buildBackgroundHandleContent,
runCheckBackgroundTask,
backgroundTaskRegistry,
BackgroundTaskRegistryClass,
CHECK_BACKGROUND_TASK_NAME,
RUN_IN_BACKGROUND_ARG,
} from './background';
import { toolOptionsSchema } from './validation';
const mcpDef = (name: string): LCTool =>
({
name,
description: `${name} description`,
parameters: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] },
}) as unknown as LCTool;
describe('isBackgroundEligibleToolName', () => {
it('excludes direct-path, host-special, code-session, and machinery tools', () => {
for (const name of [
'execute_code',
'bash_tool',
'read_file',
'skill',
'tool_search',
'run_tools_with_code',
'run_tools_with_bash',
'subagent',
'create_file',
'edit_file',
'set_memory',
'delete_memory',
'ask_user_question',
'web_search',
'file_search',
'dalle',
'dall-e',
'stable-diffusion',
'flux',
'gemini_image_gen',
'image_gen_oai',
'image_edit_oai',
CHECK_BACKGROUND_TASK_NAME,
'lc_transfer_to_researcher',
]) {
expect(isBackgroundEligibleToolName(name)).toBe(false);
}
});
it('allows MCP and other event-driven tools', () => {
for (const name of ['search_mcp_docs', 'lookup_customer', 'fetch_weather']) {
expect(isBackgroundEligibleToolName(name)).toBe(true);
}
});
});
describe('isBackgroundRequested / stripRunInBackgroundArg', () => {
it('detects the flag only when explicitly true on an object', () => {
expect(isBackgroundRequested({ [RUN_IN_BACKGROUND_ARG]: true })).toBe(true);
expect(isBackgroundRequested({ [RUN_IN_BACKGROUND_ARG]: false })).toBe(false);
expect(isBackgroundRequested({})).toBe(false);
expect(isBackgroundRequested('run_in_background')).toBe(false);
expect(isBackgroundRequested(undefined)).toBe(false);
expect(isBackgroundRequested(null)).toBe(false);
});
it('strips the flag without mutating the original', () => {
const args = { q: 'hi', [RUN_IN_BACKGROUND_ARG]: true };
const stripped = stripRunInBackgroundArg(args) as Record<string, unknown>;
expect(stripped).toEqual({ q: 'hi' });
expect(RUN_IN_BACKGROUND_ARG in args).toBe(true);
});
it('returns non-object / flagless args unchanged', () => {
expect(stripRunInBackgroundArg('str')).toBe('str');
const noFlag = { q: 'hi' };
expect(stripRunInBackgroundArg(noFlag)).toBe(noFlag);
});
it('handles stringified JSON args', () => {
expect(isBackgroundRequested('{"run_in_background":true,"q":"x"}')).toBe(true);
expect(isBackgroundRequested('{"q":"x"}')).toBe(false);
expect(isBackgroundRequested('not json')).toBe(false);
expect(stripRunInBackgroundArg('{"run_in_background":true,"q":"x"}')).toEqual({ q: 'x' });
// flag absent -> string returned unchanged (no shape rewrite)
expect(stripRunInBackgroundArg('{"q":"x"}')).toBe('{"q":"x"}');
});
});
describe('injectRunInBackgroundParam', () => {
it('adds a run_in_background boolean without mutating a frozen def', () => {
const def = Object.freeze(mcpDef('search_mcp_docs'));
const injected = injectRunInBackgroundParam(def);
const props = (injected.parameters as { properties: Record<string, { type: string }> })
.properties;
expect(props[RUN_IN_BACKGROUND_ARG]).toEqual(expect.objectContaining({ type: 'boolean' }));
expect(props.q).toEqual({ type: 'string' });
// original untouched
expect(RUN_IN_BACKGROUND_ARG in (def.parameters as { properties: object }).properties).toBe(
false,
);
});
it('creates an object schema when the tool declares no parameters', () => {
const def = { name: 'no_params' } as unknown as LCTool;
const injected = injectRunInBackgroundParam(def);
const params = injected.parameters as { type: string; properties: Record<string, unknown> };
expect(params.type).toBe('object');
expect(params.properties[RUN_IN_BACKGROUND_ARG]).toBeDefined();
});
it('is a no-op when the param already exists', () => {
const def = mcpDef('search_mcp_docs');
const once = injectRunInBackgroundParam(def);
const twice = injectRunInBackgroundParam(once);
expect(twice).toBe(once);
});
});
describe('applyBackgroundToolCalls', () => {
it('is a no-op when no tool opted in (returns the same defs, registers nothing)', () => {
const defs = [mcpDef('search_mcp_docs')];
const registry: LCToolRegistry = new Map();
const result = applyBackgroundToolCalls({
toolDefinitions: defs,
toolRegistry: registry,
toolOptions: { search_mcp_docs: { defer_loading: true } },
});
expect(result.toolDefinitions).toBe(defs);
expect(result.backgroundToolNames).toEqual([]);
expect(registry.has(CHECK_BACKGROUND_TASK_NAME)).toBe(false);
});
it('injects only opted-in eligible tools and registers the poll tool', () => {
const defs = [mcpDef('search_mcp_docs'), mcpDef('lookup_customer')];
const registry: LCToolRegistry = new Map(defs.map((d) => [d.name, { ...d }]));
const result = applyBackgroundToolCalls({
toolDefinitions: defs,
toolRegistry: registry,
toolOptions: { search_mcp_docs: { run_in_background: true } },
});
expect(result.backgroundToolNames).toEqual(['search_mcp_docs']);
const searchDef = result.toolDefinitions.find((d) => d.name === 'search_mcp_docs');
const lookupDef = result.toolDefinitions.find((d) => d.name === 'lookup_customer');
expect(
(searchDef?.parameters as { properties: Record<string, unknown> }).properties[
RUN_IN_BACKGROUND_ARG
],
).toBeDefined();
expect(
(lookupDef?.parameters as { properties: Record<string, unknown> }).properties[
RUN_IN_BACKGROUND_ARG
],
).toBeUndefined();
expect(result.toolDefinitions.some((d) => d.name === CHECK_BACKGROUND_TASK_NAME)).toBe(true);
expect(registry.has(CHECK_BACKGROUND_TASK_NAME)).toBe(true);
// registry entry for the opted-in tool also carries the injected param
expect(
(registry.get('search_mcp_docs')?.parameters as { properties: Record<string, unknown> })
.properties[RUN_IN_BACKGROUND_ARG],
).toBeDefined();
});
it('does not inject or register when an opted-in tool is excluded', () => {
const defs = [mcpDef('read_file')];
const registry: LCToolRegistry = new Map();
const result = applyBackgroundToolCalls({
toolDefinitions: defs,
toolRegistry: registry,
toolOptions: { read_file: { run_in_background: true } },
});
expect(result.backgroundToolNames).toEqual([]);
expect(registry.has(CHECK_BACKGROUND_TASK_NAME)).toBe(false);
});
it('skips a tool the host excludeTool predicate rejects (e.g. ephemeral MCP server)', () => {
const defs = [mcpDef('ephemeral_mcp__body_server'), mcpDef('search_mcp_docs')];
const registry: LCToolRegistry = new Map(defs.map((d) => [d.name, { ...d }]));
const result = applyBackgroundToolCalls({
toolDefinitions: defs,
toolRegistry: registry,
toolOptions: {
ephemeral_mcp__body_server: { run_in_background: true },
search_mcp_docs: { run_in_background: true },
},
excludeTool: (name) => name === 'ephemeral_mcp__body_server',
});
expect(result.backgroundToolNames).toEqual(['search_mcp_docs']);
const ephemeralDef = result.toolDefinitions.find(
(d) => d.name === 'ephemeral_mcp__body_server',
);
expect(
(ephemeralDef?.parameters as { properties?: Record<string, unknown> }).properties?.[
RUN_IN_BACKGROUND_ARG
],
).toBeUndefined();
});
it('skips a non-object (string-input) schema without rewriting it', () => {
const defs = [{ name: 'legacy_tool', parameters: { type: 'string' } } as unknown as LCTool];
const result = applyBackgroundToolCalls({
toolDefinitions: defs,
toolRegistry: new Map(),
toolOptions: { legacy_tool: { run_in_background: true } },
});
expect(result.backgroundToolNames).toEqual([]);
expect((result.toolDefinitions[0].parameters as { type: string }).type).toBe('string');
});
it('skips a tool that already declares its own run_in_background param', () => {
const defs = [
{
name: 'owns_it',
parameters: { type: 'object', properties: { run_in_background: { type: 'boolean' } } },
} as unknown as LCTool,
];
const result = applyBackgroundToolCalls({
toolDefinitions: defs,
toolRegistry: new Map(),
toolOptions: { owns_it: { run_in_background: true } },
});
expect(result.backgroundToolNames).toEqual([]);
});
});
describe('stripBackgroundFromToolDefinitions', () => {
it('removes the poll tool and the injected param (self-spawn sanitization)', () => {
const injected = injectRunInBackgroundParam(mcpDef('search_mcp_docs'));
const withPoll = registerBackgroundTaskTool({
toolRegistry: new Map(),
toolDefinitions: [injected],
}).toolDefinitions;
const stripped = stripBackgroundFromToolDefinitions(withPoll, ['search_mcp_docs']);
expect(stripped.some((d) => d.name === CHECK_BACKGROUND_TASK_NAME)).toBe(false);
const search = stripped.find((d) => d.name === 'search_mcp_docs');
expect(
(search?.parameters as { properties: Record<string, unknown> }).properties[
RUN_IN_BACKGROUND_ARG
],
).toBeUndefined();
});
});
describe('registerBackgroundTaskTool', () => {
it('adds the poll tool once (idempotent)', () => {
const registry: LCToolRegistry = new Map();
const first = registerBackgroundTaskTool({ toolRegistry: registry, toolDefinitions: [] });
expect(first.toolDefinitions).toHaveLength(1);
const second = registerBackgroundTaskTool({
toolRegistry: registry,
toolDefinitions: first.toolDefinitions,
});
expect(second.toolDefinitions).toHaveLength(1);
});
it('reserves the name: shadows a colliding non-poll tool with the host poll schema', () => {
const collidingDef = {
name: CHECK_BACKGROUND_TASK_NAME,
description: 'a user MCP tool that happens to share the name',
parameters: { type: 'object', properties: {} },
} as unknown as LCTool;
const registry: LCToolRegistry = new Map([[CHECK_BACKGROUND_TASK_NAME, { ...collidingDef }]]);
const result = registerBackgroundTaskTool({
toolRegistry: registry,
toolDefinitions: [collidingDef],
});
const matching = result.toolDefinitions.filter((d) => d.name === CHECK_BACKGROUND_TASK_NAME);
expect(matching).toHaveLength(1);
// the surviving def/registry entry is the host poll tool, not the user's
expect(matching[0].description).not.toBe(collidingDef.description);
expect(registry.get(CHECK_BACKGROUND_TASK_NAME)?.description).not.toBe(
collidingDef.description,
);
});
});
describe('synthesizeBackgroundToolOptions', () => {
it('returns undefined when neither the ephemeral toggle nor the model spec enables it', () => {
expect(synthesizeBackgroundToolOptions(['search_mcp_docs'], {})).toBeUndefined();
expect(
synthesizeBackgroundToolOptions(['search_mcp_docs'], {
ephemeralAgent: { run_in_background: false },
modelSpec: { runInBackground: false },
}),
).toBeUndefined();
});
it('marks only eligible tools (excludes code/HITL/attachment built-ins)', () => {
const options = synthesizeBackgroundToolOptions(
['search_mcp_docs', 'execute_code', 'ask_user_question', 'web_search', 'lookup_customer'],
{ ephemeralAgent: { run_in_background: true } },
);
expect(options).toEqual({
search_mcp_docs: { run_in_background: true },
lookup_customer: { run_in_background: true },
});
});
it('returns undefined when nothing is eligible', () => {
expect(
synthesizeBackgroundToolOptions(['execute_code', 'skill'], {
modelSpec: { runInBackground: true },
}),
).toBeUndefined();
});
});
describe('BackgroundTaskRegistryClass', () => {
it('creates, completes, and reads a task', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_1',
toolName: 'search_mcp_docs',
});
expect('atCapacity' in created).toBe(false);
if ('atCapacity' in created) {
return;
}
expect(created.isNew).toBe(true);
expect(created.task.status).toBe('running');
registry.complete('u1', 'c1', created.task.id, { content: 'DONE' });
const task = registry.get('u1', 'c1', created.task.id);
expect(task?.status).toBe('completed');
expect(task?.result).toBe('DONE');
});
it('is idempotent within the same run (never double-dispatches on replay)', () => {
const registry = new BackgroundTaskRegistryClass();
const first = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_dup',
toolName: 'search_mcp_docs',
runId: 'run-A',
});
const second = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_dup',
toolName: 'search_mcp_docs',
runId: 'run-A',
});
if ('atCapacity' in first || 'atCapacity' in second) {
throw new Error('unexpected capacity');
}
expect(second.isNew).toBe(false);
expect(second.task.id).toBe(first.task.id);
});
it('does NOT collide when the same provider toolCallId repeats in a later run/turn', () => {
const registry = new BackgroundTaskRegistryClass();
const turn1 = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_0',
toolName: 'search_mcp_docs',
runId: 'run-turn-1',
});
const turn2 = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_0',
toolName: 'search_mcp_docs',
runId: 'run-turn-2',
});
if ('atCapacity' in turn1 || 'atCapacity' in turn2) {
throw new Error('unexpected capacity');
}
expect(turn2.isNew).toBe(true);
expect(turn2.task.id).not.toBe(turn1.task.id);
});
it('does NOT collide when two agents in the same run emit the same toolCallId', () => {
const registry = new BackgroundTaskRegistryClass();
const agentA = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_0',
toolName: 'search_mcp_docs',
runId: 'run-1',
agentId: 'agent-A',
});
const agentB = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_0',
toolName: 'search_mcp_docs',
runId: 'run-1',
agentId: 'agent-B',
});
if ('atCapacity' in agentA || 'atCapacity' in agentB) {
throw new Error('unexpected capacity');
}
expect(agentB.isNew).toBe(true);
expect(agentB.task.id).not.toBe(agentA.task.id);
});
it('records failures', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_err',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
registry.fail('u1', 'c1', created.task.id, 'boom');
expect(registry.get('u1', 'c1', created.task.id)?.status).toBe('error');
expect(registry.get('u1', 'c1', created.task.id)?.error).toBe('boom');
});
it('holds a completed artifact and claims it exactly once', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_art',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
registry.complete('u1', 'c1', created.task.id, {
content: 'DONE',
artifact: { files: ['a.png'] },
});
expect(registry.get('u1', 'c1', created.task.id)?.artifact).toEqual({ files: ['a.png'] });
const claimed = registry.claimArtifact('u1', 'c1', created.task.id);
expect(claimed).toEqual({
toolName: 'search_mcp_docs',
artifact: { files: ['a.png'] },
content: 'DONE',
});
// second claim yields nothing (delivered once), and the artifact is freed
expect(registry.claimArtifact('u1', 'c1', created.task.id)).toBeUndefined();
expect(registry.get('u1', 'c1', created.task.id)?.artifact).toBeUndefined();
});
it('truncates an oversized stored result with an explicit marker (not a silent cut)', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_big',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
registry.complete('u1', 'c1', created.task.id, { content: 'x'.repeat(150_000) });
const stored = registry.get('u1', 'c1', created.task.id)?.result ?? '';
expect(stored.length).toBeLessThanOrEqual(100_000);
expect(stored).toContain('[truncated: 150000 chars exceeded 100000 limit]');
});
it('restores a claimed artifact after a failed delivery so a later claim retries', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_art_retry',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
registry.complete('u1', 'c1', created.task.id, {
content: 'DONE',
artifact: { files: ['a.png'] },
});
const claimed = registry.claimArtifact('u1', 'c1', created.task.id);
expect(claimed?.artifact).toEqual({ files: ['a.png'] });
// delivery failed: restore, then a fresh claim gets the same artifact once
registry.restoreArtifact('u1', 'c1', created.task.id, claimed?.artifact);
expect(registry.claimArtifact('u1', 'c1', created.task.id)).toEqual({
toolName: 'search_mcp_docs',
artifact: { files: ['a.png'] },
content: 'DONE',
});
expect(registry.claimArtifact('u1', 'c1', created.task.id)).toBeUndefined();
});
it('reaps a stuck running task past the running TTL (frees the slot)', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_stuck',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
// backdate creation past the 30-min running TTL, then trigger a sweep
created.task.createdAt = Date.now() - 31 * 60 * 1000;
registry.list('u1', 'c1');
expect(created.task.status).toBe('error');
expect(created.task.error).toBe('Background task timed out');
});
it('sweeps an expired completed task on direct get() (no indefinite retention)', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_old',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
registry.complete('u1', 'c1', created.task.id, { content: 'X' });
// backdate completion past the 1-hour completed TTL
created.task.updatedAt = Date.now() - 61 * 60 * 1000;
expect(registry.get('u1', 'c1', created.task.id)).toBeUndefined();
});
it('caps concurrent running tasks per conversation', () => {
const registry = new BackgroundTaskRegistryClass();
let atCapacity = false;
for (let i = 0; i < 25; i++) {
const created = registry.create({
userId: 'u1',
conversationId: 'c-cap',
toolCallId: `call_${i}`,
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
atCapacity = true;
break;
}
}
expect(atCapacity).toBe(true);
});
it('evicts oldest settled tasks instead of blocking when the total cap is full', () => {
const registry = new BackgroundTaskRegistryClass();
for (let i = 0; i < 200; i++) {
const created = registry.create({
userId: 'u1',
conversationId: 'c-full',
toolCallId: `call_${i}`,
toolName: 't',
runId: 'r',
agentId: 'a',
});
if ('atCapacity' in created) {
throw new Error(`unexpected capacity at ${i}`);
}
registry.complete('u1', 'c-full', created.task.id, { content: 'x' });
}
// bucket now holds the max number of settled tasks; a new dispatch must
// succeed by evicting the oldest settled task, not be rejected.
const next = registry.create({
userId: 'u1',
conversationId: 'c-full',
toolCallId: 'call_new',
toolName: 't',
runId: 'r',
agentId: 'a',
});
expect('atCapacity' in next).toBe(false);
if ('atCapacity' in next) {
return;
}
expect(next.isNew).toBe(true);
// total held stays bounded (one evicted, one added)
expect(registry.list('u1', 'c-full')).toHaveLength(200);
});
it('scopes tasks by user and conversation', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_scope',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
expect(registry.get('u2', 'c1', created.task.id)).toBeUndefined();
expect(registry.get('u1', 'c2', created.task.id)).toBeUndefined();
expect(registry.list('u2', 'c1')).toHaveLength(0);
});
});
describe('runCheckBackgroundTask (singleton)', () => {
it('returns not_found for an unknown id', () => {
const content = runCheckBackgroundTask({
userId: 'poll_user',
conversationId: 'poll_convo',
args: { background_task_id: 'nope' },
});
expect(JSON.parse(content)).toEqual(
expect.objectContaining({ status: 'not_found', background_task_id: 'nope' }),
);
});
it('returns a single task by id and lists all when omitted', () => {
const created = backgroundTaskRegistry.create({
userId: 'poll_user',
conversationId: 'poll_convo2',
toolCallId: 'call_poll',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
backgroundTaskRegistry.complete('poll_user', 'poll_convo2', created.task.id, {
content: 'RESULT',
});
const single = JSON.parse(
runCheckBackgroundTask({
userId: 'poll_user',
conversationId: 'poll_convo2',
args: { background_task_id: created.task.id },
}),
);
expect(single).toEqual(
expect.objectContaining({
status: 'completed',
result: 'RESULT',
background_task_id: created.task.id,
}),
);
const listed = JSON.parse(
runCheckBackgroundTask({ userId: 'poll_user', conversationId: 'poll_convo2', args: {} }),
);
expect(listed.tasks).toHaveLength(1);
expect(listed.tasks[0].background_task_id).toBe(created.task.id);
// list path must NOT dump full results (context-overflow guard); metadata only
expect(listed.tasks[0].result).toBeUndefined();
expect(listed.tasks[0]).toEqual(
expect.objectContaining({ status: 'completed', result_available: true, result_chars: 6 }),
);
// stringified args must still resolve the specific task (with its full result)
const singleFromString = JSON.parse(
runCheckBackgroundTask({
userId: 'poll_user',
conversationId: 'poll_convo2',
args: `{"background_task_id":"${created.task.id}"}`,
}),
);
expect(singleFromString).toEqual(
expect.objectContaining({ status: 'completed', result: 'RESULT' }),
);
});
it('retrieves a task across turns: the poll is keyed only by id, not the dispatch run/turn', () => {
// Turn 1 dispatches under run-turn-1 and the result lands after the turn.
const dispatched = backgroundTaskRegistry.create({
userId: 'poll_user',
conversationId: 'poll_xturn',
toolCallId: 'call_xturn',
toolName: 'search_mcp_docs',
runId: 'run-turn-1',
agentId: 'agent-A',
});
if ('atCapacity' in dispatched) {
throw new Error('unexpected capacity');
}
backgroundTaskRegistry.complete('poll_user', 'poll_xturn', dispatched.task.id, {
content: 'XTURN_RESULT',
});
// Turn 2 (a later run) polls with just the id; get/list carry no run/turn scope.
const polled = JSON.parse(
runCheckBackgroundTask({
userId: 'poll_user',
conversationId: 'poll_xturn',
args: { background_task_id: dispatched.task.id },
}),
);
expect(polled).toEqual(
expect.objectContaining({
status: 'completed',
result: 'XTURN_RESULT',
background_task_id: dispatched.task.id,
}),
);
});
});
describe('stripBackgroundFromToolRegistry', () => {
it('drops the poll entry and the injected param without mutating the input', () => {
const searchDef = mcpDef('search_mcp_docs');
const registry: LCToolRegistry = new Map([['search_mcp_docs', { ...searchDef }]]);
applyBackgroundToolCalls({
toolDefinitions: [searchDef],
toolRegistry: registry,
toolOptions: { search_mcp_docs: { run_in_background: true } },
});
expect(registry.has(CHECK_BACKGROUND_TASK_NAME)).toBe(true);
const stripped = stripBackgroundFromToolRegistry(registry, ['search_mcp_docs']);
expect(stripped?.has(CHECK_BACKGROUND_TASK_NAME)).toBe(false);
expect(
(stripped?.get('search_mcp_docs')?.parameters as { properties: Record<string, unknown> })
.properties[RUN_IN_BACKGROUND_ARG],
).toBeUndefined();
// original untouched (parent still needs background)
expect(registry.has(CHECK_BACKGROUND_TASK_NAME)).toBe(true);
});
});
describe('buildBackgroundHandleContent', () => {
it('produces a running handle carrying the id and poll instruction', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_h',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
const parsed = JSON.parse(buildBackgroundHandleContent(created.task));
expect(parsed.background_task_id).toBe(created.task.id);
expect(parsed.status).toBe('running');
expect(parsed.message).toContain(CHECK_BACKGROUND_TASK_NAME);
});
});
describe('toolOptionsSchema', () => {
it('preserves run_in_background alongside the existing options', () => {
const parsed = toolOptionsSchema.parse({
defer_loading: true,
run_in_background: true,
allowed_callers: ['direct'],
});
expect(parsed.run_in_background).toBe(true);
expect(parsed.defer_loading).toBe(true);
});
it('strips unknown keys but keeps run_in_background', () => {
const parsed = toolOptionsSchema.parse({
run_in_background: true,
bogus: 'x',
} as Record<string, unknown>);
expect(parsed).toEqual({ run_in_background: true });
});
});

View file

@ -0,0 +1,860 @@
/**
* @fileoverview Background tool calls.
*
* Lets the model dispatch an eligible (event-driven) tool call detached: the
* host executor returns a synthetic handle immediately so the graph superstep
* resolves, while the real work runs as a floating promise whose result lands
* in an in-process registry. The model retrieves it via the
* `check_background_task` poll tool. No `@librechat/agents` change is required —
* a backgrounded call and the poll call are both synchronous from the graph's
* view.
*
* Scope: cross-turn parallelism on a single Node process. The run's abort
* signal does not reach the detached invoke (the graph forwards only
* `configurable`/`metadata` to the tool-execute handler, never `signal`), so
* the floating promise keeps running past turn completion and its result stays
* in the in-process registry for a later turn to poll. Two boundaries: results
* are lost on restart and are not shared across replicas (durable follow-up),
* and ephemeral request-scoped MCP tools (runtime `{{LIBRECHAT_BODY_*}}`
* placeholders) are never backgrounded — their connection is torn down at
* request end, so the executor runs them in the foreground instead.
*
* Opt-in mirrors `deferred_tools`: an admin capability
* (`AgentCapabilities.run_in_background`) gates the feature, and a per-tool
* `tool_options[name].run_in_background` flag turns it on for a given tool,
* which injects a `run_in_background` boolean into that tool's schema.
*
* @module packages/api/src/agents/background
*/
import { randomUUID } from 'node:crypto';
import { logger } from '@librechat/data-schemas';
import { Constants as AgentConstants } from '@librechat/agents';
import { Tools, Constants, imageGenTools } from 'librechat-data-provider';
import type { LCTool, LCToolRegistry, JsonSchemaType } from '@librechat/agents';
import type { AgentToolOptions } from 'librechat-data-provider';
import { SET_MEMORY_TOOL_NAME, DELETE_MEMORY_TOOL_NAME } from './memory';
import { ASK_USER_QUESTION_TOOL_NAME } from './hitl/askUserQuestionTool';
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from './tools';
import { truncateMiddle } from '~/utils';
/** Argument the model sets on a tool call to dispatch it in the background. */
export const RUN_IN_BACKGROUND_ARG = 'run_in_background';
/** Poll tool name (LibreChat host-special-cased, not an SDK tool). */
export const CHECK_BACKGROUND_TASK_NAME: string = Constants.CHECK_BACKGROUND_TASK;
/**
* Tools that must never be backgrounded — they either run through the SDK's
* direct/host-special path (so the host `ON_TOOL_EXECUTE` interception never
* sees them), depend on synchronous artifact/code-session continuity, or are
* the background machinery itself.
*/
const EXCLUDED_BACKGROUND_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
AgentConstants.EXECUTE_CODE,
AgentConstants.BASH_TOOL,
AgentConstants.READ_FILE,
AgentConstants.SKILL_TOOL,
AgentConstants.TOOL_SEARCH,
AgentConstants.PROGRAMMATIC_TOOL_CALLING,
AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING,
AgentConstants.SUBAGENT,
CREATE_FILE_TOOL_NAME,
EDIT_FILE_TOOL_NAME,
SET_MEMORY_TOOL_NAME,
DELETE_MEMORY_TOOL_NAME,
ASK_USER_QUESTION_TOOL_NAME,
CHECK_BACKGROUND_TASK_NAME,
/**
* Built-ins whose results are turned into user-visible attachments/citations
* by the foreground `toolEndCallback`; a detached run stores only content, so
* backgrounding them would silently drop those sources/files. Image-generation
* tools are artifact-first — their files can't be reliably attached to an
* already-saved turn — so they're excluded rather than degraded. Sourced from
* the shared `imageGenTools` set plus the OAI toolkit ids it doesn't cover.
*/
Tools.web_search,
Tools.file_search,
...imageGenTools,
'image_gen_oai',
'image_edit_oai',
]);
/**
* Whether a tool may be dispatched in the background. Handoff tools
* (`lc_transfer_to_*`) run through the direct path and are excluded by prefix.
*/
export function isBackgroundEligibleToolName(name: string): boolean {
if (EXCLUDED_BACKGROUND_TOOL_NAMES.has(name)) {
return false;
}
return !name.startsWith(AgentConstants.LC_TRANSFER_TO_);
}
/**
* Coerces tool-call args to an object, parsing a stringified JSON object (some
* providers deliver args as a string). Returns undefined for non-object args.
*/
function coerceArgsObject(args: unknown): Record<string, unknown> | undefined {
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
return args as Record<string, unknown>;
}
if (typeof args === 'string' && args.trim().startsWith('{')) {
try {
const parsed = JSON.parse(args) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return undefined;
}
}
return undefined;
}
/** Whether tool-call args request background dispatch (handles stringified args). */
export function isBackgroundRequested(args: unknown): boolean {
return coerceArgsObject(args)?.[RUN_IN_BACKGROUND_ARG] === true;
}
/** Whether tool-call args carry the `run_in_background` key at all (any value). */
export function hasRunInBackgroundArg(args: unknown): boolean {
const obj = coerceArgsObject(args);
return obj != null && RUN_IN_BACKGROUND_ARG in obj;
}
/**
* Returns the args without the injected `run_in_background` key so the real
* tool never receives a parameter it doesn't declare. Parses stringified JSON
* object args; returns the value unchanged when the flag is absent.
*/
export function stripRunInBackgroundArg(args: unknown): unknown {
const obj = coerceArgsObject(args);
if (!obj || !(RUN_IN_BACKGROUND_ARG in obj)) {
return args;
}
const { [RUN_IN_BACKGROUND_ARG]: _omit, ...rest } = obj;
return rest;
}
const RUN_IN_BACKGROUND_PROPERTY: JsonSchemaType = Object.freeze<JsonSchemaType>({
type: 'boolean',
description:
'Set true to run this tool call in the background: it returns immediately with a background_task_id instead of blocking, so you can keep working while it runs. Poll check_background_task with that id to collect the result. The task persists on this server, so you may collect it later in this turn or in a following turn (it does not survive a server restart). Use for a slow call whose result you do not need right away.',
});
/**
* Returns a copy of the tool definition with a `run_in_background` boolean
* added to its parameters. Never mutates the input (built-in defs are frozen
* and MCP defs may be shared), and is a no-op if the property already exists.
*/
export function injectRunInBackgroundParam(def: LCTool): LCTool {
const params = def.parameters;
const existingProps = params?.properties ?? {};
if (RUN_IN_BACKGROUND_ARG in existingProps) {
return def;
}
const nextParams: JsonSchemaType = {
...params,
type: 'object',
properties: { ...existingProps, [RUN_IN_BACKGROUND_ARG]: RUN_IN_BACKGROUND_PROPERTY },
};
return { ...def, parameters: nextParams };
}
/**
* Whether the `run_in_background` param can be cleanly injected into a tool.
* False for non-object (e.g. string-input/DynamicTool) schemas — rewriting them
* to an object would break the tool's input contract — and for tools that
* already declare their own `run_in_background` parameter (which the executor
* would otherwise hijack/strip).
*/
function canInjectRunInBackgroundParam(def: LCTool): boolean {
const params = def.parameters;
if (params == null) {
return true;
}
if (params.type != null && params.type !== 'object') {
return false;
}
return !(params.properties != null && RUN_IN_BACKGROUND_ARG in params.properties);
}
/** Returns a copy of the def without the injected `run_in_background` property. */
function removeRunInBackgroundParam(def: LCTool): LCTool {
const params = def.parameters;
if (params?.properties == null || !(RUN_IN_BACKGROUND_ARG in params.properties)) {
return def;
}
const { [RUN_IN_BACKGROUND_ARG]: _omit, ...restProps } = params.properties;
return { ...def, parameters: { ...params, properties: restProps } };
}
/**
* Removes the background additions (the injected param + the `check_background_task`
* def) from a tool-definition list. Used to sanitize a self-spawn subagent's
* inherited inputs so it doesn't advertise a background schema the isolated child
* path can't honor.
*/
export function stripBackgroundFromToolDefinitions(
toolDefinitions: LCTool[] | undefined,
backgroundToolNames: string[] | undefined,
): LCTool[] {
const defs = toolDefinitions ?? [];
const bgSet = new Set(backgroundToolNames ?? []);
const next: LCTool[] = [];
let changed = false;
for (const def of defs) {
if (def.name === CHECK_BACKGROUND_TASK_NAME) {
changed = true;
continue;
}
const stripped = bgSet.size > 0 && bgSet.has(def.name) ? removeRunInBackgroundParam(def) : def;
if (stripped !== def) {
changed = true;
}
next.push(stripped);
}
return changed ? next : defs;
}
/**
* Registry counterpart of {@link stripBackgroundFromToolDefinitions}. Returns a
* NEW registry (never mutates the shared parent one) without the poll tool and
* with the injected param removed, so a self-spawn child that uses
* tool_search/deferred loading can't rediscover the host-only background schema.
*/
export function stripBackgroundFromToolRegistry(
toolRegistry: LCToolRegistry | undefined,
backgroundToolNames: string[] | undefined,
): LCToolRegistry | undefined {
if (!toolRegistry) {
return toolRegistry;
}
const bgSet = new Set(backgroundToolNames ?? []);
if (bgSet.size === 0 && !toolRegistry.has(CHECK_BACKGROUND_TASK_NAME)) {
return toolRegistry;
}
const next: LCToolRegistry = new Map();
for (const [name, def] of toolRegistry) {
if (name === CHECK_BACKGROUND_TASK_NAME) {
continue;
}
next.set(name, bgSet.has(name) ? removeRunInBackgroundParam(def) : def);
}
return next;
}
const CHECK_BACKGROUND_TASK_DESCRIPTION = `Check the status and retrieve the result of tool calls previously dispatched in the background (with run_in_background: true).
Provide a background_task_id to poll one task; omit it to list every background task in this conversation. A task is only finished when its status is "completed" or "error" — never assume completion without polling. Results are not pushed to you; you must call this tool to collect them. Background tasks persist on this server across turns, so you can collect a result in a later turn; they do not survive a server restart.`;
const CHECK_BACKGROUND_TASK_PARAMETERS: JsonSchemaType = Object.freeze<JsonSchemaType>({
type: 'object',
properties: {
background_task_id: {
type: 'string',
description:
'The id returned when the tool call was dispatched. Omit to list the status of all background tasks in this conversation.',
},
},
required: [],
});
const CHECK_BACKGROUND_TASK_DEF: LCTool = Object.freeze<LCTool>({
name: CHECK_BACKGROUND_TASK_NAME,
description: CHECK_BACKGROUND_TASK_DESCRIPTION,
parameters: CHECK_BACKGROUND_TASK_PARAMETERS,
});
/**
* Idempotently registers the `check_background_task` poll tool into the run's
* tool definitions and registry. Mirrors `registerCodeExecutionTools`.
*/
export function registerBackgroundTaskTool(params: {
toolRegistry: LCToolRegistry | undefined;
toolDefinitions: LCTool[] | undefined;
}): { toolDefinitions: LCTool[] } {
const { toolRegistry, toolDefinitions } = params;
const defs = toolDefinitions ?? [];
const isOurs = (tool?: { description?: string }): boolean =>
tool?.description === CHECK_BACKGROUND_TASK_DESCRIPTION;
const existingDef = defs.find((d) => d.name === CHECK_BACKGROUND_TASK_NAME);
const existingRegistry = toolRegistry?.get(CHECK_BACKGROUND_TASK_NAME);
/** Already registered by us — idempotent no-op. */
if (isOurs(existingDef) || isOurs(existingRegistry)) {
return { toolDefinitions: defs };
}
/**
* The name is reserved: since the executor intercepts every
* `check_background_task` call in a background-enabled run, a user/MCP tool
* with the same name must not be advertised (its schema would mismatch the
* interception). Overwrite so the model sees the poll schema the host honors,
* and warn that the colliding tool is shadowed.
*/
const collides = existingDef != null || existingRegistry != null;
if (collides) {
logger.warn(
`[background] A tool named "${CHECK_BACKGROUND_TASK_NAME}" collides with the reserved background poll tool; the host poll tool takes precedence and the colliding tool is shadowed for this run.`,
);
}
toolRegistry?.set(CHECK_BACKGROUND_TASK_NAME, {
name: CHECK_BACKGROUND_TASK_NAME,
description: CHECK_BACKGROUND_TASK_DESCRIPTION,
parameters: CHECK_BACKGROUND_TASK_PARAMETERS,
allowed_callers: ['direct'],
});
const withoutCollision = collides
? defs.filter((d) => d.name !== CHECK_BACKGROUND_TASK_NAME)
: defs;
return { toolDefinitions: [...withoutCollision, CHECK_BACKGROUND_TASK_DEF] };
}
/**
* Injects the `run_in_background` param into every opted-in, eligible tool and
* registers the poll tool when at least one tool became backgroundable.
*
* Opt-in is per tool via `tool_options[name].run_in_background`. Both saved
* agents and ephemeral/model-spec agents reach this with `tool_options`
* populated, so the logic is written once.
*/
export function applyBackgroundToolCalls(params: {
toolDefinitions: LCTool[] | undefined;
toolRegistry: LCToolRegistry | undefined;
toolOptions: AgentToolOptions | undefined;
/**
* Extra host-context exclusion (e.g. tools of ephemeral request-scoped MCP
* servers, whose connection dies at request end): a `true` return skips the
* param injection entirely so the model is never shown an option the
* executor would silently downgrade to foreground.
*/
excludeTool?: (toolName: string) => boolean;
}): { toolDefinitions: LCTool[]; backgroundToolNames: string[] } {
const { toolRegistry, toolOptions, excludeTool } = params;
const defs = params.toolDefinitions ?? [];
if (!toolOptions || !Object.values(toolOptions).some((o) => o?.run_in_background === true)) {
return { toolDefinitions: defs, backgroundToolNames: [] };
}
const backgroundToolNames: string[] = [];
const nextDefs = defs.map((def) => {
const optedIn = toolOptions[def.name]?.run_in_background === true;
if (!optedIn || !isBackgroundEligibleToolName(def.name) || excludeTool?.(def.name) === true) {
return def;
}
if (!canInjectRunInBackgroundParam(def)) {
logger.warn(
`[background] Skipping run_in_background for "${def.name}": non-object schema or the tool already declares the parameter.`,
);
return def;
}
backgroundToolNames.push(def.name);
const injected = injectRunInBackgroundParam(def);
if (injected === def) {
return def;
}
const registryEntry = toolRegistry?.get(def.name);
if (registryEntry) {
toolRegistry?.set(def.name, { ...registryEntry, parameters: injected.parameters });
}
return injected;
});
if (backgroundToolNames.length === 0) {
return { toolDefinitions: defs, backgroundToolNames: [] };
}
const withPoll = registerBackgroundTaskTool({ toolRegistry, toolDefinitions: nextDefs });
return { toolDefinitions: withPoll.toolDefinitions, backgroundToolNames };
}
/**
* Builds `tool_options` marking each eligible tool as backgroundable. Ephemeral
* and model-spec agents carry no `tool_options`, so the blanket spec/ephemeral
* toggle is expanded per-tool here to reuse the same per-tool opt-in the saved
* agent path uses. Returns undefined when disabled or nothing is eligible.
*
* Note: MCP servers that expand lazily (via the `mcp_all` placeholder for
* overlay/user-connection servers) are not known by name at this point, so
* their tools are not marked; standard cached MCP servers push real names and
* are covered.
*/
export function synthesizeBackgroundToolOptions(
tools: string[],
sources: {
ephemeralAgent?: { run_in_background?: boolean } | null;
modelSpec?: { runInBackground?: boolean } | null;
},
): AgentToolOptions | undefined {
const enabled =
sources.ephemeralAgent?.run_in_background === true ||
sources.modelSpec?.runInBackground === true;
if (!enabled) {
return undefined;
}
const toolOptions: AgentToolOptions = {};
for (const name of tools) {
if (isBackgroundEligibleToolName(name)) {
toolOptions[name] = { run_in_background: true };
}
}
return Object.keys(toolOptions).length > 0 ? toolOptions : undefined;
}
export type BackgroundTaskStatus = 'running' | 'completed' | 'error';
export interface BackgroundTask {
id: string;
toolName: string;
toolCallId: string;
status: BackgroundTaskStatus;
/** Tool result content once completed. */
result?: string;
/**
* The completed tool's artifact, held until the poll turn collects it (a
* backgrounded call's own turn is finalized before the artifact resolves, so
* it can't ride that turn). Cleared once delivered to free memory.
*/
artifact?: unknown;
/** True once the artifact has been handed to a live poll turn's callback. */
artifactDelivered?: boolean;
/** Error message when status === 'error'. */
error?: string;
createdAt: number;
updatedAt: number;
}
interface TaskBucket {
tasks: Map<string, BackgroundTask>;
/** toolCallId -> taskId, for dispatch idempotency across graph re-execution. */
byToolCall: Map<string, string>;
lastAccess: number;
}
const COMPLETED_TASK_TTL_MS = 60 * 60 * 1000;
const IDLE_BUCKET_TTL_MS = 6 * 60 * 60 * 1000;
/** Max wall-clock a task may stay `running` before being reaped as timed-out,
* so a detached call that never settles (hung network / lost MCP connection)
* can't hold a running slot and exhaust the per-conversation cap forever. */
const RUNNING_TASK_TTL_MS = 30 * 60 * 1000;
const MAX_RUNNING_PER_BUCKET = 10;
const MAX_TASKS_PER_BUCKET = 200;
const MAX_RESULT_CHARS = 100_000;
const MAX_ARTIFACT_CHARS = 10_000_000;
const GLOBAL_SWEEP_INTERVAL_MS = 60 * 1000;
function toStoredContent(content: unknown): string {
const asString = typeof content === 'string' ? content : JSON.stringify(content ?? '');
return truncateMiddle(asString, MAX_RESULT_CHARS);
}
/**
* Bounds retained artifact memory: an artifact is held for up to the completed
* TTL, so a runaway payload (huge base64 blobs) is dropped rather than pinned.
* Measurement failures (circular refs) keep the artifact.
*/
function toStoredArtifact(taskId: string, artifact: unknown): unknown {
if (artifact == null) {
return undefined;
}
try {
const size = JSON.stringify(artifact)?.length ?? 0;
if (size > MAX_ARTIFACT_CHARS) {
logger.warn(
`[background] Dropping oversized artifact for task ${taskId} (${size} chars > ${MAX_ARTIFACT_CHARS}).`,
);
return undefined;
}
} catch {
/* unmeasurable artifact: keep it */
}
return artifact;
}
/**
* In-process store of background tool tasks, scoped per user + conversation.
*
* MVP scope: single Node process. A task's result survives across turns within
* the same server process (a floating promise keeps running on the event loop),
* but is lost on restart and is not shared across Redis replicas — a run resumed
* on another replica cannot see it. A Redis-backed store would slot in behind
* this same interface as a follow-up.
*/
export class BackgroundTaskRegistryClass {
private readonly buckets = new Map<string, TaskBucket>();
private lastGlobalSweepAt = 0;
private key(userId: string, conversationId: string): string {
return `${userId}::${conversationId}`;
}
private sweepBucketTasks(bucket: TaskBucket, now: number): void {
for (const [taskId, task] of bucket.tasks) {
if (task.status === 'running' && now - task.createdAt > RUNNING_TASK_TTL_MS) {
/** Reap a stuck task: freeing the running slot (it no longer counts
* toward the cap) and letting the completed-task TTL evict it. */
task.status = 'error';
task.error = 'Background task timed out';
task.updatedAt = now;
continue;
}
if (task.status !== 'running' && now - task.updatedAt > COMPLETED_TASK_TTL_MS) {
bucket.tasks.delete(taskId);
}
}
/** Drop dedupe mappings whose task was evicted (keys are
* `agentId::runId::toolCallId`, so they can't be derived from a task alone). */
for (const [dedupeKey, taskId] of bucket.byToolCall) {
if (!bucket.tasks.has(taskId)) {
bucket.byToolCall.delete(dedupeKey);
}
}
}
/**
* Accessors always sweep the bucket they touch (so TTLs hold exactly for the
* data being read), while the all-buckets pass — needed only for idle-bucket
* eviction and untouched buckets — is throttled so a hot poll loop isn't
* O(total tasks server-wide) on every call.
*/
private sweep(now: number): void {
if (now - this.lastGlobalSweepAt < GLOBAL_SWEEP_INTERVAL_MS) {
return;
}
this.lastGlobalSweepAt = now;
for (const [bucketKey, bucket] of this.buckets) {
if (now - bucket.lastAccess > IDLE_BUCKET_TTL_MS) {
this.buckets.delete(bucketKey);
continue;
}
this.sweepBucketTasks(bucket, now);
}
}
private getBucket(userId: string, conversationId: string, now: number): TaskBucket {
const bucketKey = this.key(userId, conversationId);
let bucket = this.buckets.get(bucketKey);
if (!bucket) {
bucket = { tasks: new Map(), byToolCall: new Map(), lastAccess: now };
this.buckets.set(bucketKey, bucket);
}
bucket.lastAccess = now;
return bucket;
}
/**
* Registers a task for a tool call. Returns the existing task (and
* `isNew: false`) only when the SAME run re-dispatched the same `toolCallId`
* (a resume/replay) — the caller must not start the work twice. Returns
* `atCapacity: true` when the per-conversation running cap is reached.
*
* The dedupe key includes `agentId` + `runId` because provider tool-call ids
* repeat across turns AND across agents in one run (e.g. `call_0` per
* response); keying on `toolCallId` alone would make a later turn's — or a
* second agent's — identically-named call collide with a prior (retained)
* task and hand back a stale/foreign result instead of executing.
*/
create(params: {
userId: string;
conversationId: string;
toolCallId: string;
toolName: string;
runId?: string;
agentId?: string;
}): { task: BackgroundTask; isNew: boolean } | { atCapacity: true } {
const now = Date.now();
this.sweep(now);
const bucket = this.getBucket(params.userId, params.conversationId, now);
this.sweepBucketTasks(bucket, now);
const dedupeKey = `${params.agentId ?? ''}::${params.runId ?? ''}::${params.toolCallId}`;
const existingId = bucket.byToolCall.get(dedupeKey);
if (existingId) {
const existing = bucket.tasks.get(existingId);
if (existing) {
return { task: existing, isNew: false };
}
}
let running = 0;
for (const task of bucket.tasks.values()) {
if (task.status === 'running') {
running++;
}
}
/** Only *running* tasks gate dispatch. */
if (running >= MAX_RUNNING_PER_BUCKET) {
return { atCapacity: true };
}
/** The total-tasks cap bounds memory but must NOT block new work: evict the
* oldest SETTLED tasks to make room rather than rejecting (settled tasks
* aren't removed by polling, so 200 quick calls would otherwise block for
* up to the completed-TTL). Running is already capped, so room always frees. */
if (bucket.tasks.size >= MAX_TASKS_PER_BUCKET) {
const settledOldestFirst = [...bucket.tasks.values()]
.filter((t) => t.status !== 'running')
.sort((a, b) => a.createdAt - b.createdAt);
let toEvict = bucket.tasks.size - MAX_TASKS_PER_BUCKET + 1;
for (const stale of settledOldestFirst) {
if (toEvict <= 0) {
break;
}
bucket.tasks.delete(stale.id);
toEvict--;
}
}
const task: BackgroundTask = {
id: randomUUID(),
toolName: params.toolName,
toolCallId: params.toolCallId,
status: 'running',
createdAt: now,
updatedAt: now,
};
bucket.tasks.set(task.id, task);
bucket.byToolCall.set(dedupeKey, task.id);
return { task, isNew: true };
}
private update(
userId: string,
conversationId: string,
taskId: string,
patch: Partial<BackgroundTask>,
): void {
const bucket = this.buckets.get(this.key(userId, conversationId));
const task = bucket?.tasks.get(taskId);
if (!task) {
return;
}
Object.assign(task, patch, { updatedAt: Date.now() });
}
complete(
userId: string,
conversationId: string,
taskId: string,
result: { content: unknown; artifact?: unknown },
): void {
this.update(userId, conversationId, taskId, {
status: 'completed',
result: toStoredContent(result.content),
artifact: toStoredArtifact(taskId, result.artifact),
/** Marks that an artifact existed even after `claimArtifact` clears it,
* so re-polls keep the "produced an artifact" note. */
artifactDelivered: false,
});
}
/**
* Returns a completed task's artifact exactly once, marking it delivered and
* clearing it. The poll turn routes it to a live `toolEndCallback` so the
* artifact isn't lost with the finalized dispatch turn. If handing it to the
* callback throws synchronously, `restoreArtifact` puts it back so a later
* poll can retry. Note the callback's own persistence is fire-and-forget
* (failures are swallowed downstream), so delivery is at-most-once — the
* same semantics a foreground tool's artifact has.
*/
claimArtifact(
userId: string,
conversationId: string,
taskId: string,
): { toolName: string; artifact: unknown; content?: string } | undefined {
const bucket = this.buckets.get(this.key(userId, conversationId));
const task = bucket?.tasks.get(taskId);
if (!task || task.status !== 'completed' || task.artifact == null || task.artifactDelivered) {
return undefined;
}
const artifact = task.artifact;
task.artifactDelivered = true;
task.artifact = undefined;
return { toolName: task.toolName, artifact, content: task.result };
}
/**
* Puts a claimed artifact back after a synchronous delivery failure so the
* next poll retries it. No-op if the task was swept or already holds an
* artifact.
*/
restoreArtifact(userId: string, conversationId: string, taskId: string, artifact: unknown): void {
const bucket = this.buckets.get(this.key(userId, conversationId));
const task = bucket?.tasks.get(taskId);
if (!task || task.artifact != null) {
return;
}
task.artifact = artifact;
task.artifactDelivered = false;
}
fail(userId: string, conversationId: string, taskId: string, error: string): void {
this.update(userId, conversationId, taskId, { status: 'error', error });
}
get(userId: string, conversationId: string, taskId: string): BackgroundTask | undefined {
const now = Date.now();
/** Sweep before returning so repeated polling of a known id can't keep an
* expired task (and its retained result) alive past the completed TTL. */
this.sweep(now);
const bucket = this.buckets.get(this.key(userId, conversationId));
if (!bucket) {
return undefined;
}
bucket.lastAccess = now;
this.sweepBucketTasks(bucket, now);
return bucket.tasks.get(taskId);
}
list(userId: string, conversationId: string): BackgroundTask[] {
const now = Date.now();
this.sweep(now);
const bucket = this.buckets.get(this.key(userId, conversationId));
if (!bucket) {
return [];
}
bucket.lastAccess = now;
this.sweepBucketTasks(bucket, now);
return [...bucket.tasks.values()].sort((a, b) => a.createdAt - b.createdAt);
}
}
export const backgroundTaskRegistry = new BackgroundTaskRegistryClass();
/** Content for the synthetic ToolMessage returned when a call is backgrounded. */
export function buildBackgroundHandleContent(task: BackgroundTask): string {
return JSON.stringify({
background_task_id: task.id,
tool: task.toolName,
status: task.status,
message: `Started "${task.toolName}" in the background. Call ${CHECK_BACKGROUND_TASK_NAME} with background_task_id "${task.id}" to check progress and retrieve the result; it persists on this server, so you may poll it later in this turn or in a following turn. Do not assume it has finished until you have polled and seen status "completed".`,
});
}
/** Content returned when the per-conversation background running cap is hit. */
export function buildBackgroundCapacityContent(toolName: string): string {
return JSON.stringify({
status: 'rejected',
tool: toolName,
message: `Too many background tasks are already running in this conversation (limit ${MAX_RUNNING_PER_BUCKET}). Poll ${CHECK_BACKGROUND_TASK_NAME} to collect finished results before dispatching more, or run this call in the foreground.`,
});
}
/**
* Serializes a task for the poll tool. The list path (`includeResult: false`)
* returns metadata only — never the full `result` — so a status-list poll can't
* inject megabytes of retained tool output into the next model step. The full
* result is only returned when a specific `background_task_id` is requested.
*/
interface SerializedBackgroundTask {
background_task_id: string;
tool: string;
status: BackgroundTaskStatus;
/** Coarse 0..1: no intermediate progress exists, only running vs settled. */
progress: number;
result?: string;
result_available?: boolean;
result_chars?: number;
note?: string;
error?: string;
}
function resultFields(
task: BackgroundTask,
includeResult: boolean,
): Pick<SerializedBackgroundTask, 'result' | 'result_available' | 'result_chars'> {
if (task.result === undefined) {
return {};
}
if (includeResult) {
return { result: task.result };
}
return { result_available: true, result_chars: task.result.length };
}
function serializeTask(
task: BackgroundTask,
{ includeResult }: { includeResult: boolean },
): SerializedBackgroundTask {
return {
background_task_id: task.id,
tool: task.toolName,
status: task.status,
progress: task.status === 'running' ? 0 : 1,
...resultFields(task, includeResult),
...(task.artifact != null || task.artifactDelivered === true
? { note: 'The tool produced an artifact that is not included inline.' }
: {}),
...(task.error !== undefined ? { error: task.error } : {}),
};
}
/** Executes a `check_background_task` call and returns the ToolMessage content. */
export function runCheckBackgroundTask(params: {
userId: string;
conversationId: string;
args: unknown;
}): string {
const { userId, conversationId } = params;
const rawId = coerceArgsObject(params.args)?.background_task_id;
const taskId = typeof rawId === 'string' && rawId.trim() !== '' ? rawId.trim() : undefined;
if (taskId) {
const task = backgroundTaskRegistry.get(userId, conversationId, taskId);
if (!task) {
return JSON.stringify({
status: 'not_found',
background_task_id: taskId,
message: 'No background task with that id exists in this conversation.',
});
}
return JSON.stringify(serializeTask(task, { includeResult: true }));
}
const tasks = backgroundTaskRegistry.list(userId, conversationId);
logger.debug(`[background] check_background_task listed ${tasks.length} task(s)`);
return JSON.stringify({
tasks: tasks.map((task) => serializeTask(task, { includeResult: false })),
});
}
/**
* When a `check_background_task` call targets a specific completed task that
* produced an artifact, returns that artifact once (marking it delivered) so the
* poll turn's live callback can persist it. Returns undefined for the list form,
* an unknown id, or an already-delivered/artifact-less task.
*/
export function claimBackgroundArtifact(params: {
userId: string;
conversationId: string;
args: unknown;
}): { taskId: string; toolName: string; artifact: unknown; content?: string } | undefined {
const rawId = coerceArgsObject(params.args)?.background_task_id;
const taskId = typeof rawId === 'string' && rawId.trim() !== '' ? rawId.trim() : undefined;
if (!taskId) {
return undefined;
}
const claimed = backgroundTaskRegistry.claimArtifact(
params.userId,
params.conversationId,
taskId,
);
return claimed ? { taskId, ...claimed } : undefined;
}
/** Reverses a `claimBackgroundArtifact` after a failed delivery (see `restoreArtifact`). */
export function restoreBackgroundArtifact(params: {
userId: string;
conversationId: string;
taskId: string;
artifact: unknown;
}): void {
backgroundTaskRegistry.restoreArtifact(
params.userId,
params.conversationId,
params.taskId,
params.artifact,
);
}

View file

@ -96,6 +96,13 @@ export interface DiscoverConnectedAgentsParams {
* `set_memory` + `delete_memory` pair only when the parent run permits it.
*/
memoryAvailable?: InitializeAgentParams['memoryAvailable'];
/**
* Run-level `run_in_background` capability gate. Forwarded verbatim so a
* handoff/connected agent's own event-driven tools with
* `tool_options[tool].run_in_background` get the injected param + poll tool,
* matching how the same agent behaves when run as the primary.
*/
backgroundToolsAvailable?: InitializeAgentParams['backgroundToolsAvailable'];
}
export interface DiscoverConnectedAgentsDeps {
@ -165,6 +172,7 @@ export async function discoverConnectedAgents(
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
} = params;
@ -270,6 +278,7 @@ export async function discoverConnectedAgents(
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
},

View file

@ -0,0 +1,526 @@
import { z } from 'zod';
import type { StructuredToolInterface } from '@librechat/agents/langchain/tools';
import { CHECK_BACKGROUND_TASK_NAME } from './background';
import { createToolExecuteHandler } from './handlers';
interface BatchInput {
toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }>;
agentId: string;
configurable: Record<string, unknown>;
metadata: Record<string, unknown>;
resolve: (results: Array<{ content: string }>) => void;
reject: (error: Error) => void;
}
const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve));
const makeSearchTool = (state: { calls: number; lastInput?: Record<string, unknown> }) =>
({
name: 'search_mcp_docs',
description: 'search docs',
schema: z.object({ q: z.string() }),
invoke: async (input: Record<string, unknown>) => {
state.calls += 1;
state.lastInput = input;
return { content: `RESULT for ${String(input.q)}` };
},
}) as unknown as StructuredToolInterface;
const buildConfig = (backgroundToolNames: string[] = ['search_mcp_docs']) => ({
req: { user: { id: 'exec_user' }, body: { conversationId: 'exec_convo' } },
backgroundToolNames,
});
const runBatch = async (
handler: ReturnType<typeof createToolExecuteHandler>,
input: Omit<BatchInput, 'resolve' | 'reject'>,
): Promise<Array<{ content: string }>> => {
let out: Array<{ content: string }> = [];
await handler.handle('on_tool_execute', {
...input,
resolve: (results: Array<{ content: string }>) => {
out = results;
},
reject: (error: Error) => {
throw error;
},
} as unknown as Parameters<typeof handler.handle>[1]);
return out;
};
describe('createToolExecuteHandler — background tool calls', () => {
it('returns a handle immediately, runs the tool once detached, and yields the result via check_background_task', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const searchTool = makeSearchTool(state);
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [searchTool] }),
});
const configurable = buildConfig();
const metadata = { thread_id: 'exec_convo' };
const dispatchResults = await runBatch(handler, {
toolCalls: [
{
id: 'call_bg',
name: 'search_mcp_docs',
args: { q: 'librechat', run_in_background: true },
},
],
agentId: 'agent_1',
configurable,
metadata,
});
expect(dispatchResults).toHaveLength(1);
const handle = JSON.parse(dispatchResults[0].content);
expect(handle.status).toBe('running');
expect(typeof handle.background_task_id).toBe('string');
await flushMicrotasks();
await flushMicrotasks();
// real tool ran exactly once, without the injected flag
expect(state.calls).toBe(1);
expect(state.lastInput).toEqual({ q: 'librechat' });
const pollResults = await runBatch(handler, {
toolCalls: [
{
id: 'call_poll',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: handle.background_task_id },
},
],
agentId: 'agent_1',
configurable,
metadata,
});
const polled = JSON.parse(pollResults[0].content);
expect(polled.status).toBe('completed');
expect(polled.result).toContain('RESULT for librechat');
});
it('does not double-dispatch when the same tool call re-executes (resume/replay)', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const searchTool = makeSearchTool(state);
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [searchTool] }),
});
const configurable = buildConfig();
const metadata = { thread_id: 'exec_convo_dup' };
const toolCalls = [
{ id: 'call_same', name: 'search_mcp_docs', args: { q: 'x', run_in_background: true } },
];
const first = await runBatch(handler, { toolCalls, agentId: 'a', configurable, metadata });
await flushMicrotasks();
const second = await runBatch(handler, { toolCalls, agentId: 'a', configurable, metadata });
await flushMicrotasks();
const firstId = JSON.parse(first[0].content).background_task_id;
const secondId = JSON.parse(second[0].content).background_task_id;
expect(secondId).toBe(firstId);
expect(state.calls).toBe(1);
});
it('runs the tool synchronously when background is not requested', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const searchTool = makeSearchTool(state);
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [searchTool] }),
});
const configurable = buildConfig();
const metadata = { thread_id: 'exec_convo_sync' };
const results = await runBatch(handler, {
toolCalls: [{ id: 'call_sync', name: 'search_mcp_docs', args: { q: 'now' } }],
agentId: 'a',
configurable,
metadata,
});
expect(state.calls).toBe(1);
expect(results[0].content).toContain('RESULT for now');
});
it('enforces the per-tool opt-in: a tool not in backgroundToolNames runs foreground even with the flag', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const searchTool = makeSearchTool(state);
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [searchTool] }),
});
// background enabled for the run, but for a DIFFERENT tool
const configurable = buildConfig(['some_other_tool']);
const metadata = { thread_id: 'exec_convo_gate' };
const results = await runBatch(handler, {
toolCalls: [
{ id: 'call_gate', name: 'search_mcp_docs', args: { q: 'x', run_in_background: true } },
],
agentId: 'a',
configurable,
metadata,
});
// ran in the foreground; result is the tool output, not a background handle
expect(state.calls).toBe(1);
expect(results[0].content).toContain('RESULT for x');
});
it('never backgrounds an ephemeral request-scoped MCP tool: runs it foreground (no detached, leak-free)', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const ephemeralTool = {
name: 'search_mcp_docs',
description: 'search docs',
schema: z.object({ q: z.string() }),
// Tagged in createToolInstance for servers on a runtime-body-placeholder
// connection, which is torn down at request end.
mcpRequiresEphemeralConnection: true,
invoke: async (input: Record<string, unknown>) => {
state.calls += 1;
state.lastInput = input;
return { content: `RESULT for ${String(input.q)}` };
},
} as unknown as StructuredToolInterface;
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [ephemeralTool] }),
});
// background IS enabled for this tool, and the model asked to background it
const configurable = buildConfig();
const metadata = { thread_id: 'exec_convo_ephemeral' };
const results = await runBatch(handler, {
toolCalls: [
{ id: 'call_eph', name: 'search_mcp_docs', args: { q: 'z', run_in_background: true } },
],
agentId: 'a',
configurable,
metadata,
});
// ran synchronously in the foreground: real output inline, not a handle, flag stripped
expect(state.calls).toBe(1);
expect(state.lastInput).toEqual({ q: 'z' });
expect(results[0].content).toContain('RESULT for z');
expect(results[0].content).not.toContain('background_task_id');
});
it('does not intercept a check_background_task-named tool when background is off for the run', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const collisionTool = {
name: CHECK_BACKGROUND_TASK_NAME,
description: 'a user MCP tool that happens to share the name',
schema: z.object({ q: z.string() }),
invoke: async (input: Record<string, unknown>) => {
state.calls += 1;
return { content: `REAL for ${String(input.q)}` };
},
} as unknown as StructuredToolInterface;
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [collisionTool] }),
});
const configurable = buildConfig([]); // background not active for this run
const metadata = { thread_id: 'exec_convo_collision' };
const results = await runBatch(handler, {
toolCalls: [{ id: 'call_collision', name: CHECK_BACKGROUND_TASK_NAME, args: { q: 'y' } }],
agentId: 'a',
configurable,
metadata,
});
// the real tool ran; the host poll-tool shortcut did not swallow it
expect(state.calls).toBe(1);
expect(results[0].content).toContain('REAL for y');
});
it('strips run_in_background:false on a foreground call of a background-capable tool', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const searchTool = makeSearchTool(state);
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [searchTool] }),
});
const configurable = buildConfig(['search_mcp_docs']);
const metadata = { thread_id: 'exec_convo_falseflag' };
const results = await runBatch(handler, {
toolCalls: [
{ id: 'call_false', name: 'search_mcp_docs', args: { q: 'z', run_in_background: false } },
],
agentId: 'a',
configurable,
metadata,
});
// ran in the foreground, and the injected flag never reached the real tool
expect(state.calls).toBe(1);
expect(state.lastInput).toEqual({ q: 'z' });
expect(results[0].content).toContain('RESULT for z');
});
it('delivers a backgrounded tool artifact on poll (live turn), not on the finalized dispatch turn', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const artifactTool = {
name: 'search_mcp_docs',
description: 'returns an artifact',
schema: z.object({ q: z.string() }),
invoke: async (input: Record<string, unknown>) => {
state.calls += 1;
return { content: `RESULT for ${String(input.q)}`, artifact: { files: ['a.png'] } };
},
} as unknown as StructuredToolInterface;
const toolEndCalls: Array<{ name?: string; artifact?: unknown }> = [];
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [artifactTool] }),
toolEndCallback: (async (data: { output?: { name?: string; artifact?: unknown } }) => {
toolEndCalls.push({ name: data.output?.name, artifact: data.output?.artifact });
}) as unknown as Parameters<typeof createToolExecuteHandler>[0]['toolEndCallback'],
});
const configurable = buildConfig(['search_mcp_docs']);
const metadata = { thread_id: 'exec_convo_artifact', run_id: 'run-artifact' };
const dispatch = await runBatch(handler, {
toolCalls: [
{ id: 'call_art', name: 'search_mcp_docs', args: { q: 'img', run_in_background: true } },
],
agentId: 'a',
configurable,
metadata,
});
await flushMicrotasks();
await flushMicrotasks();
// the tool ran, but its artifact is NOT pushed through the finalized dispatch turn
expect(state.calls).toBe(1);
expect(toolEndCalls).toHaveLength(0);
const handleId = JSON.parse(dispatch[0].content).background_task_id;
const poll = await runBatch(handler, {
toolCalls: [
{
id: 'call_poll',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: handleId },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_artifact', run_id: 'run-poll' },
});
// the poll turn delivers the artifact once, attributed to the original tool
expect(JSON.parse(poll[0].content).status).toBe('completed');
expect(toolEndCalls).toHaveLength(1);
expect(toolEndCalls[0]).toEqual({ name: 'search_mcp_docs', artifact: { files: ['a.png'] } });
// polling again does not re-deliver (idempotent)
await runBatch(handler, {
toolCalls: [
{
id: 'call_poll2',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: handleId },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_artifact', run_id: 'run-poll2' },
});
expect(toolEndCalls).toHaveLength(1);
});
it('scopes tasks by configurable user_id when req is absent (external service hosts)', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [makeSearchTool(state)] }),
});
const configurable = {
user_id: 'exec_user_external',
backgroundToolNames: ['search_mcp_docs'],
};
const metadata = { thread_id: 'exec_convo_external', run_id: 'run-external' };
const dispatch = await runBatch(handler, {
toolCalls: [
{ id: 'call_ext', name: 'search_mcp_docs', args: { q: 'ping', run_in_background: true } },
],
agentId: 'a',
configurable,
metadata,
});
await flushMicrotasks();
await flushMicrotasks();
const handleId = JSON.parse(dispatch[0].content).background_task_id;
const poll = await runBatch(handler, {
toolCalls: [
{
id: 'call_ext_poll',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: handleId },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_external', run_id: 'run-external-poll' },
});
const polled = JSON.parse(poll[0].content);
expect(polled.status).toBe('completed');
expect(polled.result).toContain('RESULT for ping');
// a different user id cannot see the task (isolation is not conversation-only)
const foreign = await runBatch(handler, {
toolCalls: [
{
id: 'call_foreign_poll',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: handleId },
},
],
agentId: 'a',
configurable: { user_id: 'someone_else', backgroundToolNames: ['search_mcp_docs'] },
metadata: { thread_id: 'exec_convo_external', run_id: 'run-foreign' },
});
expect(JSON.parse(foreign[0].content).status).toBe('not_found');
});
it('errors immediately (like foreground) when a background-requested tool failed to load', async () => {
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [] }),
});
const results = (await runBatch(handler, {
toolCalls: [
{ id: 'call_missing', name: 'search_mcp_docs', args: { q: 'x', run_in_background: true } },
],
agentId: 'a',
configurable: buildConfig(['search_mcp_docs']),
metadata: { thread_id: 'exec_convo_missing', run_id: 'run-missing' },
})) as Array<{ content: string; status?: string; errorMessage?: string }>;
expect(results[0].status).toBe('error');
expect(results[0].errorMessage).toBe('Tool search_mcp_docs not found');
expect(results[0].content).not.toContain('background_task_id');
});
it('strips a run_in_background arg imitated onto a tool this agent never opted in', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [makeSearchTool(state)] }),
});
// background is enabled for the run via another tool; search_mcp_docs is NOT opted in
await runBatch(handler, {
toolCalls: [
{
id: 'call_foreign',
name: 'search_mcp_docs',
args: { q: 'hello', run_in_background: true },
},
],
agentId: 'a',
configurable: buildConfig(['other_tool']),
metadata: { thread_id: 'exec_convo_foreign', run_id: 'run-foreign' },
});
expect(state.calls).toBe(1);
expect(state.lastInput).toEqual({ q: 'hello' });
});
it('forwards run_in_background untouched to a tool whose own schema declares it', async () => {
const state = { calls: 0 } as { calls: number; lastInput?: Record<string, unknown> };
const owningTool = {
name: 'owns_the_param',
description: 'declares run_in_background itself',
schema: z.object({ q: z.string(), run_in_background: z.boolean().optional() }),
invoke: async (input: Record<string, unknown>) => {
state.calls += 1;
state.lastInput = input;
return { content: 'OWNED' };
},
} as unknown as StructuredToolInterface;
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [owningTool] }),
});
await runBatch(handler, {
toolCalls: [
{
id: 'call_owned',
name: 'owns_the_param',
args: { q: 'hello', run_in_background: true },
},
],
agentId: 'a',
configurable: buildConfig(['other_tool']),
metadata: { thread_id: 'exec_convo_owned', run_id: 'run-owned' },
});
expect(state.calls).toBe(1);
expect(state.lastInput).toEqual({ q: 'hello', run_in_background: true });
});
it('retries artifact delivery on the next poll when the callback fails (artifact not lost)', async () => {
const artifactTool = {
name: 'search_mcp_docs',
description: 'returns an artifact',
schema: z.object({ q: z.string() }),
invoke: async () => ({ content: 'RESULT', artifact: { files: ['a.png'] } }),
} as unknown as StructuredToolInterface;
const toolEndCalls: Array<{ name?: string; artifact?: unknown }> = [];
let failNextDelivery = true;
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [artifactTool] }),
toolEndCallback: (async (data: { output?: { name?: string; artifact?: unknown } }) => {
if (failNextDelivery) {
failNextDelivery = false;
throw new Error('transient storage failure');
}
toolEndCalls.push({ name: data.output?.name, artifact: data.output?.artifact });
}) as unknown as Parameters<typeof createToolExecuteHandler>[0]['toolEndCallback'],
});
const configurable = buildConfig(['search_mcp_docs']);
const dispatch = await runBatch(handler, {
toolCalls: [
{ id: 'call_art', name: 'search_mcp_docs', args: { q: 'img', run_in_background: true } },
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_art_retry', run_id: 'run-artifact' },
});
await flushMicrotasks();
await flushMicrotasks();
const handleId = JSON.parse(dispatch[0].content).background_task_id;
// first poll: delivery fails, but the poll itself still succeeds
const poll1 = await runBatch(handler, {
toolCalls: [
{
id: 'call_poll',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: handleId },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_art_retry', run_id: 'run-poll' },
});
expect(JSON.parse(poll1[0].content).status).toBe('completed');
expect(toolEndCalls).toHaveLength(0);
// second poll: the restored artifact is re-claimed and delivered
await runBatch(handler, {
toolCalls: [
{
id: 'call_poll2',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: handleId },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_art_retry', run_id: 'run-poll2' },
});
expect(toolEndCalls).toHaveLength(1);
expect(toolEndCalls[0]).toEqual({ name: 'search_mcp_docs', artifact: { files: ['a.png'] } });
});
});

View file

@ -15,14 +15,28 @@ import type { StructuredToolInterface } from '@librechat/agents/langchain/tools'
import type { CodeEnvRef } from 'librechat-data-provider';
import type { SkillFileRecord } from './skillFiles';
import type { ServerRequest } from '~/types';
import {
backgroundTaskRegistry,
runCheckBackgroundTask,
claimBackgroundArtifact,
restoreBackgroundArtifact,
isBackgroundRequested,
hasRunInBackgroundArg,
stripRunInBackgroundArg,
buildBackgroundHandleContent,
buildBackgroundCapacityContent,
stripBackgroundFromToolDefinitions,
CHECK_BACKGROUND_TASK_NAME,
RUN_IN_BACKGROUND_ARG,
} from './background';
import {
CREATE_FILE_TOOL_NAME,
EDIT_FILE_TOOL_NAME,
HOST_FILE_AUTHORING_ARTIFACT_KEY,
isCodeSessionToolName,
} from './tools';
import { logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils';
import { buildSkillPrimeMessage, SKILL_FILE_PREFIX } from './skills';
import { logAxiosError, runOutsideTracing } from '~/utils';
import { parseFrontmatter } from '../skills/import';
import { cleanCodeToolOutput } from './cleanup';
import { primeSkillFiles } from './skillFiles';
@ -275,22 +289,6 @@ type ToolInputSchemaKind = {
string: boolean;
};
function truncateMiddle(value: string, maxChars: number): string {
if (value.length <= maxChars) {
return value;
}
const indicator = `\n\n... [truncated: ${value.length} chars exceeded ${maxChars} limit] ...\n\n`;
const available = maxChars - indicator.length;
if (available <= 0) {
return value.slice(0, maxChars);
}
const headSize = Math.ceil(available * 0.7);
const tailSize = available - headSize;
return value.slice(0, headSize) + indicator + value.slice(value.length - tailSize);
}
function stringifyThrownValue(error: unknown): string {
try {
return String(error);
@ -1721,6 +1719,64 @@ function mergeActiveSkillNames(
return names.size > 0 ? names : undefined;
}
/**
* True for MCP tools on an ephemeral request-scoped connection (runtime body
* placeholders), tagged in `createToolInstance`. Their connection is torn down
* at request end, so they must run in the foreground rather than be backgrounded.
*/
function toolRequiresEphemeralConnection(tool: StructuredToolInterface | undefined): boolean {
return (
(tool as (StructuredToolInterface & { mcpRequiresEphemeralConnection?: boolean }) | undefined)
?.mcpRequiresEphemeralConnection === true
);
}
const EMPTY_BACKGROUND_TOOL_SET: ReadonlySet<string> = new Set();
/**
* Authenticated user id for background-task scoping. The in-repo routes merge
* `req` into the tool-execute configurable, but external hosts of the exported
* OpenAI-compatible service inject their own `loadTools` and may not — fall
* back to the run configurable's user identity so tasks are never registered
* under an empty user id (which would collapse isolation to conversationId).
*/
function resolveBackgroundUserId(configurable: Record<string, unknown> | undefined): string {
const req = configurable?.req as ServerRequest | undefined;
if (req?.user?.id) {
return req.user.id;
}
const userId = configurable?.user_id;
if (typeof userId === 'string' && userId !== '') {
return userId;
}
const user = configurable?.user;
if (typeof user === 'string') {
return user;
}
const idFromUser = (user as { id?: string } | undefined)?.id;
return typeof idFromUser === 'string' ? idFromUser : '';
}
/**
* True when the tool's own schema declares `run_in_background` (zod shape or
* raw JSON schema), i.e. the parameter belongs to the tool rather than being
* host-injected — such a tool must receive the argument untouched.
*/
function toolDeclaresRunInBackgroundParam(tool: StructuredToolInterface): boolean {
const schema = (
tool as StructuredToolInterface & {
schema?: { shape?: Record<string, unknown>; properties?: Record<string, unknown> };
}
).schema;
if (schema == null) {
return false;
}
return (
schema.shape?.[RUN_IN_BACKGROUND_ARG] != null ||
schema.properties?.[RUN_IN_BACKGROUND_ARG] != null
);
}
function mergeToolConfigurables(
base: Record<string, unknown> | undefined,
loaded: Record<string, unknown> | undefined,
@ -3226,8 +3282,181 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
const authoringQueues = new Map<string, Promise<void>>();
const sandboxAuthoringContexts = new Map<string, SandboxSessionContext>();
/**
* Background tool calls. The set of tools that received the injected
* `run_in_background` param is threaded per-agent from `initializeAgent`
* via `configurable.backgroundToolNames` (a reliable channel, unlike
* `toolRegistry` which only reaches the executor for PTC/tool_search).
* A non-empty set is the exact condition under which the run registered
* the poll tool and the model could have been shown the param, so it
* also gates the `check_background_task` interception and enforces the
* per-tool opt-in (a tool not in the set never had the param).
*/
const backgroundToolNames = mergedConfigurable?.backgroundToolNames as
| string[]
| undefined;
const backgroundEnabledForRun = (backgroundToolNames?.length ?? 0) > 0;
const backgroundToolSet: ReadonlySet<string> = backgroundEnabledForRun
? new Set(backgroundToolNames)
: EMPTY_BACKGROUND_TOOL_SET;
const backgroundReq = backgroundEnabledForRun
? (mergedConfigurable?.req as ServerRequest | undefined)
: undefined;
const backgroundUserId = backgroundEnabledForRun
? resolveBackgroundUserId(mergedConfigurable)
: '';
const backgroundConversationId = backgroundEnabledForRun
? (((metadata as Record<string, unknown>)?.thread_id as string | undefined) ??
(mergedConfigurable?.thread_id as string | undefined) ??
(backgroundReq?.body as { conversationId?: string } | undefined)?.conversationId ??
'')
: '';
/**
* Registers the task, returns a synthetic handle immediately, and
* runs the real tool as a floating promise whose result lands in the
* registry for `check_background_task` to collect. Idempotent by
* `toolCallId` so graph re-execution (resume/replay) never double-fires.
*/
const backgroundRunId = (metadata as Record<string, unknown>)?.run_id as
| string
| undefined;
const dispatchBackgroundToolCall = (tc: ToolCallRequest): ToolExecuteResult => {
/** A tool that failed to load must error immediately (matching the
* foreground path) — a synthetic "started" handle would tell the
* model a side effect is in flight that never executed. */
const tool = toolMap.get(tc.name);
if (!tool) {
return {
toolCallId: tc.id,
status: 'error' as const,
content: '',
errorMessage: `Tool ${tc.name} not found`,
};
}
const created = backgroundTaskRegistry.create({
userId: backgroundUserId,
conversationId: backgroundConversationId,
toolCallId: tc.id,
toolName: tc.name,
/** Scope idempotency to the agent + run + turn so a later turn's
* or a second agent's repeated provider id (e.g. `call_0`)
* starts a fresh task instead of colliding. */
agentId,
runId: `${backgroundRunId ?? ''}:${tc.turn ?? ''}`,
});
if ('atCapacity' in created) {
return {
toolCallId: tc.id,
status: 'success' as const,
content: buildBackgroundCapacityContent(tc.name),
};
}
const { task, isNew } = created;
if (isNew) {
const strippedArgs = stripRunInBackgroundArg(tc.args);
void (async () => {
try {
const result = (await tool.invoke(normalizeToolInvokeArgs(strippedArgs, tool), {
toolCall: { id: tc.id, stepId: tc.stepId, turn: tc.turn },
configurable: mergedConfigurable,
metadata,
} as Record<string, unknown>)) as { content?: unknown; artifact?: unknown };
/** Hold any artifact (images, files, UI resources,
* citations) on the task instead of routing it through
* this dispatch turn's callback: a slow background call
* resolves after the turn finalized, when its
* artifactPromises are already awaited and the stream is
* closed, so that push would be silently dropped. The poll
* turn delivers it live in `check_background_task`. */
backgroundTaskRegistry.complete(
backgroundUserId,
backgroundConversationId,
task.id,
{ content: result.content, artifact: result.artifact },
);
} catch (toolError) {
const { message } = getSafeToolError(toolError);
backgroundTaskRegistry.fail(
backgroundUserId,
backgroundConversationId,
task.id,
message,
);
}
})();
}
return {
toolCallId: tc.id,
status: 'success' as const,
content: buildBackgroundHandleContent(task),
};
};
const results: ToolExecuteResult[] = await Promise.all(
toolCalls.map(async (tc: ToolCallRequest) => {
if (backgroundEnabledForRun && tc.name === CHECK_BACKGROUND_TASK_NAME) {
const pollContent = runCheckBackgroundTask({
userId: backgroundUserId,
conversationId: backgroundConversationId,
args: tc.args,
});
/** Deliver a completed task's artifact through THIS live poll
* turn's callback (once): the tool's own turn finalized before
* the artifact resolved, so this is where it can be persisted. */
if (toolEndCallback) {
const pending = claimBackgroundArtifact({
userId: backgroundUserId,
conversationId: backgroundConversationId,
args: tc.args,
});
if (pending) {
try {
await toolEndCallback(
{
output: {
name: pending.toolName,
tool_call_id: tc.id,
content: pending.content,
artifact: pending.artifact,
},
},
(metadata ?? {}) as ToolEndCallbackMetadata,
);
} catch (callbackError) {
/** Only synchronous callback throws land here (e.g. a
* malformed artifact shape); the callback's downstream
* persistence is fire-and-forget, so a storage failure
* is at-most-once — the same semantics as a foreground
* artifact. */
restoreBackgroundArtifact({
userId: backgroundUserId,
conversationId: backgroundConversationId,
taskId: pending.taskId,
artifact: pending.artifact,
});
logger.warn(
'[background] toolEndCallback error delivering artifact on poll:',
callbackError,
);
}
}
}
return reportResult({
toolCallId: tc.id,
status: 'success' as const,
content: pollContent,
});
}
if (
backgroundToolSet.has(tc.name) &&
isBackgroundRequested(tc.args) &&
!toolRequiresEphemeralConnection(toolMap.get(tc.name))
) {
return reportResult(dispatchBackgroundToolCall(tc));
}
const execute = async (
sandboxContext?: SandboxSessionContext,
): Promise<ToolExecuteResult> => {
@ -3446,23 +3675,48 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
if (toolRegistry) {
const fileAuthoringToolNames =
getFileAuthoringToolNames(mergedConfigurable) ?? new Set<string>();
const toolDefs: LCTool[] = Array.from(toolRegistry.values()).filter(
const filteredToolDefs: LCTool[] = Array.from(toolRegistry.values()).filter(
(t) =>
t.name !== Constants.PROGRAMMATIC_TOOL_CALLING &&
t.name !== Constants.BASH_PROGRAMMATIC_TOOL_CALLING &&
t.name !== Constants.TOOL_SEARCH &&
/* Host-only poll tool: implemented by the ON_TOOL_EXECUTE
* shortcut, not callable from PTC-generated code. */
t.name !== CHECK_BACKGROUND_TASK_NAME &&
!fileAuthoringToolNames.has(t.name),
);
/* PTC-generated calls don't go through the host background
* interceptor, so strip the injected `run_in_background`
* param from target schemas (the registry entries were
* mutated to include it) — mirrors the self-spawn path. */
const toolDefs = stripBackgroundFromToolDefinitions(
filteredToolDefs,
mergedConfigurable?.backgroundToolNames as string[] | undefined,
);
toolCallConfig.toolDefs = toolDefs;
toolCallConfig.toolMap = ptcToolMap ?? toolMap;
}
}
const result = await tool.invoke(normalizeToolInvokeArgs(tc.args, tool), {
toolCall: toolCallConfig,
configurable: mergedConfigurable,
metadata,
} as Record<string, unknown>);
/** Strip the host-only `run_in_background` flag on foreground
* calls (the model may emit it as `false`, or imitate it from
* another agent's history on a tool this agent never opted
* in), so a strict MCP/action schema doesn't reject an
* undeclared argument. Only a tool whose own schema declares
* the parameter receives it. */
const foregroundArgs =
backgroundToolSet.has(tc.name) ||
(hasRunInBackgroundArg(tc.args) && !toolDeclaresRunInBackgroundParam(tool))
? stripRunInBackgroundArg(tc.args)
: tc.args;
const result = await tool.invoke(
normalizeToolInvokeArgs(foregroundArgs, tool),
{
toolCall: toolCallConfig,
configurable: mergedConfigurable,
metadata,
} as Record<string, unknown>,
);
// Code-execution tools emit per-call boilerplate
// ("Note: ..." paragraphs and `| <annotation>` per-file

View file

@ -50,7 +50,9 @@ import {
registerFileAuthoringTools,
isFileAuthoringToolDefinition,
} from './tools';
import { normalizeServerName, requiresEphemeralUserConnection } from '~/mcp/utils';
import { registerMemoryTools, memoryToolUsageGuard } from './memory';
import { applyBackgroundToolCalls } from './background';
import { filterFilesByEndpointConfig } from '~/files';
import { generateArtifactsPrompt } from '~/prompts';
import { getProviderConfig } from '~/endpoints';
@ -256,6 +258,13 @@ export type InitializedAgent = Agent & {
toolDefinitions?: LCTool[];
/** Precomputed flag indicating if any tools have defer_loading enabled (for efficient runtime checks) */
hasDeferredTools?: boolean;
/**
* Names of this agent's tools that were injected with the `run_in_background`
* param (capability enabled AND per-tool opt-in AND eligible). Threaded to the
* tool executor via `configurable` so it can enforce the per-tool background
* opt-in and gate the `check_background_task` poll tool at execution time.
*/
backgroundToolNames?: string[];
/** Whether the inline memory tools (`set_memory`/`delete_memory`) were
* registered for this agent. Authoritative LibreChat-only signal of the
* inline memory opt-in for the execution path, since some contexts hold the
@ -405,6 +414,13 @@ export interface InitializeAgentParams {
skillAuthoringAvailable?: boolean;
/** Whether the code execution environment is available (execute_code capability enabled) */
codeEnvAvailable?: boolean;
/**
* Whether the `run_in_background` capability is enabled for this run. When
* true, tools the agent opted in via `tool_options[name].run_in_background`
* get a `run_in_background` schema param and the `check_background_task` poll
* tool is registered.
*/
backgroundToolsAvailable?: boolean;
/** Whether stateful code sessions are available (stateful_code_sessions capability enabled) */
statefulSessionsAvailable?: boolean;
/** Whether inline memory tools are available (memory capability enabled, memory
@ -1154,6 +1170,48 @@ export async function initializeAgent(
toolDefinitions = fileAuthoringResult.toolDefinitions;
}
/**
* Inject the `run_in_background` param into eligible opted-in tools and
* register the `check_background_task` poll tool. Runs after all built-in
* tool registration so the full, final `toolDefinitions` set is considered.
* Opt-in is per-tool via `tool_options` for both saved and ephemeral agents.
*/
let backgroundToolNames: string[] | undefined;
if (params.backgroundToolsAvailable === true) {
/** Tool names embed `normalizeServerName(server)` (see MCP.js tool keys)
* while `mcpConfig` keys the original name, so index the ephemeral
* servers by their normalized form. */
const ephemeralServerNames = new Set<string>();
for (const [serverName, serverConfig] of Object.entries(req.config?.mcpConfig ?? {})) {
if (serverConfig != null && requiresEphemeralUserConnection(serverConfig)) {
ephemeralServerNames.add(normalizeServerName(serverName));
}
}
const backgroundResult = applyBackgroundToolCalls({
toolDefinitions,
toolRegistry,
toolOptions: agent.tool_options,
/** Tools of ephemeral request-scoped MCP servers (runtime body
* placeholders) never get the param: their connection dies at request
* end, so the executor would only downgrade the call to foreground.
* Unknown servers stay eligible — the executor's per-instance tag is
* the fail-safe for those. */
excludeTool: (toolName) => {
const delimiterIndex = toolName.indexOf(Constants.mcp_delimiter);
if (delimiterIndex < 0) {
return false;
}
return ephemeralServerNames.has(
toolName.slice(delimiterIndex + Constants.mcp_delimiter.length),
);
},
});
toolDefinitions = backgroundResult.toolDefinitions;
if (backgroundResult.backgroundToolNames.length > 0) {
backgroundToolNames = backgroundResult.backgroundToolNames;
}
}
/** Check for tool presence from either full instances or definitions (event-driven mode) */
const hasAgentTools = (structuredTools?.length ?? 0) > 0 || (toolDefinitions?.length ?? 0) > 0;
const providerTools = resolveProviderToolConflicts({
@ -1309,6 +1367,7 @@ export async function initializeAgent(
userMCPAuthMap,
toolDefinitions,
hasDeferredTools,
backgroundToolNames,
actionsEnabled,
baseContextTokens,
memoryToolsRegistered: inlineMemoryRegistered,

View file

@ -8,12 +8,14 @@ import {
} from 'librechat-data-provider';
import type {
AgentModelParameters,
AgentToolOptions,
TEphemeralAgent,
TModelSpec,
Agent,
} from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
import { ASK_USER_QUESTION_TOOL_NAME } from '~/agents/hitl/askUserQuestionTool';
import { synthesizeBackgroundToolOptions } from '~/agents/background';
import { requiresEphemeralUserConnection } from '~/mcp/utils';
import { getCustomEndpointConfig } from '~/app/config';
@ -149,6 +151,14 @@ export async function loadEphemeralAgent(
tools,
};
const backgroundToolOptions: AgentToolOptions | undefined = synthesizeBackgroundToolOptions(
tools,
{ ephemeralAgent, modelSpec },
);
if (backgroundToolOptions) {
result.tool_options = backgroundToolOptions;
}
if (ephemeralAgent?.artifacts) {
result.artifacts = ephemeralAgent.artifacts;
}

View file

@ -153,6 +153,13 @@ interface InitializeAgentParams {
* in-repo controllers; absent / `undefined` disables the feature.
*/
statefulSessionsAvailable?: boolean;
/**
* Whether the admin-level `run_in_background` capability is enabled.
* Gates `applyBackgroundToolCalls` in `initializeAgent` (the injected
* `run_in_background` param + the `check_background_task` poll tool);
* absent / `undefined` disables background tool calls on this route.
*/
backgroundToolsAvailable?: boolean;
}
/**
@ -445,23 +452,21 @@ export async function createAgentChatCompletion(
* use.
*/
const agentsConfig = (deps.appConfig?.endpoints as Record<string, unknown> | undefined)?.agents;
const codeEnvAvailable =
const capabilityEnabled = (capability: AgentCapabilities): boolean | undefined =>
agentsConfig != null && typeof agentsConfig === 'object'
? ((agentsConfig as { capabilities?: string[] }).capabilities ?? []).includes(
AgentCapabilities.execute_code,
)
? ((agentsConfig as { capabilities?: string[] }).capabilities ?? []).includes(capability)
: undefined;
const codeEnvAvailable = capabilityEnabled(AgentCapabilities.execute_code);
/** Mirror `codeEnvAvailable` for the stateful-session gate so an agent with
* `execute_code`, the app `stateful_code_sessions` capability, and its own
* builder opt-in resolves stateful sessions on this route too — otherwise
* `statefulCodeSessions` stays false and `createRun` never sends
* `toolExecution.sandbox`. */
const statefulSessionsAvailable =
agentsConfig != null && typeof agentsConfig === 'object'
? ((agentsConfig as { capabilities?: string[] }).capabilities ?? []).includes(
AgentCapabilities.stateful_code_sessions,
)
: undefined;
const statefulSessionsAvailable = capabilityEnabled(AgentCapabilities.stateful_code_sessions);
/** Same gate as the in-repo controllers: without it, agents that opted
* tools in via tool_options.run_in_background silently lose the
* background param + poll tool on this route. */
const backgroundToolsAvailable = capabilityEnabled(AgentCapabilities.run_in_background);
// Initialize the agent first to check for disableStreaming
const initializedAgent = await deps.initializeAgent({
@ -479,6 +484,7 @@ export async function createAgentChatCompletion(
isInitialAgent: true,
codeEnvAvailable,
statefulSessionsAvailable,
backgroundToolsAvailable,
});
// Determine if streaming is enabled (check both request and agent config)

View file

@ -35,6 +35,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 {
CHECK_BACKGROUND_TASK_NAME,
stripBackgroundFromToolRegistry,
stripBackgroundFromToolDefinitions,
} from '~/agents/background';
import {
ASK_USER_QUESTION_TOOL_NAME,
createAskUserQuestionTool,
@ -331,6 +336,8 @@ type RunAgent = Omit<Agent, 'tools'> & {
toolDefinitions?: LCTool[];
/** Precomputed flag indicating if any tools have defer_loading enabled */
hasDeferredTools?: boolean;
/** Names of tools injected with the `run_in_background` param (excluded from eager execution). */
backgroundToolNames?: string[];
/**
* Per-agent codeenv gate set by `initializeAgent`: admin-level
* `execute_code` capability AND the agent actually requested
@ -871,6 +878,14 @@ function buildSubagentConfigs(
if (allowSelf) {
const selfName = agentInput.name ?? agent.name ?? 'self';
countSubagentConfig(state);
/**
* Self-spawn reuses the parent's AgentInputs. When the parent has background
* tools, provide a sanitized copy so the isolated child — which runs the
* direct/child-graph path rather than the host background interceptor —
* doesn't advertise `run_in_background` / `check_background_task`. The
* resolver keeps a provided `agentInputs` even with `self: true`.
*/
const hasBackground = (agent.backgroundToolNames?.length ?? 0) > 0;
configs.push({
self: true,
type: SELF_SUBAGENT_TYPE,
@ -878,6 +893,21 @@ function buildSubagentConfigs(
description: `Spawn ${selfName} in an isolated context to handle a focused subtask. Verbose tool output stays in the child's context; only a summary returns.`,
/** Self-spawn reuses the parent's config, so mirror the parent's recursion limit. */
maxTurns: resolveSubagentMaxTurns(agentsEConfig, agent),
...(hasBackground
? {
agentInputs: {
...agentInput,
toolDefinitions: stripBackgroundFromToolDefinitions(
agentInput.toolDefinitions,
agent.backgroundToolNames,
),
toolRegistry: stripBackgroundFromToolRegistry(
agentInput.toolRegistry,
agent.backgroundToolNames,
),
},
}
: {}),
});
}
@ -913,6 +943,24 @@ function buildSubagentConfigs(
* source so children truly start fresh.
*/
const childInputs = toInput(child, { isSubagent: true });
/**
* A child reachable as a top-level/handoff agent is initialized WITH the
* background capability, then reused here as a subagent. Isolated child
* graphs run subagent tools without the host background dispatch/poll
* behavior, so strip the injected `run_in_background` param + the
* `check_background_task` def (defs AND registry) so the child doesn't
* advertise a background contract it can't honor. Mirrors the self-spawn path.
*/
if ((child.backgroundToolNames?.length ?? 0) > 0) {
childInputs.toolDefinitions = stripBackgroundFromToolDefinitions(
childInputs.toolDefinitions,
child.backgroundToolNames,
);
childInputs.toolRegistry = stripBackgroundFromToolRegistry(
childInputs.toolRegistry,
child.backgroundToolNames,
);
}
/**
* Recursively resolve the child's own spawn targets so multi-level
* delegation (A → B → C) works. Without this, a child whose own
@ -1409,6 +1457,16 @@ export async function createRun({
Constants.EXECUTE_CODE,
Constants.BASH_TOOL,
ASK_USER_QUESTION_TOOL_NAME,
/**
* Background-capable tools: eager execution could launch the detached
* task with speculative/partial args before the final tool call, and a
* background side effect (unlike a foreground eager mismatch) can't be
* canceled once dispatched. The poll tool is excluded for the same
* reason: collecting a task's artifact is a one-shot claim that must
* not fire from a speculative snapshot the SDK may later discard.
*/
CHECK_BACKGROUND_TASK_NAME,
...agents.flatMap((agent) => agent.backgroundToolNames ?? []),
],
},
// Let host file-authoring tools share the code-execution sandbox session so

View file

@ -289,16 +289,18 @@ export const graphEdgeSchema: z.ZodObject<
.transform((v) => (v === '' ? undefined : v)),
});
/** Per-tool options schema (defer_loading, allowed_callers) */
/** Per-tool options schema (defer_loading, allowed_callers, run_in_background) */
export const toolOptionsSchema: z.ZodObject<
{
defer_loading: z.ZodOptional<z.ZodBoolean>;
allowed_callers: z.ZodOptional<z.ZodArray<z.ZodEnum<['direct', 'code_execution']>, 'many'>>;
run_in_background: z.ZodOptional<z.ZodBoolean>;
},
'strip'
> = z.object({
defer_loading: z.boolean().optional(),
allowed_callers: z.array(z.enum(['direct', 'code_execution'])).optional(),
run_in_background: z.boolean().optional(),
});
/** Agent tool options - map of tool_id to tool options */
@ -309,16 +311,19 @@ export const agentToolOptionsSchema: z.ZodOptional<
{
defer_loading: z.ZodOptional<z.ZodBoolean>;
allowed_callers: z.ZodOptional<z.ZodArray<z.ZodEnum<['direct', 'code_execution']>, 'many'>>;
run_in_background: z.ZodOptional<z.ZodBoolean>;
},
'strip',
z.ZodTypeAny,
{
defer_loading?: boolean | undefined;
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
run_in_background?: boolean | undefined;
},
{
defer_loading?: boolean | undefined;
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
run_in_background?: boolean | undefined;
}
>
>
@ -625,16 +630,19 @@ export const agentBaseSchema: z.ZodObject<
allowed_callers: z.ZodOptional<
z.ZodArray<z.ZodEnum<['direct', 'code_execution']>, 'many'>
>;
run_in_background: z.ZodOptional<z.ZodBoolean>;
},
'strip',
z.ZodTypeAny,
{
defer_loading?: boolean | undefined;
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
run_in_background?: boolean | undefined;
},
{
defer_loading?: boolean | undefined;
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
run_in_background?: boolean | undefined;
}
>
>
@ -972,16 +980,19 @@ export const agentCreateSchema: z.ZodObject<
allowed_callers: z.ZodOptional<
z.ZodArray<z.ZodEnum<['direct', 'code_execution']>, 'many'>
>;
run_in_background: z.ZodOptional<z.ZodBoolean>;
},
'strip',
z.ZodTypeAny,
{
defer_loading?: boolean | undefined;
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
run_in_background?: boolean | undefined;
},
{
defer_loading?: boolean | undefined;
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
run_in_background?: boolean | undefined;
}
>
>
@ -1284,16 +1295,19 @@ export const agentUpdateSchema: z.ZodObject<
allowed_callers: z.ZodOptional<
z.ZodArray<z.ZodEnum<['direct', 'code_execution']>, 'many'>
>;
run_in_background: z.ZodOptional<z.ZodBoolean>;
},
'strip',
z.ZodTypeAny,
{
defer_loading?: boolean | undefined;
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
run_in_background?: boolean | undefined;
},
{
defer_loading?: boolean | undefined;
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
run_in_background?: boolean | undefined;
}
>
>

View file

@ -98,3 +98,23 @@ export async function processTextWithTokenLimit({
wasTruncated: true,
};
}
/**
* Truncates to `maxChars` keeping head and tail around an explicit marker, so
* the consumer (usually a model) can tell the content was cut and by how much.
*/
export function truncateMiddle(value: string, maxChars: number): string {
if (value.length <= maxChars) {
return value;
}
const indicator = `\n\n... [truncated: ${value.length} chars exceeded ${maxChars} limit] ...\n\n`;
const available = maxChars - indicator.length;
if (available <= 0) {
return value.slice(0, maxChars);
}
const headSize = Math.ceil(available * 0.7);
const tailSize = available - headSize;
return value.slice(0, headSize) + indicator + value.slice(value.length - tailSize);
}

View file

@ -578,6 +578,7 @@ export enum AgentCapabilities {
tools = 'tools',
chain = 'chain',
ocr = 'ocr',
run_in_background = 'run_in_background',
}
export const defaultAssistantsVersion = {
@ -2728,6 +2729,8 @@ export enum Constants {
BASH_PROGRAMMATIC_TOOL_CALLING = 'run_tools_with_bash',
/** Subagent spawn tool name (must match `@librechat/agents` `Constants.SUBAGENT`). */
SUBAGENT = 'subagent',
/** Poll tool for retrieving the status/result of a backgrounded tool call. */
CHECK_BACKGROUND_TASK = 'check_background_task',
}
/** Maximum explicit subagent hops allowed from any root agent at runtime. */

View file

@ -55,6 +55,12 @@ export type TModelSpec = {
memory?: boolean;
/** Equip the spec's ephemeral agent with the `ask_user_question` HITL tool. */
askUserQuestion?: boolean;
/**
* Let the model dispatch this spec's eligible tool calls in the background
* (poll results via `check_background_task`). Requires the `run_in_background`
* agent capability to be enabled by the admin.
*/
runInBackground?: boolean;
artifacts?: string | boolean;
mcpServers?: string[];
skills?: boolean | string[];
@ -90,6 +96,7 @@ export const tModelSpecSchema = z.object({
executeCode: z.boolean().optional(),
memory: z.boolean().optional(),
askUserQuestion: z.boolean().optional(),
runInBackground: z.boolean().optional(),
artifacts: z.union([z.string(), z.boolean()]).optional(),
mcpServers: z.array(z.string()).optional(),
skills: z.union([z.boolean(), z.array(z.string())]).optional(),

View file

@ -111,6 +111,12 @@ export type TEphemeralAgent = {
memory?: boolean;
/** Equip the ephemeral agent with the `ask_user_question` HITL tool. */
ask_user_question?: boolean;
/**
* Let the model dispatch this ephemeral agent's eligible tool calls in the
* background (poll results via `check_background_task`). Requires the
* `run_in_background` agent capability to be enabled by the admin.
*/
run_in_background?: boolean;
};
export type TPayload = Partial<TMessage> &

View file

@ -238,6 +238,13 @@ export type ToolOptions = {
* @default ['direct']
*/
allowed_callers?: AllowedCaller[];
/**
* If true (and the `run_in_background` capability is enabled), the tool's
* schema gains a `run_in_background` boolean so the model can dispatch the
* call detached and poll its result via `check_background_task`.
* @default false
*/
run_in_background?: boolean;
};
/**