Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8b1f411
🧵 feat: Background Tool Calls for Agents & Model Specs
danny-avila Jul 9, 2026
7a45465
🩹 fix: Harden background tool calls (Codex review)
danny-avila Jul 9, 2026
445bb71
🩹 fix: Address Codex round 2 on background tool calls
danny-avila Jul 9, 2026
59559c0
🩹 fix: Address Codex round 3 on background tool calls
danny-avila Jul 9, 2026
5e1fb35
🩹 fix: Address Codex round 4 on background tool calls
danny-avila Jul 9, 2026
dac5208
🩹 fix: Address Codex round 5 on background tool calls
danny-avila Jul 9, 2026
7c3b85f
🩹 fix: Strip run_in_background from PTC target tool schemas (Codex ro…
danny-avila Jul 9, 2026
1177e6b
🩹 fix: Sanitize background from explicit subagent inputs (Codex round 7)
danny-avila Jul 9, 2026
f0a4dc9
🩹 fix: Reap stuck/expired background tasks (Codex round 8)
danny-avila Jul 9, 2026
66ecdf1
🩹 fix: Evict oldest settled tasks instead of blocking at the cap (Cod…
danny-avila Jul 9, 2026
6f36bec
📝 docs: Frame background tool calls as within-turn (Codex P1 contract)
danny-avila Jul 10, 2026
99a51dc
♻️ refactor: Cross-turn background tool calls, leak-free
danny-avila Jul 11, 2026
d9d2865
🐛 fix: Guard ephemeral MCP tag against a null server config
danny-avila Jul 11, 2026
820088a
🎨 fix: Deliver backgrounded tool artifacts on the poll turn
danny-avila Jul 11, 2026
1ffa220
✨ feat: Agent-builder toggle for background tool calls + cap tool des…
danny-avila Jul 12, 2026
1a19088
🔀 merge: dev into claude/bg-tool-calls (resolve capability-flag confl…
danny-avila Jul 12, 2026
d67077d
🧪 fix: Mock new background hook functions in McpSection spec
danny-avila Jul 12, 2026
63e9a8f
🎨 fix: Restore background artifact when poll-turn delivery fails
danny-avila Jul 12, 2026
3342d13
🛡️ fix: Harden background tool call edges from review findings
danny-avila Jul 13, 2026
c4606d5
♻️ refactor: Deduplicate background tool-call plumbing and tighten types
danny-avila Jul 13, 2026
cd6d15a
🧪 test: e2e coverage for cross-turn background tool calls
danny-avila Jul 13, 2026
f25e25a
🔧 fix: Close two background capability gaps from review
danny-avila Jul 13, 2026
d8d52c5
Merge remote-tracking branch 'origin/dev' into claude/bg-tool-calls
danny-avila Jul 13, 2026
8f8e156
🛂 fix: Fall back to configurable user identity for background task sc…
danny-avila Jul 13, 2026
daaa74f
🧹 chore: Apply repo import sorter to PR-touched files
danny-avila Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions api/server/services/Endpoints/agents/addedConvo.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const processAddedConvo = async ({
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
}) => {
Expand Down Expand Up @@ -177,6 +178,7 @@ const processAddedConvo = async ({
ephemeralSkillsToggle,
}),
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
Expand Down
10 changes: 10 additions & 0 deletions api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -408,6 +410,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
accessibleSkillIds: primaryScopedSkillIds,
skillAuthoringAvailable: primarySkillAuthoringAvailable,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
Expand Down Expand Up @@ -485,6 +488,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
},
Expand Down Expand Up @@ -558,6 +562,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
statefulSessionsAvailable,
memoryAvailable,
});
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions api/server/services/Endpoints/agents/skillDeps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions api/server/services/MCP.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,7 @@ async function loadToolsForExecution({
agent,
toolNames,
toolRegistry,
backgroundToolNames,
mcpAvailableTools,
requestScopedConnections,
userMCPAuthMap,
Expand All @@ -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 = [
Expand Down
70 changes: 32 additions & 38 deletions client/src/components/SidePanel/Agents/MCPToolItem.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 =
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions client/src/components/SidePanel/Agents/OptionToggle.tsx
Original file line number Diff line number Diff line change
@@ -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>
}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
}));

Expand Down
Loading
Loading