mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
🧵 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
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:
parent
b3f9cddbef
commit
520af663bc
38 changed files with 3334 additions and 216 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
client/src/components/SidePanel/Agents/OptionToggle.tsx
Normal file
51
client/src/components/SidePanel/Agents/OptionToggle.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
114
e2e/specs/mock/background-tools.spec.ts
Normal file
114
e2e/specs/mock/background-tools.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
766
packages/api/src/agents/background.spec.ts
Normal file
766
packages/api/src/agents/background.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
860
packages/api/src/agents/background.ts
Normal file
860
packages/api/src/agents/background.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
526
packages/api/src/agents/handlers.background.spec.ts
Normal file
526
packages/api/src/agents/handlers.background.spec.ts
Normal 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'] } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
>
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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> &
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue