mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🎯 feat: Tool Intent Label Capability (tool_intents) (#14499)
* 🎯 feat: Tool Intent Label Capability (tool_intents) Adds the fourth member of the per-tool capability family (defer_loading, allowed_callers, run_in_background): an admin capability AgentCapabilities.tool_intents plus a per-tool tool_options[name].describe_intent flag. Opted-in tools get an optional intent string injected as the FIRST property of their schema — one model-authored sentence per call, streamed to the client as the call's live status label (args already reach the client verbatim, so no new event plumbing). Native host tools (web_search, create_file/edit_file, set_memory/delete_memory, ask_user_question) default on while the capability is enabled; explicit false opts out. SDK-native intent schemas (@librechat/agents coding suite) are recognized and left alone. - packages/api/src/agents/intent.ts: structural sibling of background.ts — first-key non-mutating injection with registry parity (covers deferred/tool_search discovery), eligibility and PTC-only skips, arg read/strip helpers, self-spawn strip for defs and registry, ephemeral/model-spec synthesis with a tool_options merge so the background and intent toggles compose. - handlers.ts: intent runs BEFORE background injection so the label stays the first streamed key when a tool carries both (pinned by test); the arg is stripped before invocation unless the tool's own schema declares it, on both the foreground and background-dispatch paths; PTC target schemas are sanitized like background's. - Capability plumbing through all four routes (endpoint initialize, openai + responses controllers, the exported OpenAI-compatible service) plus handoff discovery and added-convo agents, and the intentToolNames execution channel via configurable. - describe_intent on toolOptionsSchema (all three written-out Zod annotations), ToolOptions, TEphemeralAgent, TModelSpec (+ zod), and data-schemas doc comments (tool_options is Mixed — no migration). - intent.spec.ts: 28 tests cloned from background.spec.ts structure, including the intent+background key-order composition. * 🧯 fix: Codex Review — Opt-Out Strips SDK-Native Intent, Skip mcp_all Placeholders - An explicit describe_intent: false now REMOVES an SDK-native intent property from the definition and registry entry, so the per-tool opt-out actually disables the arg's token cost for tools like web_search that carry the schema natively (SDK bodies tolerate its absence). Previously the early return left the property in place. - synthesizeIntentToolOptions skips lazily-expanded mcp_all placeholders instead of recording options under names that applyIntentLabels' exact-name matching can never match, and documents the limitation (parity with synthesizeBackgroundToolOptions). The P1 about the client not rendering the label is the documented slicing: the UI streaming-label PR follows once #14391's ToolCallGroup changes merge — args already reach the client, so that slice is purely rendering. * 🧯 fix: Codex Re-Review — Label Marker Guard, Capability Kill Switch, Late Defs, Service Threading - removeIntentParam is now marker-guarded (the label contract's opening instruction discriminates it), so an MCP/action tool's own business `intent` parameter is never stripped by an opt-out or the disabled path — previously an explicit false could remove a real, possibly required argument. - New sanitizeIntentLabels pass runs AFTER every registration step (the skill catalog appends its SDK definition post-injection): with tool_intents disabled it strips SDK-native intent labels from all definitions and registry entries, making the capability a real kill switch over their token cost; with it enabled it enforces explicit per-tool opt-outs on late-registered definitions. - ask_user_question removed from the native default-on set: its graph tool is rebuilt in run.ts from its own Zod schema (also the HITL card's wire shape), so definition-level injection never reached the model. Its intent support lands with the HITL slice, which threads the label into the interrupt payload deliberately. - The exported OpenAI-compatible service now threads intentToolNames into the run configurable, so the executor's PTC path can strip host-injected intent schemas on that route like the in-repo controllers do. * 🧯 fix: Codex Round 2 — Post-Skill Injection, PTC Native Strip, Service Boundary, Honest Docs - Intent injection now runs LAST in initializeAgent, after the skill catalog — which both appends its own definition and REPLACES upgraded ones (skill-aware read_file), clobbering an earlier injection while intentToolNames still listed the tool. Injection PREPENDS while background APPENDS, so intent stays the first schema property under the new ordering (pinned by a reverse-order composition test). - The PTC target-schema strip is now marker-guarded strip-ALL: SDK- native intent labels (which are deliberately never in intentToolNames) are removed from sandbox-advertised schemas alongside host-injected ones; business intent params survive. - toolIntentsAvailable on the exported service documents the loader boundary: a custom LoadToolsFn returning only structured instances bypasses definition/registry injection and sanitize by construction. - librechat.example.yaml describes tool_intents as backend groundwork with UI rendering in an upcoming release rather than promising a live label today. * 📦 chore: bump `@librechat/agents` to v3.3.6 Brings in the SDK half of tool intent labels (danny-avila/agents#347, #349): intent-first schemas on the coding suite across all three engines, plus web_search / subagent / skill / tool_search, and the outcome / outcome_patch result channel. Activates three host paths that were inert while no SDK tool shipped an `intent` property — verified against the real 3.3.6 schemas: - capability OFF now strips SDK-native labels (a real admin kill switch) - explicit `describe_intent: false` removes them per tool - host injection stays idempotent against an SDK schema, keeping `intent` first and never double-injecting * 🔬 test: Real-Provider Verification for Tool Intent Labels Adds the live check the unit tests structurally cannot perform: whether a real model actually authors the injected arg, places it FIRST, and gives sibling calls to one tool distinct labels. Reuses the existing real-provider harness (in-memory Mongo, seeded user, credential neutralizer) and the existing stdio MCP fixture as a genuine tool, so no external service is involved. - e2e/config/librechat.real.yaml: adds the e2e-memory MCP server and the tool_intents capability, giving the real model something to call. The sibling spec asserts only relative token growth, so the extra schemas do not perturb it. - e2e/playwright.config.real.ts: optional Langfuse passthrough. The LANGFUSE_* keys match the credential-neutralizer pattern and were being blanked before the server booted; they are preserved explicitly, read from the invoking environment only, and never written to the generated config. - e2e/specs/real/tool-intents.spec.ts: two facts stored in one turn, both through the same tool, asserting intent is the first key of each call and that the two labels differ. Args are read from persistence rather than the DOM deliberately — no UI renders the label yet, and persistence is what a reloaded conversation and the trace both read. First run against claude-haiku-4-5 produced 'Recording the location of the OAuth callback router' and 'Recording the location of the MCP connection pool configuration' — distinct, first-position, no tool name. Also updates tool-intent-spec.md: records the 3.3.7 removal of the tense verb map with the evidence that motivated it, the trimmed description and the marker's role as an API, and a new mandatory requirement that client-side label rendering be gated on a server-sent signal rather than the presence of an intent key (a tool's own business 'intent' parameter would otherwise render as a status label). * 📦 chore: bump `@librechat/agents` to v3.3.7 and dedupe the intent contract Picks up danny-avila/agents#353: the tense verb map is gone (a bare intent now displays unchanged, with completion carried by UI state), the model-facing description is trimmed 502 → 289 chars, and both the marker and the description are exported. Stops redeclaring the SDK contract here: - INTENT_LABEL_MARKER is imported instead of duplicated as a string literal. Every removal path in this module keys on it, and a local copy that drifted from the SDK's would make them all stop recognizing SDK-native labels — failing OPEN, with labels left in schemas and per-tool opt-outs silently inert. - INTENT_DESCRIPTION is imported too, so host-injected tools and SDK-native tools present the model with one identical instruction. Keeping the old local copy would also have meant host-injected tools still paying ~126 tokens per schema while SDK tools paid ~72. Verified live against real Anthropic after the trim: two sibling calls to one MCP tool produced 'Storing the OAuth callback router file location' and 'Storing the MCP connection pool configuration file location' — first-position and distinct, so the shorter description holds compliance.
This commit is contained in:
parent
d70cab48fd
commit
cc813f430e
30 changed files with 2210 additions and 27 deletions
|
|
@ -46,7 +46,7 @@
|
|||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^2.8.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.3.5",
|
||||
"@librechat/agents": "^3.3.7",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
|
|
|||
|
|
@ -337,6 +337,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
}),
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
|
||||
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
|
||||
statefulSessionsAvailable: enabledCapabilities.has(
|
||||
AgentCapabilities.stateful_code_sessions,
|
||||
),
|
||||
|
|
@ -418,6 +419,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
|
||||
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
|
||||
statefulSessionsAvailable: enabledCapabilities.has(
|
||||
AgentCapabilities.stateful_code_sessions,
|
||||
),
|
||||
|
|
@ -504,6 +506,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
signal: abortController.signal,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
backgroundToolNames: ctx.backgroundToolNames,
|
||||
intentToolNames: ctx.intentToolNames,
|
||||
mcpAvailableTools: ctx.mcpAvailableTools,
|
||||
requestScopedConnections: ctx.requestScopedConnections,
|
||||
userMCPAuthMap: ctx.userMCPAuthMap,
|
||||
|
|
|
|||
|
|
@ -461,6 +461,7 @@ const createResponse = async (req, res) => {
|
|||
}),
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
|
||||
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
|
||||
statefulSessionsAvailable: enabledCapabilities.has(
|
||||
AgentCapabilities.stateful_code_sessions,
|
||||
),
|
||||
|
|
@ -542,6 +543,7 @@ const createResponse = async (req, res) => {
|
|||
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
|
||||
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
|
||||
statefulSessionsAvailable: enabledCapabilities.has(
|
||||
AgentCapabilities.stateful_code_sessions,
|
||||
),
|
||||
|
|
@ -724,6 +726,7 @@ const createResponse = async (req, res) => {
|
|||
signal: abortController.signal,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
backgroundToolNames: ctx.backgroundToolNames,
|
||||
intentToolNames: ctx.intentToolNames,
|
||||
mcpAvailableTools: ctx.mcpAvailableTools,
|
||||
requestScopedConnections: ctx.requestScopedConnections,
|
||||
userMCPAuthMap: ctx.userMCPAuthMap,
|
||||
|
|
@ -905,6 +908,7 @@ const createResponse = async (req, res) => {
|
|||
signal: abortController.signal,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
backgroundToolNames: ctx.backgroundToolNames,
|
||||
intentToolNames: ctx.intentToolNames,
|
||||
mcpAvailableTools: ctx.mcpAvailableTools,
|
||||
requestScopedConnections: ctx.requestScopedConnections,
|
||||
userMCPAuthMap: ctx.userMCPAuthMap,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ const processAddedConvo = async ({
|
|||
defaultActiveOnShare,
|
||||
codeEnvAvailable,
|
||||
backgroundToolsAvailable,
|
||||
toolIntentsAvailable,
|
||||
statefulSessionsAvailable,
|
||||
memoryAvailable,
|
||||
}) => {
|
||||
|
|
@ -180,6 +181,7 @@ const processAddedConvo = async ({
|
|||
}),
|
||||
codeEnvAvailable,
|
||||
backgroundToolsAvailable,
|
||||
toolIntentsAvailable,
|
||||
statefulSessionsAvailable,
|
||||
memoryAvailable,
|
||||
skillStates,
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt
|
|||
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
||||
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
|
||||
const backgroundToolsAvailable = enabledCapabilities.has(AgentCapabilities.run_in_background);
|
||||
const toolIntentsAvailable = enabledCapabilities.has(AgentCapabilities.tool_intents);
|
||||
const statefulSessionsAvailable = enabledCapabilities.has(
|
||||
AgentCapabilities.stateful_code_sessions,
|
||||
);
|
||||
|
|
@ -272,6 +273,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt
|
|||
agent: ctx.agent,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
backgroundToolNames: ctx.backgroundToolNames,
|
||||
intentToolNames: ctx.intentToolNames,
|
||||
mcpAvailableTools: ctx.mcpAvailableTools,
|
||||
requestScopedConnections: ctx.requestScopedConnections,
|
||||
userMCPAuthMap: ctx.userMCPAuthMap,
|
||||
|
|
@ -455,6 +457,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt
|
|||
skillAuthoringAvailable: primarySkillAuthoringAvailable,
|
||||
codeEnvAvailable,
|
||||
backgroundToolsAvailable,
|
||||
toolIntentsAvailable,
|
||||
statefulSessionsAvailable,
|
||||
memoryAvailable,
|
||||
skillStates,
|
||||
|
|
@ -533,6 +536,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt
|
|||
defaultActiveOnShare,
|
||||
codeEnvAvailable,
|
||||
backgroundToolsAvailable,
|
||||
toolIntentsAvailable,
|
||||
statefulSessionsAvailable,
|
||||
memoryAvailable,
|
||||
},
|
||||
|
|
@ -607,6 +611,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt
|
|||
defaultActiveOnShare,
|
||||
codeEnvAvailable,
|
||||
backgroundToolsAvailable,
|
||||
toolIntentsAvailable,
|
||||
statefulSessionsAvailable,
|
||||
memoryAvailable,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@ function buildAgentToolContext({ agent, config }) {
|
|||
endpointTokenConfig: config.endpointTokenConfig,
|
||||
toolRegistry: config.toolRegistry,
|
||||
backgroundToolNames: config.backgroundToolNames,
|
||||
intentToolNames: config.intentToolNames,
|
||||
mcpAvailableTools: config.mcpAvailableTools,
|
||||
requestScopedConnections: config.requestScopedConnections,
|
||||
userMCPAuthMap: config.userMCPAuthMap,
|
||||
|
|
|
|||
|
|
@ -1493,6 +1493,7 @@ async function loadToolsForExecution({
|
|||
toolNames,
|
||||
toolRegistry,
|
||||
backgroundToolNames,
|
||||
intentToolNames,
|
||||
mcpAvailableTools,
|
||||
requestScopedConnections,
|
||||
userMCPAuthMap,
|
||||
|
|
@ -1511,6 +1512,12 @@ async function loadToolsForExecution({
|
|||
if (backgroundToolNames?.length) {
|
||||
configurable.backgroundToolNames = backgroundToolNames;
|
||||
}
|
||||
/** Per-agent set of tools that received the host-injected `intent` label
|
||||
* param; the executor strips the arg before invocation and removes the
|
||||
* param from schemas the PTC sandbox sees. */
|
||||
if (intentToolNames?.length) {
|
||||
configurable.intentToolNames = intentToolNames;
|
||||
}
|
||||
|
||||
const isToolSearch = toolNames.includes(AgentConstants.TOOL_SEARCH);
|
||||
const ptcToolNames = [
|
||||
|
|
|
|||
|
|
@ -8,3 +8,28 @@ cache: true
|
|||
interface:
|
||||
# Exercise the cost row against real provider usage.
|
||||
contextCost: true
|
||||
|
||||
# Local stdio fixture reused from the mock harness: gives the real model a
|
||||
# genuine tool to call, so tool-calling behaviour (including intent labels)
|
||||
# is exercised end to end without any external service.
|
||||
mcpServers:
|
||||
e2e-memory:
|
||||
type: stdio
|
||||
command: node
|
||||
args:
|
||||
- e2e/setup/fake-mcp-server.js
|
||||
title: E2E Memory
|
||||
description: Local MCP fixture used by real-provider end-to-end tests.
|
||||
timeout: 30000
|
||||
|
||||
endpoints:
|
||||
agents:
|
||||
# Defaults plus `tool_intents`, so the real provider is asked to author an
|
||||
# `intent` label as the first argument of every opted-in tool call.
|
||||
capabilities:
|
||||
- deferred_tools
|
||||
- execute_code
|
||||
- file_search
|
||||
- actions
|
||||
- tools
|
||||
- tool_intents
|
||||
|
|
|
|||
|
|
@ -45,6 +45,21 @@ const baseEnv = {
|
|||
ALLOW_SOCIAL_LOGIN: 'false',
|
||||
ALLOW_SOCIAL_REGISTRATION: 'false',
|
||||
STREAM_KEEP_COMPLETED_JOBS: 'true',
|
||||
/**
|
||||
* Optional Langfuse passthrough for tracing a real run. Credentials are read
|
||||
* from the invoking environment only (they match the credential-neutralizer
|
||||
* pattern below, so they must be preserved explicitly) and are never written
|
||||
* to the generated config. Absent values leave tracing disabled.
|
||||
*/
|
||||
...(process.env.LANGFUSE_PUBLIC_KEY
|
||||
? {
|
||||
LANGFUSE_PUBLIC_KEY: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
LANGFUSE_SECRET_KEY: process.env.LANGFUSE_SECRET_KEY,
|
||||
...(process.env.LANGFUSE_BASE_URL
|
||||
? { LANGFUSE_BASE_URL: process.env.LANGFUSE_BASE_URL }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const SECRET_KEY_PATTERN = /(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS|CLIENT_ID|_KEY)$/i;
|
||||
|
|
|
|||
186
e2e/specs/real/tool-intents.spec.ts
Normal file
186
e2e/specs/real/tool-intents.spec.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import type { AgentDetail } from '../mock/agents.helpers';
|
||||
import { cleanupAgent, openAgentBuilder, uniqueAgentName } from '../mock/agents.helpers';
|
||||
import { fetchJson, getAccessToken, requestJson, sendMessage } from '../mock/helpers';
|
||||
|
||||
/**
|
||||
* LOCAL-ONLY real-provider verification for tool intent labels
|
||||
* (`AgentCapabilities.tool_intents`).
|
||||
*
|
||||
* The behaviour under test is model behaviour, so it cannot be faked: does a
|
||||
* real provider actually author the injected `intent` argument, put it FIRST
|
||||
* in the streamed arguments, and give sibling calls to one tool distinct
|
||||
* labels? Schema-shape unit tests cannot answer any of that.
|
||||
*
|
||||
* Runs only via e2e/playwright.config.real.ts (requires ANTHROPIC_API_KEY).
|
||||
* Set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_BASE_URL in the
|
||||
* invoking environment to also emit the run to Langfuse for trace inspection.
|
||||
*/
|
||||
|
||||
const REAL_MODEL = process.env.E2E_REAL_ANTHROPIC_MODEL ?? 'claude-haiku-4-5';
|
||||
const MCP_SERVER_NAME = 'e2e-memory';
|
||||
const REMEMBER_TOOL_ID = `remember_fact_mcp_${MCP_SERVER_NAME}`;
|
||||
const MCP_SERVER_TOOL_ID = `sys__server__sys_mcp_${MCP_SERVER_NAME}`;
|
||||
|
||||
const INTENT_ARG = 'intent';
|
||||
|
||||
type MCPToolsResponse = {
|
||||
servers?: Record<string, { tools?: Array<{ pluginKey: string }> }>;
|
||||
};
|
||||
|
||||
type ToolCallRecord = {
|
||||
name?: string;
|
||||
args?: unknown;
|
||||
};
|
||||
|
||||
type MessageRecord = {
|
||||
content?: Array<{ type?: string; tool_call?: ToolCallRecord }>;
|
||||
};
|
||||
|
||||
async function waitForRememberTool(page: Page) {
|
||||
const token = await getAccessToken(page);
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const tools = await fetchJson<MCPToolsResponse>(page, '/api/mcp/tools', token);
|
||||
const serverTools = tools.servers?.[MCP_SERVER_NAME]?.tools ?? [];
|
||||
if (serverTools.some((tool) => tool.pluginKey === REMEMBER_TOOL_ID)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Expected ${MCP_SERVER_NAME} to expose ${REMEMBER_TOOL_ID}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads back every persisted tool call for a conversation. Args are asserted
|
||||
* from persistence rather than the DOM deliberately: no UI renders the label
|
||||
* yet (that is the follow-up client slice), and persistence is what a reloaded
|
||||
* conversation and the Langfuse trace both read from.
|
||||
*/
|
||||
async function readToolCalls(page: Page, conversationId: string): Promise<ToolCallRecord[]> {
|
||||
const token = await getAccessToken(page);
|
||||
const messages = await fetchJson<MessageRecord[]>(page, `/api/messages/${conversationId}`, token);
|
||||
const calls: ToolCallRecord[] = [];
|
||||
for (const message of messages ?? []) {
|
||||
for (const part of message.content ?? []) {
|
||||
if (part.type === 'tool_call' && part.tool_call) {
|
||||
calls.push(part.tool_call);
|
||||
}
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** Provider args arrive as an object or a JSON string depending on the path. */
|
||||
function parseArgs(args: unknown): Record<string, unknown> | undefined {
|
||||
if (args != null && typeof args === 'object' && !Array.isArray(args)) {
|
||||
return args as Record<string, unknown>;
|
||||
}
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
test.describe('tool intent labels (real provider)', () => {
|
||||
test('the model authors a distinct, first-position intent per sibling call', async ({ page }) => {
|
||||
test.setTimeout(180000);
|
||||
const agentName = uniqueAgentName('intent');
|
||||
let createdAgentId: string | undefined;
|
||||
|
||||
try {
|
||||
await page.goto('/c/new');
|
||||
await waitForRememberTool(page);
|
||||
|
||||
const token = await getAccessToken(page);
|
||||
const createdAgent = await requestJson<AgentDetail>(page, {
|
||||
path: '/api/agents',
|
||||
token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: agentName,
|
||||
description: 'Real-provider verification of tool intent labels.',
|
||||
instructions:
|
||||
'Use the remember_fact tool to store facts. When asked to store several facts, ' +
|
||||
'call the tool once per fact.',
|
||||
provider: 'anthropic',
|
||||
model: REAL_MODEL,
|
||||
tools: [MCP_SERVER_TOOL_ID, REMEMBER_TOOL_ID],
|
||||
/** MCP tools are not in the default-on native set, so the label is
|
||||
* opt-in per tool — the same `tool_options` contract the builder
|
||||
* toggle will write. */
|
||||
tool_options: { [REMEMBER_TOOL_ID]: { describe_intent: true } },
|
||||
},
|
||||
});
|
||||
createdAgentId = createdAgent.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();
|
||||
|
||||
/** Two facts in one turn — the reference case the feature exists for.
|
||||
* Both calls hit the SAME tool, so only the intent can tell them apart. */
|
||||
const response = await sendMessage(
|
||||
page,
|
||||
'Store these two facts separately, one tool call each: ' +
|
||||
'(1) the OAuth callback router lives in server/routes/oauth.js, and ' +
|
||||
'(2) the MCP connection pool is configured in api/mcp/pool.ts.',
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 60000 });
|
||||
|
||||
const conversationId = new URL(page.url()).pathname.split('/c/')[1];
|
||||
expect(conversationId).toBeTruthy();
|
||||
|
||||
let rememberCalls: ToolCallRecord[] = [];
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const calls = await readToolCalls(page, conversationId);
|
||||
rememberCalls = calls.filter((call) => call.name?.startsWith('remember_fact'));
|
||||
return rememberCalls.length;
|
||||
},
|
||||
{ timeout: 120000, intervals: [2000] },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
const intents: string[] = [];
|
||||
for (const call of rememberCalls) {
|
||||
const args = parseArgs(call.args);
|
||||
expect(args, `tool call ${call.name} had unreadable args`).toBeTruthy();
|
||||
const keys = Object.keys(args as Record<string, unknown>);
|
||||
|
||||
/** The whole mechanism depends on first-key placement: it is what lets
|
||||
* a client render the label before the remaining args have streamed. */
|
||||
expect(keys[0], `expected ${INTENT_ARG} first, got ${keys.join(',')}`).toBe(INTENT_ARG);
|
||||
|
||||
const intent = (args as Record<string, unknown>)[INTENT_ARG];
|
||||
expect(typeof intent).toBe('string');
|
||||
expect((intent as string).trim().length).toBeGreaterThan(0);
|
||||
intents.push(intent as string);
|
||||
}
|
||||
|
||||
/** Sibling differentiation is the headline behaviour; models tend to emit
|
||||
* identical labels for parallel calls unless the arg description forces
|
||||
* the distinction. */
|
||||
expect(new Set(intents).size, `intents were not distinct: ${JSON.stringify(intents)}`).toBe(
|
||||
intents.length,
|
||||
);
|
||||
|
||||
console.log('[intent] observed labels:', JSON.stringify(intents, null, 2));
|
||||
|
||||
console.log('[intent] conversationId:', conversationId);
|
||||
} finally {
|
||||
await cleanupAgent(page, createdAgentId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -428,6 +428,9 @@ endpoints:
|
|||
# maxCatalogSkills: 20
|
||||
# # (optional) Agent Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below.
|
||||
# capabilities: ["deferred_tools", "execute_code", "file_search", "actions", "tools"]
|
||||
# # Off-by-default capabilities you can add to the list above:
|
||||
# # - "run_in_background": opted-in tools gain a `run_in_background` param so the model can dispatch them detached and poll via `check_background_task`.
|
||||
# # - "tool_intents": opted-in tools (native tools by default) gain an `intent` param — one model-written sentence per call, streamed into the tool-call arguments. Backend groundwork today: the chat UI renders it as each call's live status label in an upcoming release; opt tools in per agent via `tool_options[tool].describe_intent`.
|
||||
|
||||
# (optional) Custom request headers for the built-in OpenAI / Google endpoints.
|
||||
# Forwarded on every request to the provider (or an AI gateway / reverse proxy
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -61,7 +61,7 @@
|
|||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^2.8.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.3.5",
|
||||
"@librechat/agents": "^3.3.7",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
|
@ -10615,9 +10615,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@librechat/agents": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.3.5.tgz",
|
||||
"integrity": "sha512-0mdLZmPpD15eV/4UlyUxIgDT7qA1q+hIGNFPh/imqYQk7s5IVn7+CuaDkd11JA5F/RW9K0pXQYFK03A0qVVj1w==",
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.3.7.tgz",
|
||||
"integrity": "sha512-lWX2Om+iME32QQy1kZKx52SE3edECp8Wgvvi4OJ7qdmsDSJPJOZBqKuSS8XNxAyGaqeo664H5JlgHWsa+I9CSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
|
|
@ -42535,7 +42535,7 @@
|
|||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^2.8.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.3.5",
|
||||
"@librechat/agents": "^3.3.7",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@
|
|||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^2.8.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.3.5",
|
||||
"@librechat/agents": "^3.3.7",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import type { Agent, AgentToolOptions, TConversation, TModelSpec } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { synthesizeIntentToolOptions, mergeSynthesizedToolOptions } from '~/agents/intent';
|
||||
import { ASK_USER_QUESTION_TOOL_NAME } from '~/agents/hitl/askUserQuestionTool';
|
||||
import { synthesizeBackgroundToolOptions } from '~/agents/background';
|
||||
import { requiresEphemeralUserConnection } from '~/mcp/utils';
|
||||
|
|
@ -121,6 +122,7 @@ export async function loadAddedAgent(
|
|||
memory?: boolean;
|
||||
ask_user_question?: boolean;
|
||||
run_in_background?: boolean;
|
||||
describe_intent?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
|
|
@ -163,6 +165,16 @@ export async function loadAddedAgent(
|
|||
if (primaryBackgroundToolOptions) {
|
||||
result.tool_options = primaryBackgroundToolOptions;
|
||||
}
|
||||
const primaryIntentToolOptions: AgentToolOptions | undefined = synthesizeIntentToolOptions(
|
||||
result.tools as string[],
|
||||
{ ephemeralAgent, modelSpec },
|
||||
);
|
||||
if (primaryIntentToolOptions) {
|
||||
result.tool_options = mergeSynthesizedToolOptions(
|
||||
result.tool_options as AgentToolOptions | undefined,
|
||||
primaryIntentToolOptions,
|
||||
);
|
||||
}
|
||||
return result as unknown as Agent;
|
||||
}
|
||||
|
||||
|
|
@ -281,6 +293,16 @@ export async function loadAddedAgent(
|
|||
if (backgroundToolOptions) {
|
||||
result.tool_options = backgroundToolOptions;
|
||||
}
|
||||
const intentToolOptions: AgentToolOptions | undefined = synthesizeIntentToolOptions(tools, {
|
||||
ephemeralAgent,
|
||||
modelSpec,
|
||||
});
|
||||
if (intentToolOptions) {
|
||||
result.tool_options = mergeSynthesizedToolOptions(
|
||||
result.tool_options as AgentToolOptions | undefined,
|
||||
intentToolOptions,
|
||||
);
|
||||
}
|
||||
|
||||
return result as unknown as Agent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,12 @@ export interface DiscoverConnectedAgentsParams {
|
|||
* matching how the same agent behaves when run as the primary.
|
||||
*/
|
||||
backgroundToolsAvailable?: InitializeAgentParams['backgroundToolsAvailable'];
|
||||
/**
|
||||
* Run-level `tool_intents` capability gate. Forwarded verbatim so a
|
||||
* handoff/connected agent's opted-in tools get the injected `intent` param,
|
||||
* matching how the same agent behaves when run as the primary.
|
||||
*/
|
||||
toolIntentsAvailable?: InitializeAgentParams['toolIntentsAvailable'];
|
||||
}
|
||||
|
||||
export interface DiscoverConnectedAgentsDeps {
|
||||
|
|
@ -173,6 +179,7 @@ export async function discoverConnectedAgents(
|
|||
defaultActiveOnShare,
|
||||
codeEnvAvailable,
|
||||
backgroundToolsAvailable,
|
||||
toolIntentsAvailable,
|
||||
statefulSessionsAvailable,
|
||||
memoryAvailable,
|
||||
} = params;
|
||||
|
|
@ -279,6 +286,7 @@ export async function discoverConnectedAgents(
|
|||
defaultActiveOnShare,
|
||||
codeEnvAvailable,
|
||||
backgroundToolsAvailable,
|
||||
toolIntentsAvailable,
|
||||
statefulSessionsAvailable,
|
||||
memoryAvailable,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ import {
|
|||
HOST_FILE_AUTHORING_ARTIFACT_KEY,
|
||||
isCodeSessionToolName,
|
||||
} from './tools';
|
||||
import {
|
||||
hasIntentArg,
|
||||
stripIntentArg,
|
||||
stripIntentLabelsFromToolDefinitions,
|
||||
INTENT_ARG,
|
||||
} from './intent';
|
||||
import { logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils';
|
||||
import { buildSkillPrimeMessage, SKILL_FILE_PREFIX } from './skills';
|
||||
import { parseFrontmatter } from '../skills/import';
|
||||
|
|
@ -2030,6 +2036,37 @@ function toolDeclaresRunInBackgroundParam(tool: StructuredToolInterface): boolea
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the tool's own schema declares `intent` (zod shape or raw JSON
|
||||
* schema) — SDK-native intent tools do, so they receive the argument
|
||||
* untouched and handle it themselves; host-injected tools do not, so the
|
||||
* arg is stripped before invocation.
|
||||
*/
|
||||
function toolDeclaresIntentParam(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?.[INTENT_ARG] != null || schema.properties?.[INTENT_ARG] != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the host-injected `intent` label from invoke args unless the tool's
|
||||
* own schema declares it. The label rides `tool_call.args` to the client
|
||||
* untouched — only the tool body must never see an undeclared parameter
|
||||
* (strict MCP/action schemas would reject it; zod tools would strip-or-throw).
|
||||
*/
|
||||
function stripIntentForInvoke(args: unknown, tool: StructuredToolInterface): unknown {
|
||||
if (!hasIntentArg(args) || toolDeclaresIntentParam(tool)) {
|
||||
return args;
|
||||
}
|
||||
return stripIntentArg(args);
|
||||
}
|
||||
|
||||
function mergeToolConfigurables(
|
||||
base: Record<string, unknown> | undefined,
|
||||
loaded: Record<string, unknown> | undefined,
|
||||
|
|
@ -3721,7 +3758,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
}
|
||||
const { task, isNew } = created;
|
||||
if (isNew) {
|
||||
const strippedArgs = stripRunInBackgroundArg(tc.args);
|
||||
const strippedArgs = stripIntentForInvoke(stripRunInBackgroundArg(tc.args), tool);
|
||||
/** Persists the settled result onto the dispatch turn's message
|
||||
* (patch the tool-call part's output, persist generated files,
|
||||
* append attachments), so a backgrounded code call reads like a
|
||||
|
|
@ -4180,10 +4217,16 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
/* 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,
|
||||
* mutated to include it) — mirrors the self-spawn path.
|
||||
* Intent LABELS are stripped for the same reason —
|
||||
* host-injected AND SDK-native alike (marker-guarded):
|
||||
* no card renders for an inner call, so the sandbox
|
||||
* bridge must not advertise them. */
|
||||
const toolDefs = stripIntentLabelsFromToolDefinitions(
|
||||
stripBackgroundFromToolDefinitions(
|
||||
filteredToolDefs,
|
||||
mergedConfigurable?.backgroundToolNames as string[] | undefined,
|
||||
),
|
||||
);
|
||||
toolCallConfig.toolDefs = toolDefs;
|
||||
toolCallConfig.toolMap = ptcToolMap ?? toolMap;
|
||||
|
|
@ -4202,7 +4245,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
? stripRunInBackgroundArg(tc.args)
|
||||
: tc.args;
|
||||
const result = await tool.invoke(
|
||||
normalizeToolInvokeArgs(foregroundArgs, tool),
|
||||
normalizeToolInvokeArgs(stripIntentForInvoke(foregroundArgs, tool), tool),
|
||||
{
|
||||
toolCall: toolCallConfig,
|
||||
configurable: mergedConfigurable,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
} from './tools';
|
||||
import { normalizeServerName, requiresEphemeralUserConnection, splitMCPToolKey } from '~/mcp/utils';
|
||||
import { registerMemoryTools, memoryToolUsageGuard } from './memory';
|
||||
import { applyIntentLabels, sanitizeIntentLabels } from './intent';
|
||||
import { applyBackgroundToolCalls } from './background';
|
||||
import { filterFilesByEndpointConfig } from '~/files';
|
||||
import { generateArtifactsPrompt } from '~/prompts';
|
||||
|
|
@ -265,6 +266,15 @@ export type InitializedAgent = Agent & {
|
|||
* opt-in and gate the `check_background_task` poll tool at execution time.
|
||||
*/
|
||||
backgroundToolNames?: string[];
|
||||
/**
|
||||
* Names of this agent's tools that received the host-injected `intent`
|
||||
* param (capability enabled AND opted in AND eligible). Threaded to the
|
||||
* tool executor via `configurable` so the arg is stripped before invoking
|
||||
* tools that don't declare it, and stripped from schemas a self-spawn
|
||||
* child or PTC sandbox inherits. SDK-native intent schemas are the tools'
|
||||
* own and are never listed here.
|
||||
*/
|
||||
intentToolNames?: 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
|
||||
|
|
@ -421,6 +431,13 @@ export interface InitializeAgentParams {
|
|||
* tool is registered.
|
||||
*/
|
||||
backgroundToolsAvailable?: boolean;
|
||||
/**
|
||||
* Whether the `tool_intents` capability is enabled for this run. When true,
|
||||
* tools opted in via `tool_options[name].describe_intent` (native host
|
||||
* tools default on) get an `intent` string injected as the FIRST schema
|
||||
* property, rendered by the client as the call's live status label.
|
||||
*/
|
||||
toolIntentsAvailable?: boolean;
|
||||
/** Whether stateful code sessions are available (stateful_code_sessions capability enabled) */
|
||||
statefulSessionsAvailable?: boolean;
|
||||
/** Whether inline memory tools are available (memory capability enabled, memory
|
||||
|
|
@ -1170,6 +1187,8 @@ export async function initializeAgent(
|
|||
toolDefinitions = fileAuthoringResult.toolDefinitions;
|
||||
}
|
||||
|
||||
let intentToolNames: string[] | undefined;
|
||||
|
||||
/**
|
||||
* Inject the `run_in_background` param into eligible opted-in tools and
|
||||
* register the `check_background_task` poll tool. Runs after all built-in
|
||||
|
|
@ -1307,6 +1326,37 @@ export async function initializeAgent(
|
|||
activeSkillNames = skillResult.activeSkillNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent labels run LAST, after every registration step — the skill
|
||||
* catalog above both appends its own definition and REPLACES upgraded ones
|
||||
* (e.g. the skill-aware `read_file`), so an earlier injection would be
|
||||
* clobbered. Injection PREPENDS while background's param APPENDS, so
|
||||
* `intent` is the first schema property regardless of this ordering.
|
||||
* The sanitize pass then enforces the flip side: with the capability off
|
||||
* it strips SDK-native intent labels (a real admin kill switch); with it
|
||||
* on it enforces explicit per-tool opt-outs on late-registered
|
||||
* definitions. Both are marker-guarded — a tool's own `intent` business
|
||||
* parameter is never touched.
|
||||
*/
|
||||
if (params.toolIntentsAvailable === true) {
|
||||
const intentResult = applyIntentLabels({
|
||||
toolDefinitions,
|
||||
toolRegistry,
|
||||
toolOptions: agent.tool_options,
|
||||
});
|
||||
toolDefinitions = intentResult.toolDefinitions;
|
||||
if (intentResult.intentToolNames.length > 0) {
|
||||
intentToolNames = intentResult.intentToolNames;
|
||||
}
|
||||
}
|
||||
const intentSanitized = sanitizeIntentLabels({
|
||||
toolDefinitions,
|
||||
toolRegistry,
|
||||
toolOptions: agent.tool_options,
|
||||
capabilityEnabled: params.toolIntentsAvailable === true,
|
||||
});
|
||||
toolDefinitions = intentSanitized.toolDefinitions;
|
||||
|
||||
const hasFinalAgentTools =
|
||||
(structuredTools?.length ?? 0) > 0 || (toolDefinitions?.length ?? 0) > 0;
|
||||
if (isGoogleToolCombinationProvider(agent.provider) && hasProviderTools && hasFinalAgentTools) {
|
||||
|
|
@ -1367,6 +1417,7 @@ export async function initializeAgent(
|
|||
toolDefinitions,
|
||||
hasDeferredTools,
|
||||
backgroundToolNames,
|
||||
intentToolNames,
|
||||
actionsEnabled,
|
||||
baseContextTokens,
|
||||
memoryToolsRegistered: inlineMemoryRegistered,
|
||||
|
|
|
|||
546
packages/api/src/agents/intent.spec.ts
Normal file
546
packages/api/src/agents/intent.spec.ts
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
import { Constants } from 'librechat-data-provider';
|
||||
import type { LCTool, LCToolRegistry } from '@librechat/agents';
|
||||
import {
|
||||
INTENT_ARG,
|
||||
NATIVE_INTENT_TOOL_NAMES,
|
||||
isIntentEligibleToolName,
|
||||
hasIntentArg,
|
||||
readIntentArg,
|
||||
stripIntentArg,
|
||||
injectIntentParam,
|
||||
stripIntentFromToolDefinitions,
|
||||
stripIntentLabelsFromToolDefinitions,
|
||||
stripIntentFromToolRegistry,
|
||||
applyIntentLabels,
|
||||
sanitizeIntentLabels,
|
||||
synthesizeIntentToolOptions,
|
||||
mergeSynthesizedToolOptions,
|
||||
} from './intent';
|
||||
import { applyBackgroundToolCalls, CHECK_BACKGROUND_TASK_NAME } 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;
|
||||
|
||||
/** Mirrors an SDK-native intent schema: the label contract's marker text. */
|
||||
const sdkNativeDef = (name: string): LCTool =>
|
||||
({
|
||||
name,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
intent: {
|
||||
type: 'string',
|
||||
description:
|
||||
'ALWAYS write this field FIRST, before any other argument. One short sentence…',
|
||||
},
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
}) as unknown as LCTool;
|
||||
|
||||
describe('isIntentEligibleToolName', () => {
|
||||
it('excludes only the poll tool and handoff tools', () => {
|
||||
expect(isIntentEligibleToolName(CHECK_BACKGROUND_TASK_NAME)).toBe(false);
|
||||
expect(isIntentEligibleToolName('lc_transfer_to_researcher')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows MCP, native, and code-execution tools (labels are inert)', () => {
|
||||
for (const name of [
|
||||
'search_mcp_docs',
|
||||
'web_search',
|
||||
'create_file',
|
||||
'edit_file',
|
||||
'set_memory',
|
||||
'delete_memory',
|
||||
'ask_user_question',
|
||||
'execute_code',
|
||||
'bash_tool',
|
||||
'file_search',
|
||||
]) {
|
||||
expect(isIntentEligibleToolName(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasIntentArg / readIntentArg / stripIntentArg', () => {
|
||||
it('detects and reads the arg on object and stringified args', () => {
|
||||
expect(hasIntentArg({ [INTENT_ARG]: 'Searching for OAuth handling' })).toBe(true);
|
||||
expect(hasIntentArg({})).toBe(false);
|
||||
expect(hasIntentArg('{"intent":"Searching"}')).toBe(true);
|
||||
expect(readIntentArg({ [INTENT_ARG]: 'Searching for OAuth handling' })).toBe(
|
||||
'Searching for OAuth handling',
|
||||
);
|
||||
expect(readIntentArg({ [INTENT_ARG]: ' ' })).toBeUndefined();
|
||||
expect(readIntentArg({ [INTENT_ARG]: 42 })).toBeUndefined();
|
||||
expect(readIntentArg('not json')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips the arg without mutating the original', () => {
|
||||
const args = { q: 'hi', [INTENT_ARG]: 'Searching' };
|
||||
const stripped = stripIntentArg(args) as Record<string, unknown>;
|
||||
expect(stripped).toEqual({ q: 'hi' });
|
||||
expect(INTENT_ARG in args).toBe(true);
|
||||
});
|
||||
|
||||
it('returns non-object / arg-less args unchanged', () => {
|
||||
expect(stripIntentArg('str')).toBe('str');
|
||||
const noArg = { q: 'hi' };
|
||||
expect(stripIntentArg(noArg)).toBe(noArg);
|
||||
expect(stripIntentArg('{"intent":"Searching","q":"x"}')).toEqual({ q: 'x' });
|
||||
expect(stripIntentArg('{"q":"x"}')).toBe('{"q":"x"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectIntentParam', () => {
|
||||
it('PREPENDS intent as the FIRST property without mutating a frozen def', () => {
|
||||
const def = Object.freeze(mcpDef('search_mcp_docs')) as unknown as LCTool & {
|
||||
parameters: { properties: Record<string, unknown> };
|
||||
};
|
||||
const injected = injectIntentParam(def);
|
||||
const props = (injected.parameters as { properties: Record<string, { type: string }> })
|
||||
.properties;
|
||||
expect(Object.keys(props)).toEqual([INTENT_ARG, 'q']);
|
||||
expect(props[INTENT_ARG]).toEqual(expect.objectContaining({ type: 'string' }));
|
||||
expect(INTENT_ARG in def.parameters.properties).toBe(false);
|
||||
});
|
||||
|
||||
it('never adds intent to required', () => {
|
||||
const injected = injectIntentParam(mcpDef('search_mcp_docs'));
|
||||
expect((injected.parameters as { required?: string[] }).required).toEqual(['q']);
|
||||
});
|
||||
|
||||
it('creates an object schema when the tool declares no parameters', () => {
|
||||
const def = { name: 'no_params' } as unknown as LCTool;
|
||||
const injected = injectIntentParam(def);
|
||||
const params = injected.parameters as { type: string; properties: Record<string, unknown> };
|
||||
expect(params.type).toBe('object');
|
||||
expect(Object.keys(params.properties)).toEqual([INTENT_ARG]);
|
||||
});
|
||||
|
||||
it('is a no-op when the param already exists (position preserved)', () => {
|
||||
const def = mcpDef('search_mcp_docs');
|
||||
const once = injectIntentParam(def);
|
||||
const twice = injectIntentParam(once);
|
||||
expect(twice).toBe(once);
|
||||
expect(Object.keys((twice.parameters as { properties: object }).properties)[0]).toBe(
|
||||
INTENT_ARG,
|
||||
);
|
||||
});
|
||||
|
||||
it('embeds an extensible copy of the property, not a frozen shared instance', () => {
|
||||
const first = injectIntentParam(mcpDef('a'));
|
||||
const second = injectIntentParam(mcpDef('b'));
|
||||
const firstProp = (first.parameters as { properties: Record<string, object> }).properties[
|
||||
INTENT_ARG
|
||||
];
|
||||
const secondProp = (second.parameters as { properties: Record<string, object> }).properties[
|
||||
INTENT_ARG
|
||||
];
|
||||
expect(firstProp).not.toBe(secondProp);
|
||||
expect(Object.isFrozen(firstProp)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyIntentLabels', () => {
|
||||
it('injects only opted-in tools and mirrors the registry entry', () => {
|
||||
const optedIn = mcpDef('search_mcp_docs');
|
||||
const notOpted = mcpDef('lookup_customer');
|
||||
const toolRegistry: LCToolRegistry = new Map([
|
||||
['search_mcp_docs', optedIn],
|
||||
['lookup_customer', notOpted],
|
||||
]);
|
||||
const { toolDefinitions, intentToolNames } = applyIntentLabels({
|
||||
toolDefinitions: [optedIn, notOpted],
|
||||
toolRegistry,
|
||||
toolOptions: { search_mcp_docs: { describe_intent: true } },
|
||||
});
|
||||
expect(intentToolNames).toEqual(['search_mcp_docs']);
|
||||
const injected = toolDefinitions.find((d) => d.name === 'search_mcp_docs');
|
||||
expect(Object.keys((injected?.parameters as { properties: object }).properties)[0]).toBe(
|
||||
INTENT_ARG,
|
||||
);
|
||||
expect(toolDefinitions.find((d) => d.name === 'lookup_customer')).toBe(notOpted);
|
||||
const registryProps = (
|
||||
toolRegistry.get('search_mcp_docs')?.parameters as { properties: object }
|
||||
).properties;
|
||||
expect(Object.keys(registryProps)[0]).toBe(INTENT_ARG);
|
||||
});
|
||||
|
||||
it('defaults native host tools ON, with explicit false opting out', () => {
|
||||
const webSearch = mcpDef('web_search');
|
||||
const setMemory = mcpDef('set_memory');
|
||||
const { toolDefinitions, intentToolNames } = applyIntentLabels({
|
||||
toolDefinitions: [webSearch, setMemory],
|
||||
toolRegistry: undefined,
|
||||
toolOptions: { set_memory: { describe_intent: false } },
|
||||
});
|
||||
expect(intentToolNames).toEqual(['web_search']);
|
||||
expect(toolDefinitions.find((d) => d.name === 'set_memory')).toBe(setMemory);
|
||||
});
|
||||
|
||||
it('covers every advertised native tool name', () => {
|
||||
const defs = [...NATIVE_INTENT_TOOL_NAMES].map((name) => mcpDef(name));
|
||||
const { intentToolNames } = applyIntentLabels({
|
||||
toolDefinitions: defs,
|
||||
toolRegistry: undefined,
|
||||
toolOptions: undefined,
|
||||
});
|
||||
expect(intentToolNames.sort()).toEqual([...NATIVE_INTENT_TOOL_NAMES].sort());
|
||||
});
|
||||
|
||||
it('skips SDK-native defs that already declare intent (not counted as host-injected)', () => {
|
||||
const sdkNative = {
|
||||
name: 'read_file',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { intent: { type: 'string' }, path: { type: 'string' } },
|
||||
},
|
||||
} as unknown as LCTool;
|
||||
const { toolDefinitions, intentToolNames } = applyIntentLabels({
|
||||
toolDefinitions: [sdkNative],
|
||||
toolRegistry: undefined,
|
||||
toolOptions: { read_file: { describe_intent: true } },
|
||||
});
|
||||
expect(intentToolNames).toEqual([]);
|
||||
expect(toolDefinitions[0]).toBe(sdkNative);
|
||||
});
|
||||
|
||||
it('strips an SDK-native intent property on explicit opt-out (def and registry)', () => {
|
||||
const sdkNative = sdkNativeDef('web_search');
|
||||
const toolRegistry: LCToolRegistry = new Map([['web_search', sdkNative]]);
|
||||
const { toolDefinitions, intentToolNames } = applyIntentLabels({
|
||||
toolDefinitions: [sdkNative],
|
||||
toolRegistry,
|
||||
toolOptions: { web_search: { describe_intent: false } },
|
||||
});
|
||||
expect(intentToolNames).toEqual([]);
|
||||
const strippedProps = (toolDefinitions[0].parameters as { properties: object }).properties;
|
||||
expect(Object.keys(strippedProps)).toEqual(['query']);
|
||||
const registryProps = (toolRegistry.get('web_search')?.parameters as { properties: object })
|
||||
.properties;
|
||||
expect(INTENT_ARG in registryProps).toBe(false);
|
||||
expect(INTENT_ARG in (sdkNative.parameters as { properties: object }).properties).toBe(true);
|
||||
});
|
||||
|
||||
it('never strips a tool-owned business `intent` parameter on opt-out', () => {
|
||||
const businessIntent = {
|
||||
name: 'create_record',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
intent: { type: 'string', description: 'CRM intent category for the record' },
|
||||
title: { type: 'string' },
|
||||
},
|
||||
required: ['intent'],
|
||||
},
|
||||
} as unknown as LCTool;
|
||||
const { toolDefinitions } = applyIntentLabels({
|
||||
toolDefinitions: [businessIntent],
|
||||
toolRegistry: undefined,
|
||||
toolOptions: { create_record: { describe_intent: false } },
|
||||
});
|
||||
expect(toolDefinitions[0]).toBe(businessIntent);
|
||||
});
|
||||
|
||||
it('skips a non-object (string-input) schema without rewriting it', () => {
|
||||
const stringInput = {
|
||||
name: 'legacy_tool',
|
||||
parameters: { type: 'string' },
|
||||
} as unknown as LCTool;
|
||||
const { toolDefinitions, intentToolNames } = applyIntentLabels({
|
||||
toolDefinitions: [stringInput],
|
||||
toolRegistry: undefined,
|
||||
toolOptions: { legacy_tool: { describe_intent: true } },
|
||||
});
|
||||
expect(intentToolNames).toEqual([]);
|
||||
expect(toolDefinitions[0]).toBe(stringInput);
|
||||
});
|
||||
|
||||
it('skips PTC-only tools (no card ever renders)', () => {
|
||||
const ptcOnly = {
|
||||
...mcpDef('sandbox_helper'),
|
||||
allowed_callers: ['code_execution'],
|
||||
} as unknown as LCTool;
|
||||
const { intentToolNames } = applyIntentLabels({
|
||||
toolDefinitions: [ptcOnly],
|
||||
toolRegistry: undefined,
|
||||
toolOptions: { sandbox_helper: { describe_intent: true } },
|
||||
});
|
||||
expect(intentToolNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('honors the host excludeTool predicate', () => {
|
||||
const def = mcpDef('search_mcp_ephemeral');
|
||||
const { intentToolNames } = applyIntentLabels({
|
||||
toolDefinitions: [def],
|
||||
toolRegistry: undefined,
|
||||
toolOptions: { search_mcp_ephemeral: { describe_intent: true } },
|
||||
excludeTool: () => true,
|
||||
});
|
||||
expect(intentToolNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps intent FIRST when background injection runs after it', () => {
|
||||
const def = mcpDef('search_mcp_docs');
|
||||
const toolRegistry: LCToolRegistry = new Map([['search_mcp_docs', def]]);
|
||||
const toolOptions = {
|
||||
search_mcp_docs: { describe_intent: true, run_in_background: true },
|
||||
};
|
||||
const intentResult = applyIntentLabels({
|
||||
toolDefinitions: [def],
|
||||
toolRegistry,
|
||||
toolOptions,
|
||||
});
|
||||
const backgroundResult = applyBackgroundToolCalls({
|
||||
toolDefinitions: intentResult.toolDefinitions,
|
||||
toolRegistry,
|
||||
toolOptions,
|
||||
});
|
||||
const finalDef = backgroundResult.toolDefinitions.find((d) => d.name === 'search_mcp_docs');
|
||||
const keys = Object.keys((finalDef?.parameters as { properties: object }).properties);
|
||||
expect(keys[0]).toBe(INTENT_ARG);
|
||||
expect(keys).toContain('run_in_background');
|
||||
expect(backgroundResult.backgroundToolNames).toEqual(['search_mcp_docs']);
|
||||
});
|
||||
|
||||
it('keeps intent FIRST when injected AFTER background (initialize.ts ordering)', () => {
|
||||
const def = mcpDef('search_mcp_docs');
|
||||
const toolRegistry: LCToolRegistry = new Map([['search_mcp_docs', def]]);
|
||||
const toolOptions = {
|
||||
search_mcp_docs: { describe_intent: true, run_in_background: true },
|
||||
};
|
||||
const backgroundResult = applyBackgroundToolCalls({
|
||||
toolDefinitions: [def],
|
||||
toolRegistry,
|
||||
toolOptions,
|
||||
});
|
||||
const intentResult = applyIntentLabels({
|
||||
toolDefinitions: backgroundResult.toolDefinitions,
|
||||
toolRegistry,
|
||||
toolOptions,
|
||||
});
|
||||
const finalDef = intentResult.toolDefinitions.find((d) => d.name === 'search_mcp_docs');
|
||||
const keys = Object.keys((finalDef?.parameters as { properties: object }).properties);
|
||||
expect(keys[0]).toBe(INTENT_ARG);
|
||||
expect(keys).toContain('run_in_background');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripIntentLabelsFromToolDefinitions', () => {
|
||||
it('strips host-injected AND SDK-native labels, sparing business intent params', () => {
|
||||
const hostInjected = injectIntentParam(mcpDef('search_mcp_docs'));
|
||||
const sdkNative = sdkNativeDef('read_file');
|
||||
const businessIntent = {
|
||||
name: 'create_record',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
intent: { type: 'string', description: 'CRM intent category' },
|
||||
},
|
||||
required: ['intent'],
|
||||
},
|
||||
} as unknown as LCTool;
|
||||
const stripped = stripIntentLabelsFromToolDefinitions([
|
||||
hostInjected,
|
||||
sdkNative,
|
||||
businessIntent,
|
||||
]);
|
||||
expect(INTENT_ARG in (stripped[0].parameters as { properties: object }).properties).toBe(false);
|
||||
expect(INTENT_ARG in (stripped[1].parameters as { properties: object }).properties).toBe(false);
|
||||
expect(stripped[2]).toBe(businessIntent);
|
||||
});
|
||||
|
||||
it('returns the same array when nothing carries a label', () => {
|
||||
const defs = [mcpDef('a'), mcpDef('b')];
|
||||
expect(stripIntentLabelsFromToolDefinitions(defs)).toBe(defs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripIntentFromToolDefinitions / stripIntentFromToolRegistry', () => {
|
||||
it('removes the injected param only from named tools, without mutating inputs', () => {
|
||||
const injected = injectIntentParam(mcpDef('search_mcp_docs'));
|
||||
const sdkNative = injectIntentParam(mcpDef('read_file'));
|
||||
const defs = [injected, sdkNative];
|
||||
const stripped = stripIntentFromToolDefinitions(defs, ['search_mcp_docs']);
|
||||
const searchDef = stripped.find((d) => d.name === 'search_mcp_docs');
|
||||
expect(INTENT_ARG in (searchDef?.parameters as { properties: object }).properties).toBe(false);
|
||||
expect(stripped.find((d) => d.name === 'read_file')).toBe(sdkNative);
|
||||
expect(INTENT_ARG in (injected.parameters as { properties: object }).properties).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the same references when nothing is named', () => {
|
||||
const defs = [injectIntentParam(mcpDef('a'))];
|
||||
expect(stripIntentFromToolDefinitions(defs, [])).toBe(defs);
|
||||
const registry: LCToolRegistry = new Map([['a', defs[0]]]);
|
||||
expect(stripIntentFromToolRegistry(registry, undefined)).toBe(registry);
|
||||
});
|
||||
|
||||
it('registry strip returns a NEW registry with the param removed', () => {
|
||||
const injected = injectIntentParam(mcpDef('search_mcp_docs'));
|
||||
const registry: LCToolRegistry = new Map([['search_mcp_docs', injected]]);
|
||||
const next = stripIntentFromToolRegistry(registry, ['search_mcp_docs']);
|
||||
expect(next).not.toBe(registry);
|
||||
const props = (next?.get('search_mcp_docs')?.parameters as { properties: object }).properties;
|
||||
expect(INTENT_ARG in props).toBe(false);
|
||||
const originalProps = (registry.get('search_mcp_docs')?.parameters as { properties: object })
|
||||
.properties;
|
||||
expect(INTENT_ARG in originalProps).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeIntentLabels', () => {
|
||||
it('strips every SDK-native label when the capability is disabled (kill switch)', () => {
|
||||
const skill = sdkNativeDef('skill');
|
||||
const search = sdkNativeDef('tool_search');
|
||||
const toolRegistry: LCToolRegistry = new Map([
|
||||
['skill', skill],
|
||||
['tool_search', search],
|
||||
]);
|
||||
const { toolDefinitions } = sanitizeIntentLabels({
|
||||
toolDefinitions: [skill, search],
|
||||
toolRegistry,
|
||||
toolOptions: undefined,
|
||||
capabilityEnabled: false,
|
||||
});
|
||||
for (const def of toolDefinitions) {
|
||||
expect(INTENT_ARG in (def.parameters as { properties: object }).properties).toBe(false);
|
||||
}
|
||||
for (const entry of toolRegistry.values()) {
|
||||
expect(INTENT_ARG in (entry.parameters as { properties: object }).properties).toBe(false);
|
||||
}
|
||||
expect(INTENT_ARG in (skill.parameters as { properties: object }).properties).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves defs untouched when the capability is on and nothing opted out', () => {
|
||||
const skill = sdkNativeDef('skill');
|
||||
const defs = [skill];
|
||||
const { toolDefinitions } = sanitizeIntentLabels({
|
||||
toolDefinitions: defs,
|
||||
toolRegistry: undefined,
|
||||
toolOptions: undefined,
|
||||
capabilityEnabled: true,
|
||||
});
|
||||
expect(toolDefinitions).toBe(defs);
|
||||
});
|
||||
|
||||
it('enforces explicit opt-outs on late-registered defs when the capability is on', () => {
|
||||
const skill = sdkNativeDef('skill');
|
||||
const toolRegistry: LCToolRegistry = new Map([['skill', skill]]);
|
||||
const { toolDefinitions } = sanitizeIntentLabels({
|
||||
toolDefinitions: [skill],
|
||||
toolRegistry,
|
||||
toolOptions: { skill: { describe_intent: false } },
|
||||
capabilityEnabled: true,
|
||||
});
|
||||
expect(INTENT_ARG in (toolDefinitions[0].parameters as { properties: object }).properties).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
INTENT_ARG in (toolRegistry.get('skill')?.parameters as { properties: object }).properties,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('spares tool-owned business intent params in both modes', () => {
|
||||
const businessIntent = {
|
||||
name: 'create_record',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
intent: { type: 'string', description: 'CRM intent category for the record' },
|
||||
},
|
||||
required: ['intent'],
|
||||
},
|
||||
} as unknown as LCTool;
|
||||
for (const capabilityEnabled of [true, false]) {
|
||||
const { toolDefinitions } = sanitizeIntentLabels({
|
||||
toolDefinitions: [businessIntent],
|
||||
toolRegistry: undefined,
|
||||
toolOptions: { create_record: { describe_intent: false } },
|
||||
capabilityEnabled,
|
||||
});
|
||||
expect(toolDefinitions[0]).toBe(businessIntent);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('synthesizeIntentToolOptions', () => {
|
||||
it('returns undefined when neither the ephemeral toggle nor the model spec enables it', () => {
|
||||
expect(synthesizeIntentToolOptions(['web_search'], {})).toBeUndefined();
|
||||
expect(
|
||||
synthesizeIntentToolOptions(['web_search'], {
|
||||
ephemeralAgent: { describe_intent: false },
|
||||
modelSpec: { describeIntent: false },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks only eligible tools', () => {
|
||||
const options = synthesizeIntentToolOptions(
|
||||
['web_search', CHECK_BACKGROUND_TASK_NAME, 'lc_transfer_to_researcher'],
|
||||
{ ephemeralAgent: { describe_intent: true } },
|
||||
);
|
||||
expect(options).toEqual({ web_search: { describe_intent: true } });
|
||||
});
|
||||
|
||||
it('skips lazily-expanded mcp_all placeholders (exact-name matching would never apply)', () => {
|
||||
const placeholder = `${Constants.mcp_all}${Constants.mcp_delimiter}overlay_server`;
|
||||
const options = synthesizeIntentToolOptions([placeholder, 'web_search'], {
|
||||
ephemeralAgent: { describe_intent: true },
|
||||
});
|
||||
expect(options).toEqual({ web_search: { describe_intent: true } });
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is eligible', () => {
|
||||
expect(
|
||||
synthesizeIntentToolOptions([CHECK_BACKGROUND_TASK_NAME], {
|
||||
modelSpec: { describeIntent: true },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSynthesizedToolOptions', () => {
|
||||
it('merges per-tool entries without dropping sibling keys', () => {
|
||||
const merged = mergeSynthesizedToolOptions(
|
||||
{ web_search: { run_in_background: true }, other: { defer_loading: true } },
|
||||
{ web_search: { describe_intent: true } },
|
||||
);
|
||||
expect(merged).toEqual({
|
||||
web_search: { run_in_background: true, describe_intent: true },
|
||||
other: { defer_loading: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through when either side is absent', () => {
|
||||
const base = { web_search: { run_in_background: true } };
|
||||
expect(mergeSynthesizedToolOptions(base, undefined)).toBe(base);
|
||||
const extra = { web_search: { describe_intent: true } };
|
||||
expect(mergeSynthesizedToolOptions(undefined, extra)).toBe(extra);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolOptionsSchema', () => {
|
||||
it('preserves describe_intent alongside the existing options', () => {
|
||||
const parsed = toolOptionsSchema.parse({
|
||||
defer_loading: true,
|
||||
run_in_background: true,
|
||||
describe_intent: true,
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
defer_loading: true,
|
||||
run_in_background: true,
|
||||
describe_intent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips unknown keys but keeps describe_intent', () => {
|
||||
const parsed = toolOptionsSchema.parse({ describe_intent: false, bogus: 1 });
|
||||
expect(parsed).toEqual({ describe_intent: false });
|
||||
});
|
||||
});
|
||||
482
packages/api/src/agents/intent.ts
Normal file
482
packages/api/src/agents/intent.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
/**
|
||||
* @fileoverview Tool intent labels.
|
||||
*
|
||||
* Injects an optional `intent` string as the FIRST property of a tool's
|
||||
* schema so the model can declare, per call, one sentence stating what that
|
||||
* specific call is about to do ("Searching for OAuth handling in the
|
||||
* callback router"). Because the property is first, it is the first key
|
||||
* providers stream in the tool-call args, and the client renders it as the
|
||||
* call's live status label — the args already reach the client verbatim, so
|
||||
* no new event plumbing is involved. The label is inert server-side: the
|
||||
* only interception is stripping the arg before invoking a tool that did
|
||||
* not declare it.
|
||||
*
|
||||
* Opt-in mirrors `run_in_background`: an admin capability
|
||||
* (`AgentCapabilities.tool_intents`) gates the feature, and a per-tool
|
||||
* `tool_options[name].describe_intent` flag turns it on for a given tool.
|
||||
* Native host tools (web search, file authoring, memory, ask-user-question)
|
||||
* default ON while the capability is enabled — an explicit
|
||||
* `describe_intent: false` opts one out. SDK-native tools (the coding
|
||||
* suite, subagent, skill, tool_search) declare `intent` in their own
|
||||
* schemas and need no host injection.
|
||||
*
|
||||
* @module packages/api/src/agents/intent
|
||||
*/
|
||||
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { Tools, Constants } from 'librechat-data-provider';
|
||||
import {
|
||||
Constants as AgentConstants,
|
||||
INTENT_LABEL_MARKER,
|
||||
INTENT_DESCRIPTION,
|
||||
} from '@librechat/agents';
|
||||
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 { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from './tools';
|
||||
|
||||
/** Argument carrying the model-authored label for a tool call. */
|
||||
export const INTENT_ARG = 'intent';
|
||||
|
||||
/**
|
||||
* Host-native tools that default INTO intent labels while the capability is
|
||||
* enabled (an explicit `describe_intent: false` opts one out). These are the
|
||||
* least legible calls in the UI today, and the convention only becomes a
|
||||
* convention if our own tools model it.
|
||||
*
|
||||
* `ask_user_question` is deliberately absent: its graph tool is rebuilt in
|
||||
* `run.ts` from its own Zod schema (which is also the HITL card's wire
|
||||
* shape), so definition-level injection never reaches the model. Its intent
|
||||
* support lands with the HITL slice, which threads the label into the
|
||||
* interrupt payload on purpose.
|
||||
*/
|
||||
export const NATIVE_INTENT_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
|
||||
Tools.web_search,
|
||||
CREATE_FILE_TOOL_NAME,
|
||||
EDIT_FILE_TOOL_NAME,
|
||||
SET_MEMORY_TOOL_NAME,
|
||||
DELETE_MEMORY_TOOL_NAME,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Tools that never get the injected param: the background poll tool is host
|
||||
* machinery, and handoff tools run through the direct path where no card
|
||||
* renders a label. Intent labels are otherwise inert, so — unlike
|
||||
* background's correctness-driven list — nothing else is excluded.
|
||||
*/
|
||||
const EXCLUDED_INTENT_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
|
||||
String(Constants.CHECK_BACKGROUND_TASK),
|
||||
]);
|
||||
|
||||
/** Whether a tool may carry an intent label. */
|
||||
export function isIntentEligibleToolName(name: string): boolean {
|
||||
if (EXCLUDED_INTENT_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 != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether tool-call args carry the `intent` key at all (any value). */
|
||||
export function hasIntentArg(args: unknown): boolean {
|
||||
const obj = coerceArgsObject(args);
|
||||
return obj != null && INTENT_ARG in obj;
|
||||
}
|
||||
|
||||
/** Reads the model-authored intent from tool-call args (handles stringified args). */
|
||||
export function readIntentArg(args: unknown): string | undefined {
|
||||
const value = coerceArgsObject(args)?.[INTENT_ARG];
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the args without the injected `intent` key so a tool that did not
|
||||
* declare the parameter never receives it. Parses stringified JSON object
|
||||
* args; returns the value unchanged when the key is absent.
|
||||
*/
|
||||
export function stripIntentArg(args: unknown): unknown {
|
||||
const obj = coerceArgsObject(args);
|
||||
if (!obj || !(INTENT_ARG in obj)) {
|
||||
return args;
|
||||
}
|
||||
const { [INTENT_ARG]: _omit, ...rest } = obj;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical (frozen) shape of the injected property. Injection embeds a
|
||||
* copy so downstream schema tooling that mutates subschemas (JSON-schema
|
||||
* dereferencers stamp URI markers) never trips on a frozen shared instance.
|
||||
*
|
||||
* The description is the SDK's, not a local copy: host-injected tools and
|
||||
* SDK-native tools must present the model with one identical instruction, and
|
||||
* a divergent copy would also miss the SDK's token trimming.
|
||||
*/
|
||||
const INTENT_PROPERTY: JsonSchemaType = Object.freeze<JsonSchemaType>({
|
||||
type: 'string',
|
||||
description: INTENT_DESCRIPTION,
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a copy of the tool definition with `intent` PREPENDED as the first
|
||||
* property of its parameters — first key in the schema means first key in
|
||||
* the streamed input, which is what lets the label render before the rest of
|
||||
* the args exist. Never mutates the input (built-in defs are frozen and MCP
|
||||
* defs may be shared); no-op if the property already exists. Never added to
|
||||
* `required`.
|
||||
*/
|
||||
export function injectIntentParam(def: LCTool): LCTool {
|
||||
const params = def.parameters;
|
||||
const existingProps = params?.properties ?? {};
|
||||
if (INTENT_ARG in existingProps) {
|
||||
return def;
|
||||
}
|
||||
const nextParams: JsonSchemaType = {
|
||||
...params,
|
||||
type: 'object',
|
||||
properties: { [INTENT_ARG]: { ...INTENT_PROPERTY }, ...existingProps },
|
||||
};
|
||||
return { ...def, parameters: nextParams };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the `intent` 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 definitions whose
|
||||
* `allowed_callers` never includes `direct` (no card ever renders for a
|
||||
* PTC-only tool, so the label would be pure token cost).
|
||||
*/
|
||||
function canInjectIntentParam(def: LCTool): boolean {
|
||||
const callers = def.allowed_callers;
|
||||
if (callers != null && !callers.includes('direct')) {
|
||||
return false;
|
||||
}
|
||||
const params = def.parameters;
|
||||
if (params == null) {
|
||||
return true;
|
||||
}
|
||||
return params.type == null || params.type === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminates the intent LABEL property (host-injected here, or SDK-native
|
||||
* from `@librechat/agents`) from a tool's own business parameter that merely
|
||||
* shares the name. Both contracts open with this exact instruction, while an
|
||||
* MCP/action tool's real `intent` argument will not — removal paths must
|
||||
* never strip a parameter the tool actually needs.
|
||||
*
|
||||
* Imported rather than redeclared: a local copy that drifts from the SDK's
|
||||
* would make every removal path here stop recognizing SDK-native labels, and
|
||||
* it would fail OPEN — labels left in schemas, opt-outs silently inert, no
|
||||
* error anywhere.
|
||||
*/
|
||||
function isIntentLabelProperty(property: JsonSchemaType | undefined): boolean {
|
||||
return (
|
||||
property != null &&
|
||||
property.type === 'string' &&
|
||||
typeof property.description === 'string' &&
|
||||
property.description.startsWith(INTENT_LABEL_MARKER)
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a copy of the def without the intent LABEL property (marker-guarded). */
|
||||
function removeIntentParam(def: LCTool): LCTool {
|
||||
const params = def.parameters;
|
||||
if (params?.properties == null || !isIntentLabelProperty(params.properties[INTENT_ARG])) {
|
||||
return def;
|
||||
}
|
||||
const { [INTENT_ARG]: _omit, ...restProps } = params.properties;
|
||||
return { ...def, parameters: { ...params, properties: restProps } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the host-injected `intent` param from a tool-definition list. Used
|
||||
* to sanitize a self-spawn subagent's inherited inputs so the isolated child
|
||||
* path doesn't advertise a schema the parent injected. Only the named
|
||||
* (host-injected) tools are touched — SDK-native intent schemas are the
|
||||
* tool's own and stay.
|
||||
*/
|
||||
export function stripIntentFromToolDefinitions(
|
||||
toolDefinitions: LCTool[] | undefined,
|
||||
intentToolNames: string[] | undefined,
|
||||
): LCTool[] {
|
||||
const defs = toolDefinitions ?? [];
|
||||
const intentSet = new Set(intentToolNames ?? []);
|
||||
if (intentSet.size === 0) {
|
||||
return defs;
|
||||
}
|
||||
let changed = false;
|
||||
const next = defs.map((def) => {
|
||||
if (!intentSet.has(def.name)) {
|
||||
return def;
|
||||
}
|
||||
const stripped = removeIntentParam(def);
|
||||
if (stripped !== def) {
|
||||
changed = true;
|
||||
}
|
||||
return stripped;
|
||||
});
|
||||
return changed ? next : defs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker-guarded removal of intent LABELS from every definition —
|
||||
* host-injected AND SDK-native alike. Used for the schemas the PTC sandbox
|
||||
* bridge advertises: no card renders for an inner call, so any label there
|
||||
* is pure token cost for the generating model. Business `intent` params
|
||||
* survive (marker guard).
|
||||
*/
|
||||
export function stripIntentLabelsFromToolDefinitions(
|
||||
toolDefinitions: LCTool[] | undefined,
|
||||
): LCTool[] {
|
||||
const defs = toolDefinitions ?? [];
|
||||
let changed = false;
|
||||
const next = defs.map((def) => {
|
||||
const stripped = removeIntentParam(def);
|
||||
if (stripped !== def) {
|
||||
changed = true;
|
||||
}
|
||||
return stripped;
|
||||
});
|
||||
return changed ? next : defs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry counterpart of {@link stripIntentFromToolDefinitions}. Returns a
|
||||
* NEW registry (never mutates the shared parent one) with the injected param
|
||||
* removed, so a self-spawn child that uses tool_search/deferred loading
|
||||
* can't rediscover a host-injected schema it can't honor.
|
||||
*/
|
||||
export function stripIntentFromToolRegistry(
|
||||
toolRegistry: LCToolRegistry | undefined,
|
||||
intentToolNames: string[] | undefined,
|
||||
): LCToolRegistry | undefined {
|
||||
if (!toolRegistry) {
|
||||
return toolRegistry;
|
||||
}
|
||||
const intentSet = new Set(intentToolNames ?? []);
|
||||
if (intentSet.size === 0) {
|
||||
return toolRegistry;
|
||||
}
|
||||
const next: LCToolRegistry = new Map();
|
||||
for (const [name, def] of toolRegistry) {
|
||||
next.set(name, intentSet.has(name) ? removeIntentParam(def) : def);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tool is opted into intent labels: an explicit per-tool
|
||||
* `describe_intent` wins; native host tools default on.
|
||||
*/
|
||||
function isIntentOptedIn(name: string, toolOptions?: AgentToolOptions): boolean {
|
||||
const explicit = toolOptions?.[name]?.describe_intent;
|
||||
if (explicit != null) {
|
||||
return explicit === true;
|
||||
}
|
||||
return NATIVE_INTENT_TOOL_NAMES.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the `intent` param into every opted-in, eligible tool definition,
|
||||
* mirroring the injection into the registry entry so a deferred tool
|
||||
* discovered later (tool_search reads the registry) arrives with the same
|
||||
* schema. Definitions that already declare `intent` (SDK-native tools) are
|
||||
* left alone and NOT counted as host-injected — their schema is their own —
|
||||
* unless the tool is explicitly opted OUT (`describe_intent: false`), in
|
||||
* which case the property is removed so the opt-out actually disables the
|
||||
* arg's token cost (the SDK tool bodies tolerate its absence).
|
||||
*
|
||||
* Both saved agents and ephemeral/model-spec agents reach this with
|
||||
* `tool_options` populated, so the logic is written once.
|
||||
*/
|
||||
export function applyIntentLabels(params: {
|
||||
toolDefinitions: LCTool[] | undefined;
|
||||
toolRegistry: LCToolRegistry | undefined;
|
||||
toolOptions: AgentToolOptions | undefined;
|
||||
/** Extra host-context exclusion, mirroring `applyBackgroundToolCalls`. */
|
||||
excludeTool?: (toolName: string) => boolean;
|
||||
}): { toolDefinitions: LCTool[]; intentToolNames: string[] } {
|
||||
const { toolRegistry, toolOptions, excludeTool } = params;
|
||||
const defs = params.toolDefinitions ?? [];
|
||||
|
||||
let changed = false;
|
||||
const intentToolNames: string[] = [];
|
||||
const mirrorRegistryEntry = (def: LCTool): void => {
|
||||
const registryEntry = toolRegistry?.get(def.name);
|
||||
if (registryEntry) {
|
||||
toolRegistry?.set(def.name, { ...registryEntry, parameters: def.parameters });
|
||||
}
|
||||
};
|
||||
const nextDefs = defs.map((def) => {
|
||||
if (toolOptions?.[def.name]?.describe_intent === false) {
|
||||
const stripped = removeIntentParam(def);
|
||||
if (stripped !== def) {
|
||||
changed = true;
|
||||
mirrorRegistryEntry(stripped);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
if (!isIntentOptedIn(def.name, toolOptions)) {
|
||||
return def;
|
||||
}
|
||||
if (!isIntentEligibleToolName(def.name) || excludeTool?.(def.name) === true) {
|
||||
return def;
|
||||
}
|
||||
if (!canInjectIntentParam(def)) {
|
||||
if (def.allowed_callers == null || def.allowed_callers.includes('direct')) {
|
||||
logger.warn(
|
||||
`[intent] Skipping describe_intent for "${def.name}": non-object schema cannot carry the injected parameter.`,
|
||||
);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
const injected = injectIntentParam(def);
|
||||
if (injected === def) {
|
||||
return def;
|
||||
}
|
||||
changed = true;
|
||||
intentToolNames.push(def.name);
|
||||
mirrorRegistryEntry(injected);
|
||||
return injected;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return { toolDefinitions: defs, intentToolNames };
|
||||
}
|
||||
return { toolDefinitions: nextDefs, intentToolNames };
|
||||
}
|
||||
|
||||
const MCP_ALL_PLACEHOLDER_PREFIX = `${Constants.mcp_all}${Constants.mcp_delimiter}`;
|
||||
|
||||
/**
|
||||
* Post-registration sanitize pass, run AFTER every tool registration step —
|
||||
* including the skill catalog, which appends its definition after the
|
||||
* injection pass. Removes intent LABEL properties that must not be
|
||||
* advertised: every one when the capability is disabled (the admin kill
|
||||
* switch over SDK-native schemas, which otherwise pay the token cost with
|
||||
* the feature off), or the explicitly opted-out ones when it is enabled.
|
||||
* Marker-guarded, so a tool's own `intent` business parameter survives.
|
||||
*/
|
||||
export function sanitizeIntentLabels(params: {
|
||||
toolDefinitions: LCTool[] | undefined;
|
||||
toolRegistry: LCToolRegistry | undefined;
|
||||
toolOptions: AgentToolOptions | undefined;
|
||||
capabilityEnabled: boolean;
|
||||
}): { toolDefinitions: LCTool[] } {
|
||||
const { toolRegistry, toolOptions, capabilityEnabled } = params;
|
||||
const defs = params.toolDefinitions ?? [];
|
||||
const shouldStrip = (name: string): boolean =>
|
||||
capabilityEnabled ? toolOptions?.[name]?.describe_intent === false : true;
|
||||
|
||||
let changed = false;
|
||||
const nextDefs = defs.map((def) => {
|
||||
if (!shouldStrip(def.name)) {
|
||||
return def;
|
||||
}
|
||||
const stripped = removeIntentParam(def);
|
||||
if (stripped !== def) {
|
||||
changed = true;
|
||||
const registryEntry = toolRegistry?.get(def.name);
|
||||
if (registryEntry) {
|
||||
toolRegistry?.set(def.name, { ...registryEntry, parameters: stripped.parameters });
|
||||
}
|
||||
}
|
||||
return stripped;
|
||||
});
|
||||
if (toolRegistry) {
|
||||
for (const [name, entry] of toolRegistry) {
|
||||
if (!shouldStrip(name)) {
|
||||
continue;
|
||||
}
|
||||
const stripped = removeIntentParam(entry);
|
||||
if (stripped !== entry) {
|
||||
toolRegistry.set(name, stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { toolDefinitions: changed ? nextDefs : defs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds `tool_options` marking each eligible tool as intent-describing for
|
||||
* ephemeral and model-spec agents, which carry no per-tool options of their
|
||||
* own. 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 —
|
||||
* `applyIntentLabels` matches expanded tool names exactly, so an option
|
||||
* recorded under the placeholder would silently never apply. Those entries
|
||||
* are skipped rather than synthesized dead; standard cached MCP servers push
|
||||
* real names and are covered. Mirrors `synthesizeBackgroundToolOptions`.
|
||||
*/
|
||||
export function synthesizeIntentToolOptions(
|
||||
tools: string[],
|
||||
sources: {
|
||||
ephemeralAgent?: { describe_intent?: boolean } | null;
|
||||
modelSpec?: { describeIntent?: boolean } | null;
|
||||
},
|
||||
): AgentToolOptions | undefined {
|
||||
const enabled =
|
||||
sources.ephemeralAgent?.describe_intent === true || sources.modelSpec?.describeIntent === true;
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const toolOptions: AgentToolOptions = {};
|
||||
for (const name of tools) {
|
||||
if (name.startsWith(MCP_ALL_PLACEHOLDER_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
if (isIntentEligibleToolName(name)) {
|
||||
toolOptions[name] = { describe_intent: true };
|
||||
}
|
||||
}
|
||||
return Object.keys(toolOptions).length > 0 ? toolOptions : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merges two synthesized `tool_options` maps per tool key, so the
|
||||
* ephemeral background and intent toggles compose instead of overwriting
|
||||
* each other's per-tool entries.
|
||||
*/
|
||||
export function mergeSynthesizedToolOptions(
|
||||
base: AgentToolOptions | undefined,
|
||||
extra: AgentToolOptions | undefined,
|
||||
): AgentToolOptions | undefined {
|
||||
if (!extra) {
|
||||
return base;
|
||||
}
|
||||
if (!base) {
|
||||
return extra;
|
||||
}
|
||||
const merged: AgentToolOptions = { ...base };
|
||||
for (const [name, options] of Object.entries(extra)) {
|
||||
merged[name] = { ...merged[name], ...options };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
Agent,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { synthesizeIntentToolOptions, mergeSynthesizedToolOptions } from '~/agents/intent';
|
||||
import { ASK_USER_QUESTION_TOOL_NAME } from '~/agents/hitl/askUserQuestionTool';
|
||||
import { synthesizeBackgroundToolOptions } from '~/agents/background';
|
||||
import { requiresEphemeralUserConnection } from '~/mcp/utils';
|
||||
|
|
@ -158,6 +159,13 @@ export async function loadEphemeralAgent(
|
|||
if (backgroundToolOptions) {
|
||||
result.tool_options = backgroundToolOptions;
|
||||
}
|
||||
const intentToolOptions: AgentToolOptions | undefined = synthesizeIntentToolOptions(tools, {
|
||||
ephemeralAgent,
|
||||
modelSpec,
|
||||
});
|
||||
if (intentToolOptions) {
|
||||
result.tool_options = mergeSynthesizedToolOptions(result.tool_options, intentToolOptions);
|
||||
}
|
||||
|
||||
if (ephemeralAgent?.artifacts) {
|
||||
result.artifacts = ephemeralAgent.artifacts;
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ interface InitializedAgent {
|
|||
toolContextMap: Record<string, unknown>;
|
||||
maxContextTokens: number;
|
||||
userMCPAuthMap?: Record<string, Record<string, string>>;
|
||||
/** Names of tools with the host-injected `intent` label param (see `agents/intent.ts`). */
|
||||
intentToolNames?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +162,18 @@ interface InitializeAgentParams {
|
|||
* absent / `undefined` disables background tool calls on this route.
|
||||
*/
|
||||
backgroundToolsAvailable?: boolean;
|
||||
/**
|
||||
* Whether the admin-level `tool_intents` capability is enabled. Gates
|
||||
* `applyIntentLabels` in `initializeAgent` (the injected `intent` label
|
||||
* param); absent / `undefined` disables intent labels on this route.
|
||||
*
|
||||
* Boundary: injection and the capability-off sanitize operate on the
|
||||
* `toolDefinitions`/`toolRegistry` surfaces. A custom `LoadToolsFn` that
|
||||
* returns only structured tool INSTANCES bypasses both — such loaders
|
||||
* must provide definition/registry surfaces to participate in intent
|
||||
* labels (matching how the in-repo tool loader behaves).
|
||||
*/
|
||||
toolIntentsAvailable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -467,6 +481,8 @@ export async function createAgentChatCompletion(
|
|||
* 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);
|
||||
/** Same gate for the injected `intent` label param. */
|
||||
const toolIntentsAvailable = capabilityEnabled(AgentCapabilities.tool_intents);
|
||||
|
||||
// Initialize the agent first to check for disableStreaming
|
||||
const initializedAgent = await deps.initializeAgent({
|
||||
|
|
@ -485,6 +501,7 @@ export async function createAgentChatCompletion(
|
|||
codeEnvAvailable,
|
||||
statefulSessionsAvailable,
|
||||
backgroundToolsAvailable,
|
||||
toolIntentsAvailable,
|
||||
});
|
||||
|
||||
// Determine if streaming is enabled (check both request and agent config)
|
||||
|
|
@ -571,6 +588,13 @@ export async function createAgentChatCompletion(
|
|||
thread_id: conversationId,
|
||||
user_id: userId,
|
||||
user: safeUser,
|
||||
/** Same per-agent channel the in-repo controllers thread via
|
||||
* `loadTools`: without it, the executor's PTC path cannot
|
||||
* strip host-injected `intent` params from the schemas the
|
||||
* sandbox bridge advertises on this route. */
|
||||
...(initializedAgent.intentToolNames?.length
|
||||
? { intentToolNames: initializedAgent.intentToolNames }
|
||||
: {}),
|
||||
},
|
||||
signal: abortController.signal,
|
||||
streamMode: 'values',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
} from '~/agents/hitl/askUserQuestionTool';
|
||||
import { resolveToolApprovalPolicy, exemptAskUserQuestionFromApproval } from '~/agents/hitl/policy';
|
||||
import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPromptKeyCompatibility';
|
||||
import { stripIntentFromToolRegistry, stripIntentFromToolDefinitions } from '~/agents/intent';
|
||||
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
|
||||
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools';
|
||||
import { getProviderConfig } from '~/endpoints/config/providers';
|
||||
|
|
@ -342,6 +343,8 @@ type RunAgent = Omit<Agent, 'tools'> & {
|
|||
hasDeferredTools?: boolean;
|
||||
/** Names of tools injected with the `run_in_background` param (excluded from eager execution). */
|
||||
backgroundToolNames?: string[];
|
||||
/** Names of tools with the host-injected `intent` param (stripped from self-spawn inputs). */
|
||||
intentToolNames?: string[];
|
||||
/**
|
||||
* Per-agent codeenv gate set by `initializeAgent`: admin-level
|
||||
* `execute_code` capability AND the agent actually requested
|
||||
|
|
@ -883,13 +886,16 @@ function buildSubagentConfigs(
|
|||
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
|
||||
* Self-spawn reuses the parent's AgentInputs. When the parent has
|
||||
* background or host-injected intent tools, provide a sanitized copy so
|
||||
* the isolated child — which runs the direct/child-graph path rather
|
||||
* than the host interceptors — doesn't advertise `run_in_background` /
|
||||
* `check_background_task` or an injected `intent` param its direct tool
|
||||
* invocations would forward to tools that never declared it. The
|
||||
* resolver keeps a provided `agentInputs` even with `self: true`.
|
||||
*/
|
||||
const hasBackground = (agent.backgroundToolNames?.length ?? 0) > 0;
|
||||
const hasInjectedIntent = (agent.intentToolNames?.length ?? 0) > 0;
|
||||
configs.push({
|
||||
self: true,
|
||||
type: SELF_SUBAGENT_TYPE,
|
||||
|
|
@ -897,17 +903,20 @@ 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
|
||||
...(hasBackground || hasInjectedIntent
|
||||
? {
|
||||
agentInputs: {
|
||||
...agentInput,
|
||||
toolDefinitions: stripBackgroundFromToolDefinitions(
|
||||
agentInput.toolDefinitions,
|
||||
agent.backgroundToolNames,
|
||||
toolDefinitions: stripIntentFromToolDefinitions(
|
||||
stripBackgroundFromToolDefinitions(
|
||||
agentInput.toolDefinitions,
|
||||
agent.backgroundToolNames,
|
||||
),
|
||||
agent.intentToolNames,
|
||||
),
|
||||
toolRegistry: stripBackgroundFromToolRegistry(
|
||||
agentInput.toolRegistry,
|
||||
agent.backgroundToolNames,
|
||||
toolRegistry: stripIntentFromToolRegistry(
|
||||
stripBackgroundFromToolRegistry(agentInput.toolRegistry, agent.backgroundToolNames),
|
||||
agent.intentToolNames,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
@ -965,6 +974,18 @@ function buildSubagentConfigs(
|
|||
child.backgroundToolNames,
|
||||
);
|
||||
}
|
||||
/** Same sanitization for the host-injected `intent` param (see the
|
||||
* self-spawn path above). */
|
||||
if ((child.intentToolNames?.length ?? 0) > 0) {
|
||||
childInputs.toolDefinitions = stripIntentFromToolDefinitions(
|
||||
childInputs.toolDefinitions,
|
||||
child.intentToolNames,
|
||||
);
|
||||
childInputs.toolRegistry = stripIntentFromToolRegistry(
|
||||
childInputs.toolRegistry,
|
||||
child.intentToolNames,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Recursively resolve the child's own spawn targets so multi-level
|
||||
* delegation (A → B → C) works. Without this, a child whose own
|
||||
|
|
|
|||
|
|
@ -289,18 +289,20 @@ export const graphEdgeSchema: z.ZodObject<
|
|||
.transform((v) => (v === '' ? undefined : v)),
|
||||
});
|
||||
|
||||
/** Per-tool options schema (defer_loading, allowed_callers, run_in_background) */
|
||||
/** Per-tool options schema (defer_loading, allowed_callers, run_in_background, describe_intent) */
|
||||
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>;
|
||||
describe_intent: 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(),
|
||||
describe_intent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Agent tool options - map of tool_id to tool options */
|
||||
|
|
@ -312,6 +314,7 @@ 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>;
|
||||
describe_intent: z.ZodOptional<z.ZodBoolean>;
|
||||
},
|
||||
'strip',
|
||||
z.ZodTypeAny,
|
||||
|
|
@ -319,11 +322,13 @@ export const agentToolOptionsSchema: z.ZodOptional<
|
|||
defer_loading?: boolean | undefined;
|
||||
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
|
||||
run_in_background?: boolean | undefined;
|
||||
describe_intent?: boolean | undefined;
|
||||
},
|
||||
{
|
||||
defer_loading?: boolean | undefined;
|
||||
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
|
||||
run_in_background?: boolean | undefined;
|
||||
describe_intent?: boolean | undefined;
|
||||
}
|
||||
>
|
||||
>
|
||||
|
|
@ -631,6 +636,7 @@ export const agentBaseSchema: z.ZodObject<
|
|||
z.ZodArray<z.ZodEnum<['direct', 'code_execution']>, 'many'>
|
||||
>;
|
||||
run_in_background: z.ZodOptional<z.ZodBoolean>;
|
||||
describe_intent: z.ZodOptional<z.ZodBoolean>;
|
||||
},
|
||||
'strip',
|
||||
z.ZodTypeAny,
|
||||
|
|
@ -638,11 +644,13 @@ export const agentBaseSchema: z.ZodObject<
|
|||
defer_loading?: boolean | undefined;
|
||||
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
|
||||
run_in_background?: boolean | undefined;
|
||||
describe_intent?: boolean | undefined;
|
||||
},
|
||||
{
|
||||
defer_loading?: boolean | undefined;
|
||||
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
|
||||
run_in_background?: boolean | undefined;
|
||||
describe_intent?: boolean | undefined;
|
||||
}
|
||||
>
|
||||
>
|
||||
|
|
@ -981,6 +989,7 @@ export const agentCreateSchema: z.ZodObject<
|
|||
z.ZodArray<z.ZodEnum<['direct', 'code_execution']>, 'many'>
|
||||
>;
|
||||
run_in_background: z.ZodOptional<z.ZodBoolean>;
|
||||
describe_intent: z.ZodOptional<z.ZodBoolean>;
|
||||
},
|
||||
'strip',
|
||||
z.ZodTypeAny,
|
||||
|
|
@ -988,11 +997,13 @@ export const agentCreateSchema: z.ZodObject<
|
|||
defer_loading?: boolean | undefined;
|
||||
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
|
||||
run_in_background?: boolean | undefined;
|
||||
describe_intent?: boolean | undefined;
|
||||
},
|
||||
{
|
||||
defer_loading?: boolean | undefined;
|
||||
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
|
||||
run_in_background?: boolean | undefined;
|
||||
describe_intent?: boolean | undefined;
|
||||
}
|
||||
>
|
||||
>
|
||||
|
|
@ -1296,6 +1307,7 @@ export const agentUpdateSchema: z.ZodObject<
|
|||
z.ZodArray<z.ZodEnum<['direct', 'code_execution']>, 'many'>
|
||||
>;
|
||||
run_in_background: z.ZodOptional<z.ZodBoolean>;
|
||||
describe_intent: z.ZodOptional<z.ZodBoolean>;
|
||||
},
|
||||
'strip',
|
||||
z.ZodTypeAny,
|
||||
|
|
@ -1303,11 +1315,13 @@ export const agentUpdateSchema: z.ZodObject<
|
|||
defer_loading?: boolean | undefined;
|
||||
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
|
||||
run_in_background?: boolean | undefined;
|
||||
describe_intent?: boolean | undefined;
|
||||
},
|
||||
{
|
||||
defer_loading?: boolean | undefined;
|
||||
allowed_callers?: ('direct' | 'code_execution')[] | undefined;
|
||||
run_in_background?: boolean | undefined;
|
||||
describe_intent?: boolean | undefined;
|
||||
}
|
||||
>
|
||||
>
|
||||
|
|
|
|||
|
|
@ -579,6 +579,7 @@ export enum AgentCapabilities {
|
|||
chain = 'chain',
|
||||
ocr = 'ocr',
|
||||
run_in_background = 'run_in_background',
|
||||
tool_intents = 'tool_intents',
|
||||
}
|
||||
|
||||
export const defaultAssistantsVersion = {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ export type TModelSpec = {
|
|||
* agent capability to be enabled by the admin.
|
||||
*/
|
||||
runInBackground?: boolean;
|
||||
/**
|
||||
* Inject the `intent` label param into this spec's eligible tools so each
|
||||
* call streams a live status label. Requires the `tool_intents` agent
|
||||
* capability to be enabled by the admin.
|
||||
*/
|
||||
describeIntent?: boolean;
|
||||
artifacts?: string | boolean;
|
||||
mcpServers?: string[];
|
||||
skills?: boolean | string[];
|
||||
|
|
@ -97,6 +103,7 @@ export const tModelSpecSchema = z.object({
|
|||
memory: z.boolean().optional(),
|
||||
askUserQuestion: z.boolean().optional(),
|
||||
runInBackground: z.boolean().optional(),
|
||||
describeIntent: 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(),
|
||||
|
|
|
|||
|
|
@ -117,6 +117,12 @@ export type TEphemeralAgent = {
|
|||
* `run_in_background` agent capability to be enabled by the admin.
|
||||
*/
|
||||
run_in_background?: boolean;
|
||||
/**
|
||||
* Inject the `intent` label param into this ephemeral agent's eligible
|
||||
* tools so each call streams a live status label. Requires the
|
||||
* `tool_intents` agent capability to be enabled by the admin.
|
||||
*/
|
||||
describe_intent?: boolean;
|
||||
};
|
||||
|
||||
export type TPayload = Partial<TMessage> &
|
||||
|
|
|
|||
|
|
@ -245,6 +245,14 @@ export type ToolOptions = {
|
|||
* @default false
|
||||
*/
|
||||
run_in_background?: boolean;
|
||||
/**
|
||||
* If true (and the `tool_intents` capability is enabled), the tool's schema
|
||||
* gains an `intent` string as its FIRST property — one model-authored
|
||||
* sentence per call, rendered as the call's live status label. Native host
|
||||
* tools default on while the capability is enabled; `false` opts one out.
|
||||
* @default false
|
||||
*/
|
||||
describe_intent?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ const agentSchema: Schema<IAgent> = new Schema<IAgent>(
|
|||
default: [],
|
||||
index: true,
|
||||
},
|
||||
/** Per-tool configuration (defer_loading, allowed_callers) */
|
||||
/** Per-tool configuration (defer_loading, allowed_callers, run_in_background, describe_intent) */
|
||||
tool_options: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: undefined,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export interface IAgent extends Omit<Document, 'model'> {
|
|||
is_promoted?: boolean;
|
||||
/** MCP server names extracted from tools for efficient querying */
|
||||
mcpServerNames?: string[];
|
||||
/** Per-tool configuration (defer_loading, allowed_callers) */
|
||||
/** Per-tool configuration (defer_loading, allowed_callers, run_in_background, describe_intent) */
|
||||
tool_options?: AgentToolOptions;
|
||||
/** Subagent spawning configuration — isolated-context child agents. */
|
||||
subagents?: AgentSubagentsConfig;
|
||||
|
|
|
|||
691
tool-intent-spec.md
Normal file
691
tool-intent-spec.md
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
# Feature Spec: Tool Intent & Outcome Labels
|
||||
|
||||
**Status:** proposal, ready for implementation by someone with no prior context
|
||||
**Repos:** `danny-avila/agents` (published as `@librechat/agents`, local path `~/agentus`), `danny-avila/LibreChat`
|
||||
|
||||
**Verified dependency landscape (2026-07-28):**
|
||||
|
||||
| Dependency | State | What it means for this spec |
|
||||
|---|---|---|
|
||||
| Background capability core (`packages/api/src/agents/background.ts` + `background.spec.ts`) | **merged in LibreChat main** | the reference implementation exists today; clone it freely |
|
||||
| [#14407](https://github.com/danny-avila/LibreChat/pull/14407) — Background Execution Toggles for Actions & Plugin Tools | **open** | this is the "bg tools open PR"; only slice 5 (builder toggles) sequences after it |
|
||||
| Activity labels, SDK side (`Run.generateActivityLabel`, `src/prompts/activityLabel.ts`, `src/types/activityLabel.ts`, `ACTIVITY_LABEL_PROMPT`) | **merged & published** in `@librechat/agents@3.3.0` ([danny-avila/agents#327](https://github.com/danny-avila/agents/pull/327)) | treat as existing API, build on it directly |
|
||||
| [#14391](https://github.com/danny-avila/LibreChat/pull/14391) — Activity Groups With Fast-Model Headers (LibreChat host side) | **open, near merge** | reshapes `ToolCallGroup` grouping and header precedence; §5.2, §9.2, and §10.10 assume it lands first and specify the composition explicitly |
|
||||
|
||||
---
|
||||
|
||||
## 1. Intent of the feature
|
||||
|
||||
Tool calls currently render with mechanical, provider-derived labels. The card tells you which tool ran and where. It never tells you why. Two `search_code` calls in a single turn are indistinguishable: the group header reads `Used 2 tools — github` and both rows read `Ran search_code in github`. The user cannot tell that one call searched for MCP wiring and the other searched for OAuth handling. The information exists, buried in the args behind a disclosure triangle, and the chrome throws it away.
|
||||
|
||||
The fix: let the model declare, as the **first argument of every tool call**, a short natural-language statement of what that specific call is attempting. That string becomes the label for that call's card, streaming into place as the provider streams the tool input. When the call settles, the label is edited in place into an outcome form.
|
||||
|
||||
Before and after, using the two-`search_code` case as the reference:
|
||||
|
||||
```
|
||||
BEFORE
|
||||
⌄ Used 2 tools — github
|
||||
Ran search_code in github ⌄
|
||||
Ran search_code in github ⌄
|
||||
|
||||
AFTER (streaming, second call in flight)
|
||||
⌄ Searching for OAuth handling (shimmering)
|
||||
Searched for MCP wiring ⌄
|
||||
Searching for OAuth handling ⌄ (shimmering)
|
||||
|
||||
AFTER (settled)
|
||||
⌄ Searched for OAuth handling — github
|
||||
Searched for MCP wiring ⌄
|
||||
Searched for OAuth handling ⌄
|
||||
```
|
||||
|
||||
Four design commitments that shape everything below:
|
||||
|
||||
1. **This is a tool capability, not a feature.** It is the fourth member of an existing family (`defer_loading`, `allowed_callers`, `run_in_background`) and must be built to that family's established shape, so the pattern becomes a documented convention rather than four one-offs. Future capabilities should be able to point at this one and copy it.
|
||||
2. **Flexibility over prescription.** The field is a free-form sentence and the outcome is an in-place edit of any span of that sentence. We deliberately do not enumerate verbs or categories. We want to see what people and models produce, then tighten later.
|
||||
3. **Native tools opt in by default.** The convention only becomes a convention if our own tools model it: web search, the entire coding suite across every engine, subagents, memory, and file authoring.
|
||||
4. **It must compose.** A tool can be deferred, programmatic, backgroundable, and intent-describing simultaneously, and the whole system must layer cleanly under the activity-group headers landing in #14391. Section 10 specifies every one of those interactions.
|
||||
|
||||
The relationship to activity labels, stated up front because it frames the design: **intents are the live, per-call, zero-cost layer; activity labels are the settled, per-block, fast-model layer.** A block's header progressively enriches — streaming intent while a call runs, the tool-authored outcome the instant it settles, fast-model narrative summary a moment later. The same two-layer idea extends to Langfuse traces (§11): intents name spans, activity labels name batches, and a session trace becomes readable as a narrative without opening a single payload.
|
||||
|
||||
---
|
||||
|
||||
## 2. Existing conventions to mirror
|
||||
|
||||
Read these before writing any code. The new capability is a structural sibling of the third row.
|
||||
|
||||
| Capability | `tool_options` key | Where it lands | Admin gate | Mechanism |
|
||||
|---|---|---|---|---|
|
||||
| Deferred tools | `defer_loading` | `LCTool.defer_loading` | `AgentCapabilities.deferred_tools` | tool withheld from context, discovered via `tool_search` BM25 |
|
||||
| Programmatic (PTC) | `allowed_callers` | `LCTool.allowed_callers` | `AgentCapabilities.programmatic_tools` | callable only from `run_tools_with_code` / `run_tools_with_bash` |
|
||||
| Background tools | `run_in_background` | **injected schema param** | `AgentCapabilities.run_in_background` | `packages/api/src/agents/background.ts` injects a boolean, `handlers.ts` intercepts and strips, `check_background_task` polls |
|
||||
|
||||
`packages/api/src/agents/background.ts` is the reference implementation. It is roughly 200 lines containing exactly the machinery this feature needs: a frozen property constant, non-mutating injection, an injectability guard, registry parity, self-spawn stripping, and ephemeral/model-spec synthesis. **Read it first and follow its structure function for function.** The new host module should be recognizable as its sibling at a glance.
|
||||
|
||||
Not per-tool capabilities, so out of scope for the family: `stateful_code_sessions`, `skills`, `subagents`, HITL approval, tool output references, eager event execution, activity labels (run-scoped, config-gated per endpoint via `librechat.yaml`, not per-tool). Those are agent- or run-scoped.
|
||||
|
||||
---
|
||||
|
||||
## 3. Naming
|
||||
|
||||
| Thing | Name |
|
||||
|---|---|
|
||||
| Injected arg | `intent` |
|
||||
| Result field | `outcome` (plus `outcome_patch`) |
|
||||
| Per-tool flag | `describe_intent` |
|
||||
| Admin capability | `AgentCapabilities.tool_intents` |
|
||||
| Host module | `packages/api/src/agents/intent.ts` |
|
||||
| SDK module | `src/tools/intentArg.ts` |
|
||||
|
||||
**Do not use `description` as the arg name.** Two blocking reasons:
|
||||
|
||||
- It collides with real third-party params. MCP servers and OpenAPI actions routinely expose a `description` field (create-issue, create-page, create-card, update-record). The injectability guard would refuse those tools silently, and they are precisely where an intent label has the most value.
|
||||
- It is ambiguous in every type, log line, and error message, because `LCTool.description` already means "the tool's own description".
|
||||
|
||||
Runner-up: `activity` / `activity_label`, which would unify vocabulary with the SDK's `activity_label` content type, `Run.generateActivityLabel`, and `src/prompts/activityLabel.ts`. Rejected precisely *because* of #14391: activity labels are now a shipping sibling system (per-block, fast-model-authored, a content part), and reusing the word for a per-call, calling-model-authored, args-resident string would make every conversation about either feature ambiguous. `intent` keeps the two layers nameable. This spec uses `intent`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Wire shape
|
||||
|
||||
### 4.1 The injected schema property
|
||||
|
||||
Injected as the **first** key of `properties`:
|
||||
|
||||
```ts
|
||||
/** Opening words double as the discriminator — see below. Exported. */
|
||||
export const INTENT_LABEL_MARKER = 'ALWAYS write this field FIRST';
|
||||
|
||||
export const INTENT_DESCRIPTION =
|
||||
`${INTENT_LABEL_MARKER}, before any other argument. One present-progressive ` +
|
||||
'sentence saying what THIS call is about to do: "Searching for OAuth handling ' +
|
||||
'in the callback router". Shown to the user as this call\'s live status. ' +
|
||||
'Never name the tool. Sibling calls to one tool must differ.';
|
||||
|
||||
const INTENT_PROPERTY: JsonSchemaType = Object.freeze<JsonSchemaType>({
|
||||
type: 'string',
|
||||
description: INTENT_DESCRIPTION,
|
||||
});
|
||||
```
|
||||
|
||||
**Keep it terse.** This text rides every opted-in schema on every request, so each sentence is paid for many times over — measured at ~72 tokens, or roughly 1.1k per request across a full coding bundle. An earlier 502-char draft cost ~126 tokens per tool; trimming it in 3.3.7 returned ~800 tokens per request. Every remaining clause is load-bearing: first-position placement (the entire streaming mechanism), the one-sentence present-progressive form, who reads it, and the sibling rule without which models emit identical labels for parallel calls and defeat the headline case.
|
||||
|
||||
**The first five words are an API.** `INTENT_LABEL_MARKER` is how every strip/sanitize path tells the injected label apart from a tool's own business parameter named `intent` — the two are structurally identical (`{type: 'string'}` under the same key) and only the description distinguishes them. It is prose rather than a schema extension (`x-intent-label`) because unknown JSON-Schema keywords are dropped by zod↔JSON-Schema conversion and actively rejected by OpenAI strict function schemas; `description` is the one field that survives to the wire intact. Import the constant, never re-declare the literal: two copies that drift make the host silently stop recognizing SDK-native labels, failing **open** with no error.
|
||||
|
||||
`{ [INTENT_ARG]: INTENT_PROPERTY, ...existingProps }` gives first-key placement, because JS object literal key order is insertion order and every provider serializer preserves it. First key in the schema means first key in the streamed input, which is the entire reason the label can appear before the rest of the args exist.
|
||||
|
||||
The "distinguish siblings" clause is load-bearing for the reference case. Without it, models emit identical intents for parallel calls to the same tool.
|
||||
|
||||
### 4.2 Outcome, and who authors it
|
||||
|
||||
The model cannot know the outcome at call time, so `outcome` is never a model-authored arg. Two sources, in precedence order:
|
||||
|
||||
1. **Tool-supplied replacement.** Native tools may return `outcome: string`. Full replacement. `web_search` returns `Found 12 results for OAuth handling`.
|
||||
2. **Tool-supplied in-place patch.** `outcome_patch: { from: string; to: string }` edits one span of the intent, first occurrence only, case-sensitive. `{ from: 'Searching', to: 'Searched' }` turns `Searching for OAuth handling` into `Searched for OAuth handling`, preserving whatever the model wrote after the verb. This is the flexible mechanism: any part of the sentence, edited in place.
|
||||
|
||||
Absent either, **the intent is displayed unchanged.** There is deliberately no mechanical tense rewrite.
|
||||
|
||||
An earlier draft of this spec specified a present-progressive→past-tense map over the leading verb (Searching→Searched, Reading→Read, …). It shipped in `@librechat/agents` 3.3.6 and was removed in 3.3.7, because such a map can only ever be a closed list of English verbs and is therefore wrong three ways at once:
|
||||
|
||||
- **It never fires for non-English labels.** §9.6 expects the model to write in the user's language, so intents arrive as "Buscando…", "検索中…". An English verb map is permanently dead for those users — the settled label already behaved differently per locale.
|
||||
- **It fires for some siblings and not others.** The first real-provider run produced *"Recording the location of the OAuth callback router"*; "Recording" was not in the 22-entry map. Beside a mapped "Searched…" in the same group, one card reads past tense and the other present, for no reason a user can perceive. Never transforming is more coherent than transforming sometimes.
|
||||
- **It enumerates a vocabulary** in a feature whose stated premise (§1, commitment 2) is a free-form sentence.
|
||||
|
||||
Completion is conveyed by **UI state** — the shimmer stopping, the icon settling — which is language-neutral and always consistent. A tool that wants past tense says so explicitly through `outcome` / `outcome_patch`.
|
||||
|
||||
Error and cancellation get their own framing (prefix or restyle), never past tense. Final fallback with no intent at all: today's `Ran search_code in github` per card, `Used N tools` per group. Nothing regresses for tools without the capability.
|
||||
|
||||
### 4.3 Type additions
|
||||
|
||||
In `@librechat/agents`:
|
||||
|
||||
```ts
|
||||
// ToolExecuteResult
|
||||
outcome?: string;
|
||||
outcome_patch?: { from: string; to: string };
|
||||
|
||||
// LCTool (optional, advertises SDK-native self-description)
|
||||
intent?: boolean;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Label propagation semantics
|
||||
|
||||
### 5.1 Per-call card
|
||||
|
||||
Each card's label is that call's own, resolved as:
|
||||
|
||||
```
|
||||
not a label-bearing tool -> existing default label (see gate below)
|
||||
streaming, partial intent parsed -> partial intent text, shimmering
|
||||
streaming, nothing parsable yet -> existing default label
|
||||
settled, outcome present -> outcome
|
||||
settled, outcome_patch present -> patch applied to intent
|
||||
settled, intent only -> the intent, unchanged
|
||||
settled, no intent -> existing default label
|
||||
error / cancelled -> error-framed intent, or existing default
|
||||
```
|
||||
|
||||
Cards are independent. One call's label never affects another's.
|
||||
|
||||
**The label-bearing gate is mandatory, and it is a server-sent signal — never the presence of an `intent` key.** A tool may declare its own business parameter named `intent` (an MCP CRM tool with an intent category, say). Injection is skipped for those tools, and every strip path is marker-guarded, so nothing breaks server-side. But the marker lives in the schema *description*, which never reaches the browser: the client sees only `{"intent": "..."}` in the args and cannot tell a status label from a business value. Rendering on key presence alone would display `billing_inquiry` as a status label.
|
||||
|
||||
So the server must tell the client which calls carry a label. `intentToolNames` is the natural channel — it already exists, is already threaded through the run configurable for the PTC strip, and already names exactly the host-injected set. Two requirements follow:
|
||||
|
||||
- SDK-native tools must be marked too. They are deliberately **excluded** from `intentToolNames` (their schema is their own, so the host never counts them as host-injected), which means the existing list is necessary but not sufficient — extend it, or send a parallel set.
|
||||
- The gate belongs in the shared resolver, not in each card, so the group header (§5.2) inherits it automatically.
|
||||
|
||||
This is latent today — nothing renders the label yet — and becomes real the moment the UI slice lands. It is a design constraint on slice 4a, not a defect to patch now.
|
||||
|
||||
### 5.2 Group header — a three-phase lifecycle
|
||||
|
||||
The group header shows the label of the **most recent call to change state**, where "change state" means either newly received (first parsable intent arrived) or newly finished (settled with a label). With #14391's activity labels in the picture, the header of a block moves through three phases:
|
||||
|
||||
1. **In flight.** If any call is in flight, the header shows the **latest in-flight** call's intent, live and shimmering. That is the live edge of the turn. (During this phase #14391's activity-label part for the block is an empty pending reservation that renders nothing, so there is no conflict.)
|
||||
2. **Settled.** When all calls are settled, the header shows the **latest settled** call's outcome. It does not revert to an earlier call's label and does not revert to `Used N tools`.
|
||||
3. **Summarized.** When the block's fast-model activity label resolves (asynchronously, off the critical path — #14391 generates it at the batch boundary via the `PostToolBatch` hook), the activity label **takes over as the durable header**. It is richer than any single call's outcome: it summarizes the whole block, in past tense, outcome-first. If activity labels are disabled, pending, or blank, the header simply stays at phase 2 forever.
|
||||
|
||||
Precedence in one line: **settled activity label > latest in-flight intent > latest settled outcome > existing subagent / ask-question tense-aware counts > `Used N tools`.** The phases are strictly ordered in time within one block (#14391's batch boundary means a new in-flight call starts a new block), so this chain never fights itself.
|
||||
|
||||
**Ordering caveat:** parallel batches settle out of order. Track a monotonic sequence at update time rather than trusting array order, so a slow first call settling after a fast second call cannot rewind the header.
|
||||
|
||||
### 5.3 Interaction with existing homogeneous-group labels
|
||||
|
||||
`ToolCallGroup.tsx` already special-cases two categories with tense-aware verbs: subagents (`Running/Ran N agents`, `Users` glyph) and `ask_user_question` (`Asking/Asked N questions`, question glyph).
|
||||
|
||||
- **Intent labels win over both.** A specific sentence beats a generic count.
|
||||
- **Keep the category glyphs.** `Users` for an all-subagent group is information the sentence does not carry.
|
||||
- Preserve the count in `aria-label` so screen readers still get `2 tools`.
|
||||
- Keep the `— toolNameSummary` suffix (`— github`). It is orthogonal and still useful.
|
||||
|
||||
### 5.4 Collapsed and historical messages
|
||||
|
||||
Labels persist because they live in `tool_call.args` and the tool result, both already saved with the message. A reloaded conversation renders identical labels with no new persistence and no migration. This is a deliberate structural choice worth naming: intents ride `tool_call.args`, **not** a content part — so they are immune by construction to the run-step index-space and edit-offset hazards #14391 spends its hardest engineering on. Introduce no new content type and no new SSE event for the request side.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verified native tool inventory
|
||||
|
||||
### 6.1 Canonical source of truth
|
||||
|
||||
`danny-avila/agents` already defines every coding tool name as a `Constants` member and groups them into exported arrays — `src/common/enum.ts` for the local groupings, `src/types/tools.ts` for the cloudflare groupings. Do not hand-maintain a parallel list; key off these.
|
||||
|
||||
| Export | Where | Members |
|
||||
|---|---|---|
|
||||
| `LOCAL_CODING_TOOL_NAMES` | `src/common/enum.ts` | `read_file`, `write_file`, `edit_file`, `grep_search`, `glob_search`, `list_directory`, `compile_check` |
|
||||
| `LOCAL_CODING_BUNDLE_NAMES` | `src/common/enum.ts` | the above plus `bash_tool`, `execute_code`, `run_tools_with_code`, `run_tools_with_bash` |
|
||||
| `CODE_EXECUTION_TOOLS` | `src/common/enum.ts` | `execute_code`, `bash_tool`, `run_tools_with_code`, `run_tools_with_bash` |
|
||||
| `CLOUDFLARE_CODING_TOOL_NAMES` | `src/types/tools.ts` | the same 11 names |
|
||||
| `CLOUDFLARE_BASH_CODING_TOOL_NAMES` | `src/types/tools.ts` | the 9-name bash-only subset (no `execute_code`, no `run_tools_with_code`) |
|
||||
|
||||
The SDK already pins `LOCAL_CODING_BUNDLE_NAMES` in a test so bundle changes are deliberate. **Add an equivalent pin for intent coverage:** assert every name in `LOCAL_CODING_BUNDLE_NAMES` and `CLOUDFLARE_CODING_TOOL_NAMES` emits `intent` as its first schema property. That turns "did we get them all" from a review question into a failing test.
|
||||
|
||||
### 6.2 Injection strategy: schemas, not factories
|
||||
|
||||
The key finding, which collapses most of the work: **Cloudflare has no file tools of its own.** `src/tools/cloudflare/CloudflareSandboxTools.ts` imports the local factories directly (`createLocalReadFileTool`, `createLocalWriteFileTool`, `createLocalEditFileTool`, `createLocalGrepSearchTool`, `createLocalGlobSearchTool`, `createLocalListDirectoryTool`, `createCompileCheckTool`) and reuses the shared `BashExecutionToolSchema` and `CodeExecutionToolSchema`.
|
||||
|
||||
So inject at the **schema definition**, never at the factory:
|
||||
|
||||
| Inject here | Automatically covers |
|
||||
|---|---|
|
||||
| the 7 local coding schemas (`src/tools/local/LocalCodingTools.ts`, `src/tools/local/CompileCheckTool.ts`) | local **and** cloudflare-sandbox engines |
|
||||
| `BashExecutionToolSchema` (`src/tools/BashExecutor.ts`) | remote sandbox, local, cloudflare `bash_tool` |
|
||||
| `CodeExecutionToolSchema` (`src/tools/CodeExecutor.ts`) | remote sandbox, local, cloudflare `execute_code` |
|
||||
| `ProgrammaticToolCallingSchema` (`src/tools/ProgrammaticToolCalling.ts`) | `run_tools_with_code`, all engines |
|
||||
| the bash PTC schema (`src/tools/BashProgrammaticToolCalling.ts`) | `run_tools_with_bash`, all engines |
|
||||
| `src/tools/ReadFile.ts` | the remote engine's parallel `read_file` implementation |
|
||||
|
||||
Six to eight edit sites cover three engines and all file CRUD. Still verify per engine with a test: `src/tools/local/resolveLocalExecutionTools.ts` and `createCloudflareExecutionTool` (plus `CloudflareProgrammaticToolCalling.ts`) switch on names and could swap in an engine-specific schema later.
|
||||
|
||||
### 6.3 Full opt-in-by-default list
|
||||
|
||||
**SDK (`danny-avila/agents`)**
|
||||
|
||||
| Tool | Constant | Notes |
|
||||
|---|---|---|
|
||||
| `read_file` | `READ_FILE` | two implementations (remote + local) share the name |
|
||||
| `write_file` | `WRITE_FILE` | |
|
||||
| `edit_file` | `EDIT_FILE` | |
|
||||
| `grep_search` | `GREP_SEARCH` | |
|
||||
| `glob_search` | `GLOB_SEARCH` | |
|
||||
| `list_directory` | `LIST_DIRECTORY` | |
|
||||
| `compile_check` | `COMPILE_CHECK` | |
|
||||
| `bash_tool` | `BASH_TOOL` | shared schema, all engines |
|
||||
| `execute_code` | `EXECUTE_CODE` | shared schema, all engines |
|
||||
| `run_tools_with_code` | `PROGRAMMATIC_TOOL_CALLING` | intent describes the whole program |
|
||||
| `run_tools_with_bash` | `BASH_PROGRAMMATIC_TOOL_CALLING` | same |
|
||||
| `subagent` | `SUBAGENT` | intent is the card header; `ON_SUBAGENT_UPDATE.label` stays the ticker |
|
||||
| `skill` | `SKILL_TOOL` | |
|
||||
| `tool_search` | `TOOL_SEARCH` | "Looking for a tool that can convert PDFs" |
|
||||
| `web_search` | `WEB_SEARCH` | SDK constant; LibreChat's implementation is host-side |
|
||||
|
||||
**Host (`danny-avila/LibreChat`)**
|
||||
|
||||
| Tool | Where | Notes |
|
||||
|---|---|---|
|
||||
| `web_search` | host implementation | explicit priority |
|
||||
| `create_file` | `CREATE_FILE_TOOL_NAME` in `agents/tools` | skill-aware file authoring |
|
||||
| `edit_file` | `EDIT_FILE_TOOL_NAME` | **name collides with the SDK's**, see 6.5 |
|
||||
| `set_memory` / `delete_memory` | `agents/memory` | the least legible calls in the UI today, high value |
|
||||
| `ask_user_question` | `hitl/askUserQuestionTool` | strong synergy, see 10.4 |
|
||||
| `file_search` | host | phase 2 |
|
||||
|
||||
**Deliberately excluded**
|
||||
|
||||
| Tool | Reason |
|
||||
|---|---|
|
||||
| any tool with `allowed_callers: ['code_execution']` only | no UI card exists, pure token cost |
|
||||
| `lc_transfer_to_*` | prefix-excluded, direct-path dispatch, already labeled by `agent_update` |
|
||||
| `check_background_task` | host machinery (optional if "Checking on the background search" proves useful) |
|
||||
| image generation tools | artifact-first, the artifact is the label |
|
||||
|
||||
### 6.4 Each tool body must strip
|
||||
|
||||
Every injected tool must call `stripIntent` before validating or using its args, so no tool receives a parameter it did not declare.
|
||||
|
||||
### 6.5 The `edit_file` collision
|
||||
|
||||
`edit_file` exists in both surfaces: the SDK coding suite (`Constants.EDIT_FILE`) and LibreChat's skill-authoring tool (`EDIT_FILE_TOOL_NAME`, matched by `FILE_AUTHORING_TOOLS` in `useStepHandler.ts` alongside `create_file`). Consequences:
|
||||
|
||||
- Host injection must be idempotent against an SDK-injected schema (`if (INTENT_ARG in existingProps) return def;`). Add an explicit test for this pair, because double injection would move `intent` out of first position.
|
||||
- Icon and label resolution in `client/src/utils` keys on the shared name, so both render identically. That should stay true.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation: `@librechat/agents`
|
||||
|
||||
### 7.1 New module `src/tools/intentArg.ts`
|
||||
|
||||
- `INTENT_ARG = 'intent'`
|
||||
- `INTENT_DESCRIPTION` (§4.1)
|
||||
- `withIntent(schema)`: prepends the field. Provide both a zod variant (`z.object({ intent: ..., ...rest })`, since zod key order drives JSON schema property order) and a raw-JSON-schema variant, because tool factories use both.
|
||||
- `readIntent(args)`: tolerant read handling object args and stringified-JSON args (mirror `coerceArgsObject` in `background.ts`).
|
||||
- `stripIntent(args)`: returns args without the key.
|
||||
- `applyOutcome(intent, { outcome, outcome_patch })`: the §4.2 precedence chain — outcome, then patch, then the intent UNCHANGED. Pure, dependency-free, exported, unit tested in isolation. The client needs identical logic, so keep it importable or mirror it exactly.
|
||||
- `withoutIntent(schema)`: the embedder opt-out. Native schemas apply `withIntent` at module scope, so a consumer that renders no status label otherwise has no lever and pays the tokens unconditionally. Marker-guarded.
|
||||
|
||||
### 7.2 Wiring
|
||||
|
||||
Apply per §6.2 at the schema sites, then confirm coverage per engine. Skip tools whose `allowed_callers` is `['code_execution']` only.
|
||||
|
||||
### 7.3 Result threading
|
||||
|
||||
Carry `outcome` / `outcome_patch` from the tool return through `ToolExecuteResult` to the `ON_RUN_STEP_COMPLETED` completion event, so the host sees it without a new channel.
|
||||
|
||||
### 7.4 Release
|
||||
|
||||
Everything here ships as a minor `@librechat/agents` release (3.4.x), mirroring how #327 shipped the activity-label SDK surface ahead of its host PR. LibreChat's host slice pins the bump; older SDK versions simply never emit intents, so there is no fallback path to write.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation: LibreChat host
|
||||
|
||||
### 8.1 New module `packages/api/src/agents/intent.ts`
|
||||
|
||||
A structural clone of `background.ts`:
|
||||
|
||||
| `background.ts` | `intent.ts` |
|
||||
|---|---|
|
||||
| `RUN_IN_BACKGROUND_ARG` | `INTENT_ARG` |
|
||||
| `RUN_IN_BACKGROUND_PROPERTY` | `INTENT_PROPERTY` (frozen) |
|
||||
| `injectRunInBackgroundParam` | `injectIntentParam` (**prepends**, never mutates input) |
|
||||
| `canInjectRunInBackgroundParam` | `canInjectIntentParam` |
|
||||
| `stripRunInBackgroundArg` | `stripIntentArg` |
|
||||
| `isBackgroundRequested` | `readIntentArg` |
|
||||
| `applyBackgroundToolCalls` | `applyIntentLabels` |
|
||||
| `stripBackgroundFromToolDefinitions` | `stripIntentFromToolDefinitions` |
|
||||
| `stripBackgroundFromToolRegistry` | `stripIntentFromToolRegistry` |
|
||||
| `synthesizeBackgroundToolOptions` | `synthesizeIntentToolOptions` |
|
||||
| `EXCLUDED_BACKGROUND_TOOL_NAMES` | `EXCLUDED_INTENT_TOOL_NAMES` |
|
||||
|
||||
Capability-specific notes:
|
||||
|
||||
- `canInjectIntentParam` returns false for non-object schemas (rewriting a DynamicTool string input breaks its contract) and for tools already declaring `intent`. Log a warning on skip, as background does.
|
||||
- The exclusion set is much smaller than background's. Background excludes for correctness (artifact continuity, direct-path dispatch). Intent labels are inert, so exclude only `check_background_task`, `lc_transfer_to_*` by prefix, and code-execution-only tools.
|
||||
- Registry parity is mandatory: deferred tools are injected at discovery time as well as load time, and `stripIntentFromToolRegistry` exists for the same reason its background sibling does.
|
||||
|
||||
### 8.2 Strip and capture
|
||||
|
||||
In `packages/api/src/agents/handlers.ts`, at the same seam that calls `stripRunInBackgroundArg` (two call sites today; match both). Read the intent for the event payload, strip before invoking. The label rides `tool_call.args` to the client, so no new request-side event field is needed; `outcome` rides the completion result.
|
||||
|
||||
**Do not strip intent from the args that feed activity-label batch entries.** The strip is for tool bodies; `ActivityLabelToolEntry.toolInput` should keep the intent — it is the most valuable field in that prompt (§10.10).
|
||||
|
||||
### 8.3 Capability plumbing
|
||||
|
||||
Background's flag threads through exactly four call sites. Follow all four, or agents that opted in silently lose the capability on one route:
|
||||
|
||||
- `api/server/services/Endpoints/agents/initialize.js`
|
||||
- `api/server/controllers/agents/openai.js`
|
||||
- `api/server/controllers/agents/responses.js`
|
||||
- `packages/api/src/agents/openai/service.ts`
|
||||
|
||||
Each reads `enabledCapabilities.has(AgentCapabilities.tool_intents)` and passes `toolIntentsAvailable` into `initializeAgent`.
|
||||
|
||||
### 8.4 Schema and config surface
|
||||
|
||||
- `toolOptionsSchema` and `agentToolOptionsSchema` in `packages/api/src/agents/validation.ts`: add `describe_intent: z.boolean().optional()`. These have fully written-out Zod type annotations, so update the annotation and the runtime object together.
|
||||
- `AgentToolOptions` in `packages/data-provider/src/types/assistants.ts`
|
||||
- `AgentCapabilities` enum in `packages/data-provider/src/config.ts` (`tool_intents = 'tool_intents'`, alongside `deferred_tools`, `programmatic_tools`, `run_in_background`)
|
||||
- `librechat.example.yaml` capability list plus a comment
|
||||
- `packages/data-schemas/src/schema/agent.ts` and `types/agent.ts` doc comments (`tool_options` is `Mixed`, so no migration)
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation: UI
|
||||
|
||||
**Base assumption:** #14391 lands first. It extends `groupSequentialToolCalls` so THINK parts absorb into blocks terminated and labeled by an `ACTIVITY_LABEL` part, renders that label as the `ToolCallGroup` header, and auto-collapses labeled single-tool groups. Build the intent header logic on top of that shape, slotting into the §5.2 precedence chain. If the ordering flips and intent UI lands first, the precedence chain is unchanged — #14391 rebases onto it by adding its highest-precedence branch.
|
||||
|
||||
### 9.1 Partial-JSON intent parser
|
||||
|
||||
New `client/src/utils/toolIntent.ts`, heavily unit tested. This is the trickiest piece.
|
||||
|
||||
`useStepHandler.ts` already accumulates streamed args as a raw string:
|
||||
|
||||
```ts
|
||||
let args = finalUpdate || typeof existingToolCall?.args === 'object'
|
||||
? contentPart.tool_call.args
|
||||
: (existingToolCall?.args ?? '') + (toolCallArgs ?? '');
|
||||
```
|
||||
|
||||
So mid-stream the client holds fragments like `{"intent":"Searching for OAuth han`. Required behavior for `readStreamingIntent(args): { text: string; complete: boolean } | null`:
|
||||
|
||||
- Handle object args (already parsed) and string args (partial).
|
||||
- Handle unterminated string values, escaped quotes (`\"`), escaped backslashes, `\n`, and partial `\u` sequences (never render a half-decoded escape).
|
||||
- Handle whitespace variance (`{ "intent" : "..."`).
|
||||
- Return `null` cleanly when the key has not arrived, or when the leading key is not `intent` because the provider reordered.
|
||||
- Never throw. Malformed fragments degrade to the default label.
|
||||
|
||||
### 9.2 `ToolCallGroup.tsx`
|
||||
|
||||
- Extend `getToolMeta` to also return the resolved label and a state marker (`streaming` / `settled` / `error`).
|
||||
- Add the §5.2 branches to `resolveGroupLabel()`: settled activity label first (this is #14391's branch, already highest), then latest in-flight intent, then latest settled outcome, then the existing subagent / ask-question / `Used N tools` chain.
|
||||
- Keep #14391's auto-collapse behavior for labeled groups; an intent header alone does not trigger auto-collapse (only a resolved activity label does).
|
||||
- Keep `CategoryIcon` selection and `StackedToolIcons` behavior unchanged.
|
||||
- Keep `toolNameSummary`.
|
||||
- Move the count into `aria-label`.
|
||||
|
||||
### 9.3 Per-card label
|
||||
|
||||
Same precedence in the single-card path (`ToolCall.tsx`, `getToolDisplayLabel`). A group only exists at count ≥ 2, so a solo call must not regress. Note #14391's labeled single-tool groups: a solo call inside a labeled block keeps its own intent/outcome as the card label beneath the activity header.
|
||||
|
||||
### 9.4 Streaming effect
|
||||
|
||||
No synthetic typewriter needed. The text genuinely arrives token by token because providers stream tool inputs.
|
||||
|
||||
- Render the accumulating string directly.
|
||||
- Trailing shimmer via a CSS gradient mask on the last few characters, plus the existing `animate-pulse` on the icon while `!allCompleted && isSubmitting`.
|
||||
- **Single line, `truncate`, fixed height.** A growing label must not reflow the message. Do not call `scheduleMessageContentLayoutReconcile` per token; it exists for expand/collapse.
|
||||
- Crossfade or brief highlight when the label flips from intent to outcome (and again when the activity label takes over), so each in-place edit reads as an edit rather than a flicker.
|
||||
- Respect `prefers-reduced-motion`: no shimmer, just text.
|
||||
- Consider a 150 to 250ms minimum display time per header label so a fast burst does not make the header unreadable, without delaying underlying state.
|
||||
|
||||
### 9.5 Sanitization
|
||||
|
||||
These strings are model-authored and now render in application chrome. Treat as untrusted:
|
||||
|
||||
- Strip newlines and control characters.
|
||||
- Cap at ~120 characters with ellipsis (the model is instructed to be brief; enforce anyway).
|
||||
- Plain text only, no markdown, no HTML.
|
||||
- Full text in a `title` attribute or the expanded view.
|
||||
- Sanitize at render, not at ingest, so persisted data stays faithful.
|
||||
|
||||
### 9.6 i18n
|
||||
|
||||
New keys only for fallback and error framings. The model-authored sentence is unlocalized, which is acceptable because the model already replies in the user's language. If insufficient, add a locale hint to the arg description in a follow-up rather than post-translating.
|
||||
|
||||
---
|
||||
|
||||
## 10. Interaction with the other tool capabilities
|
||||
|
||||
Each subsection is a decision, not a discussion.
|
||||
|
||||
### 10.1 With `defer_loading`
|
||||
|
||||
- Inject at **both** the initial load path and the discovery-promotion path in `packages/api/src/agents/run.ts`. A tool discovered mid-conversation must arrive with `intent` already first, or its first call renders with the legacy label while later calls do not.
|
||||
- The idempotent guard is mandatory, since a promoted tool passes through injection twice.
|
||||
- **Token accounting must run after injection.** `AgentContext` tracks `toolTokenCounts` and `deferredToolNames`, consumed by the pruner's calibration. Injecting 40 to 60 tokens per tool after counting understates the schema budget. Verify ordering and add a test that a counted deferred tool's count reflects the injected property.
|
||||
- `stripIntentFromToolRegistry` prevents a self-spawned child using tool search from rediscovering a host-injected schema it cannot honor.
|
||||
|
||||
### 10.2 With `allowed_callers` (PTC)
|
||||
|
||||
| Tool's `allowed_callers` | Inject? | Required? |
|
||||
|---|---|---|
|
||||
| `['direct']` or omitted | yes | may be required for SDK natives |
|
||||
| `['direct', 'code_execution']` | yes | **never required** |
|
||||
| `['code_execution']` | no | n/a |
|
||||
|
||||
The dual-caller rule is load-bearing. PTC generates callable signatures from tool schemas for the in-sandbox bridge (`normalizeToPythonIdentifier`, `filterToolsByUsage`). A required `intent` would force every model-written `await write_file(...)` to pass a label no UI displays. Keep it optional there; the bridge must tolerate absence.
|
||||
|
||||
The PTC runners themselves carry intent, describing the program as a whole. Inner calls stay unlabeled: the sandbox is one card.
|
||||
|
||||
Confirm intent is stripped before the sandbox bridge serializes inner-call inputs, so a stray `intent` never reaches a tool body through the programmatic path.
|
||||
|
||||
### 10.3 With `run_in_background`
|
||||
|
||||
The most intertwined pairing, since both inject into the same schema.
|
||||
|
||||
**Ordering.** Run `applyIntentLabels` **before** `applyBackgroundToolCalls`, and confirm `injectRunInBackgroundParam` appends (`{ ...existingProps, [RUN_IN_BACKGROUND_ARG]: ... }`) rather than prepends. It currently appends, so the order holds; pin it with a test asserting `Object.keys(properties)[0] === 'intent'` on a tool with both capabilities.
|
||||
|
||||
**Label lifecycle.**
|
||||
|
||||
- A backgrounded call returns a synthetic handle immediately. That instant "completion" is not a real outcome, so it must **not** settle the label. Keep the intent, reframed (`Searching for OAuth handling · in background`), and treat the call as in-flight for the §5.2 header rule. Otherwise a fire-and-forget dispatch hijacks the header with a fake outcome while real work continues.
|
||||
- The real outcome arrives at harvest. `background.ts` already patches the dispatch turn's tool-call output and attaches files via `attachHarvest` / `getBackgroundCodeDelivery`, signalling the client with `BACKGROUND_STATUS_ATTACHMENT_TYPE`. **Patch the label at that same seam.** The existing idempotent re-emission on every poll works in our favor.
|
||||
- A task reaped as timed out (`RUNNING_TASK_TTL_MS`) must resolve to an error-framed label, not shimmer forever. The `harvestStarted` marker set at dispatch exists so never-settling tasks still take the heal path.
|
||||
|
||||
**Self-spawn stripping.** `stripBackgroundFromToolDefinitions` and `stripBackgroundFromToolRegistry` need intent siblings, called from the same place.
|
||||
|
||||
**Cost.** A tool with both carries two injected properties. Include that combination in the token measurement.
|
||||
|
||||
### 10.4 With HITL and `ask_user_question`
|
||||
|
||||
The strongest synergy in the feature, and nearly free.
|
||||
|
||||
`PreToolUse` `ask` decisions raise a LangGraph `interrupt()` carrying a `HumanInterruptPayload`, rendered by the approval UI, which today leads with a tool name and a JSON args blob. **Thread `intent` into the payload and lead with it.** "Approve: Deleting the staging database migrations" is a materially better consent prompt, and this is exactly where a bad approval is expensive.
|
||||
|
||||
- **Resume re-execution:** `HumanInTheLoopConfig` documents that an approved batch is re-executed on resume. Intent lives in args, so it survives unchanged. No work, but assert it.
|
||||
- **`updatedInput`:** a `PreToolUse` hook can rewrite args, leaving the intent stale. Decision: **hooks may also rewrite `intent`**, documented in the hook docs. The alternative (marking the label as modified) is worse UX.
|
||||
|
||||
`ask_user_question` itself gets intent; the existing `Asking/Asked N questions` group label yields per §5.3 while keeping the question glyph.
|
||||
|
||||
### 10.5 With subagents
|
||||
|
||||
- The `subagent` tool's intent is the parent-side card header. The existing `ON_SUBAGENT_UPDATE` `label` (`Subagent "x" started`) remains the ticker line beneath it. Do not merge them.
|
||||
- Child tool calls carry their own intents, aggregated into `subagent_content` by `foldSubagentEvent`. Worthwhile enrichment: surface the child's **latest** intent in the parent's collapsed ticker, so a running subagent reads `Delegating the OAuth audit · Reading callback router` without expanding. `tickerState` and `latestLabel` already exist in the Recoil atom.
|
||||
- Self-spawned children must have intent stripped from inherited definitions and registry.
|
||||
- Note #14391 skips subagent scopes for activity labels (their content belongs to the spawning tool call), so inside a subagent the intent layer is the *only* labeling layer. That makes SDK-native injection (§6.3) matter doubly there.
|
||||
|
||||
### 10.6 With tool output references
|
||||
|
||||
`{{tool<i>turn<n>}}` placeholders are substituted into **any string arg** immediately before invocation, and the registry stores raw untruncated output up to ~400KB per entry.
|
||||
|
||||
**`intent` must be excluded from placeholder substitution.** Otherwise a model writing `{{tool0turn0}}` inside its intent dumps hundreds of kilobytes into a single-line label. Add the exclusion in the substitution pass, not just a render-time cap, so the oversized string never reaches persistence. This is a real bug the feature would introduce if built naively.
|
||||
|
||||
Positive side: a follow-up can label each `tool<i>turn<n>` key with its intent, so both the model and a debugging human can see which reference holds what.
|
||||
|
||||
### 10.7 With eager event tool execution
|
||||
|
||||
- Because `intent` is the first key, it is present in every partial and cannot delay the eager completeness gate.
|
||||
- If args are revised after an eager start, the intent may change. The label must accept revision without flicker; the existing arg-accumulation logic handles it.
|
||||
- No change to `excludeToolNames`. Intent does not affect whether a tool is safe to speculate on.
|
||||
|
||||
### 10.8 With compaction, pruning, and tracing
|
||||
|
||||
- **Compaction:** when the pruner drops tool bodies, retain `intent` and `outcome`. That is the anchor ledger in §11 and the cheapest way for a summarized session to retain a record of its own actions.
|
||||
- **Langfuse:** covered in full in §11.3 — intent as span-level anchor, following #14391's tracing conventions and redaction-policy selection.
|
||||
|
||||
### 10.9 Composition test matrix
|
||||
|
||||
| Combination | Assertion |
|
||||
|---|---|
|
||||
| intent + `run_in_background` | `intent` first key, `run_in_background` present, order stable |
|
||||
| intent + backgrounded dispatch | dispatch does not settle the label; harvest patches it |
|
||||
| intent + background timeout | reaped task resolves to an error label |
|
||||
| intent + `defer_loading` | discovered tool arrives injected; token count includes it |
|
||||
| intent + `allowed_callers: ['code_execution']` | not injected |
|
||||
| intent + `['direct','code_execution']` | injected, not required; PTC bridge tolerates absence |
|
||||
| intent + PTC inner call | inner tool body receives no `intent` key |
|
||||
| intent + HITL `ask` | interrupt payload carries the intent |
|
||||
| intent + HITL `updatedInput` | hook-rewritten intent is honored |
|
||||
| intent + self-spawn subagent | stripped from both definitions and registry |
|
||||
| intent + tool output references | `{{...}}` in `intent` is not substituted |
|
||||
| intent + activity label | header phases in order; resolved activity label wins; batch entries keep intent |
|
||||
| intent + all three other caps | one tool, all enabled, schema valid, `intent` first |
|
||||
|
||||
### 10.10 With activity groups (PR #14391)
|
||||
|
||||
Not a capability, but the most consequential composition in the feature, so it gets the same decision treatment.
|
||||
|
||||
- **Layering, restated as the rule:** intents are per-call, model-authored, free, and live; activity labels are per-block, fast-model-authored, paid, and settled. The §5.2 three-phase header is the entire UI contract between them. Neither system replaces the other, and neither is configured by the other: activity labels stay per-endpoint `librechat.yaml` config (`activityLabel`, `activityModel`, …), intents stay a per-tool capability.
|
||||
- **Intents make activity labels better and cheaper.** `ActivityLabelToolEntry.toolInput` carries the call args, and #14391's header prompt explicitly forbids restating tool names, counts, and argument echoes — it wants exactly the human-readable material intents provide. With intents present, the labeling model reads `"Searching for OAuth handling in the callback router"` instead of raw JSON. Per §8.2, the handler strip must not remove intent from the batch entries; add a test pinning that the entry serialization keeps it.
|
||||
- **No shared plumbing to build.** Intents ride `tool_call.args`; activity labels ride an `ACTIVITY_LABEL` content part with its own `on_activity_label` SSE event, epoch-scoped fills, and index-space reconciliation. Do not couple the transports. In particular, intents must not add content parts — that immunity to #14391's index-space hazards (§5.4) is a feature, not an accident.
|
||||
- **Auto-collapse:** #14391 auto-collapses labeled single-tool groups. Keep that keyed on the activity label only. An intent-labeled but activity-unlabeled group stays expanded per current behavior.
|
||||
- **Landing order:** the UI slice (slice 4) rebases on #14391's `ToolCallGroup` / `groupSequentialToolCalls` shape. Coordinate before merge — #14391 is still moving.
|
||||
|
||||
---
|
||||
|
||||
## 11. Activity labels, Langfuse anchors, and session snapshots
|
||||
|
||||
### 11.1 Current state, precisely
|
||||
|
||||
The SDK layer is **shipped**: `Run.generateActivityLabel`, `RunActivityLabelOptions`, `ActivityLabelToolEntry`, `ACTIVITY_LABEL_PROMPT` in `src/prompts/activityLabel.ts`, and the UI-only `activity_label` content type are all published in `@librechat/agents@3.3.0` (danny-avila/agents#327). The host layer that consumes it is [#14391](https://github.com/danny-avila/LibreChat/pull/14391), open and near merge. This spec builds on both; §10.10 covers the UI composition. What remains here is the trace story and the anchor ledger.
|
||||
|
||||
### 11.2 The two-layer anchor model
|
||||
|
||||
- **Intent labels are per-call and free.** Written inline by the model, zero extra inference. They answer "what is this span doing" at the finest grain.
|
||||
- **Activity labels are per-block and cost a fast-model call.** They answer "what did this stretch of the run accomplish," past-tense and outcome-first.
|
||||
- Together they make a session skimmable at two zoom levels — in the chat UI (§5.2) and, below, in traces.
|
||||
|
||||
### 11.3 Langfuse: intents as span-level anchors
|
||||
|
||||
Borrow #14391's tracing decisions wholesale; they are the template for how a labeling layer reaches Langfuse correctly.
|
||||
|
||||
What #14391 established:
|
||||
|
||||
- Label generations are traced through `run.generateActivityLabel` with the **conversation as the Langfuse session** and a per-batch trace seed, so they group under their conversation (trace name `LibreChat Activity Label`, tags `["librechat", "activity-label"]`) instead of appearing as orphans.
|
||||
- The executing agent is forwarded (`RunActivityLabelOptions.agentId`), which selects that agent's trace metadata **and its tool-output redaction policy** — a stricter per-agent overlay cannot leak through the label path.
|
||||
|
||||
What intents add — with **no new model call and no new trace**:
|
||||
|
||||
- **Name or annotate the existing tool spans with the intent.** The SDK's trace shaping (`src/langfuseTraceShaping.ts`, `src/langfuseToolOutputTracing.ts`) already builds the tool span; attach `intent` at span start and patch in `outcome` at settle. A session trace then reads as a narrative — `Searching for OAuth handling → Found 12 results` — without opening a single span payload. This is the "what has the agent been up to" view, answered from the trace list alone.
|
||||
- **Redaction stance:** intent is a model-authored *argument*, not tool output. It follows the args-tracing policy, not the output-redaction policy (`shouldRedactTool` guards outputs). But when a tool's args are redacted for an agent, its intent must be too — the intent is a distillation of the args and would otherwise be a side channel. Decide this in the same config object, not ad hoc per call site.
|
||||
- **Tagging:** mirror the convention — tag intent-annotated spans `["librechat", "tool-intent"]` so both label layers are queryable as families in Langfuse.
|
||||
|
||||
This lands as part of slice 6, in `~/agentus`, since the trace shaping lives there.
|
||||
|
||||
### 11.4 Anchor ledger
|
||||
|
||||
Persist a compact per-turn array derived from settled calls:
|
||||
|
||||
```ts
|
||||
type IntentAnchor = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
intent: string;
|
||||
outcome?: string;
|
||||
status: 'success' | 'error' | 'cancelled';
|
||||
ts: number;
|
||||
agentId?: string;
|
||||
};
|
||||
```
|
||||
|
||||
This unlocks four things that are otherwise hard:
|
||||
|
||||
1. **Turn timelines.** A scrubbable "what happened in this turn" list with jump-to-anchor, and meaningful collapsed headers for long agentic turns.
|
||||
2. **Compaction survival.** Keep the ledger when the pruner drops tool bodies. Feed it to the summarizer prompt or inject as a `source: 'system'` meta message.
|
||||
3. **Named checkpoints.** `Run.rewindFiles()` and `checkpointForkSeq` exist but have no human-readable labels. "Rewind to *Rewriting the zod schema*" is what makes rewind usable by a person rather than a debugger.
|
||||
4. **Analytics.** Intent text is queryable: which intents precede failures, which tools get vague intents, how often the model makes redundant sibling calls.
|
||||
|
||||
Treat the ledger as a follow-up slice, but design label resolution so it is a trivial derivation rather than a rewrite.
|
||||
|
||||
---
|
||||
|
||||
## 12. Builder toggles
|
||||
|
||||
Mirror #14407's shared-switch approach. One `client/src/components/SidePanel/Agents/Intent.tsx` writing `tool_options[toolId].describe_intent`, reused by:
|
||||
|
||||
- `MCPToolItem.tsx` via `OptionToggle` (distinct glyph and `activeClass`; `defer_loading` uses `Clock` / amber)
|
||||
- `ToolSection` for plugin tools
|
||||
- the Code card
|
||||
- the action dialog, carrying over the same `---` vs `_` encoded-domain aliasing #14407 documents. That bug (opt-ins silently no-oping for short hostnames like `slack.com`, `openai.com`) will recur verbatim if the aliasing is not brought along.
|
||||
|
||||
Add a "Describe all tools" bulk affordance alongside the existing `com_ui_mcp_defer_all`, since per-tool toggling across a large MCP server is tedious. New locale keys follow the `com_ui_mcp_*` naming pattern, written for a non-technical admin.
|
||||
|
||||
---
|
||||
|
||||
## 13. Risks and open decisions
|
||||
|
||||
1. **Token cost.** Roughly 40 to 60 schema tokens per opted-in tool, plus 10 to 25 output tokens per call. With a large MCP server opted in wholesale this is material, and "native tools on by default" means the default agent pays. **Measure a representative coding turn before and after and put the number in the PR.**
|
||||
2. **OpenAI strict function calling** requires every property in `required`. An optional injected property is a problem under strict mode. `run_in_background` has identical exposure, so first confirm whether strict is in play on any path; if so, either add `intent` to `required` for those providers or skip injection there.
|
||||
3. **Provider arg ordering is convention, not contract.** Some providers may reorder keys or deliver args in one chunk. The UI must degrade silently to "no label until parsable". Never assert on ordering at runtime.
|
||||
4. **Required vs optional on native tools.** Optional is safer and non-breaking; required measurably improves compliance. Recommendation: required for SDK natives (we control every caller), optional for host-injected third-party tools. Cost: any code constructing native tool inputs by hand, including tests and PTC-generated inner calls, must supply `intent`.
|
||||
5. **Sibling-call collision.** Models tend to emit identical intents for parallel calls to one tool, which defeats the reference case entirely. Mitigated by the description clause in §4.1. Verify empirically across Anthropic, OpenAI, and Google; if compliance is poor, consider a client-side disambiguating suffix.
|
||||
6. **Header churn.** Fast parallel bursts make the header flicker. Minimum display time per §9.4.
|
||||
7. **Untrusted display text.** First time model-authored free text renders in collapsed chrome. §9.5 applies.
|
||||
8. **Deferred double-injection.** Discovery-time and load-time paths both inject; guard idempotently.
|
||||
9. **In-flight base for the UI slice.** #14391 is open and still moving (its PR notes flag follow-ups around run-step index math and parallel-column lanes). Slice 4 must rebase on its final `ToolCallGroup` shape; coordinate the merge order rather than racing it. Intents themselves are insulated (§5.4 — args, not content parts), so only the header-precedence branch is exposed to churn.
|
||||
|
||||
---
|
||||
|
||||
## 14. Test plan
|
||||
|
||||
**SDK**
|
||||
- `applyOutcome` precedence: outcome, outcome_patch, then the intent UNCHANGED. Assert no tense rewrite across English, non-English, lowercase and single-word labels — a regression here reintroduces the locale split (§4.2).
|
||||
- `outcome_patch` replaces first occurrence only, case-sensitive, no-op when `from` is absent.
|
||||
- `withIntent` places `intent` first in the emitted JSON schema, both zod and raw variants.
|
||||
- Coverage pin: every name in `LOCAL_CODING_BUNDLE_NAMES` and `CLOUDFLARE_CODING_TOOL_NAMES` emits `intent` first.
|
||||
- Every native tool body receives args without `intent`.
|
||||
- `allowed_callers: ['code_execution']` tools are not injected.
|
||||
- Trace shaping attaches intent at span start and outcome at settle; redacted-args agents get neither.
|
||||
|
||||
**Host** (clone `background.spec.ts` structure)
|
||||
- Injection is first-key and non-mutating; frozen input defs survive.
|
||||
- Idempotent when the property already exists (including the SDK/host `edit_file` pair).
|
||||
- Non-object schema skipped with a warning.
|
||||
- Tool declaring its own `intent` skipped with a warning.
|
||||
- Registry parameters updated alongside definitions.
|
||||
- `stripIntentFrom{ToolDefinitions,ToolRegistry}` remove it for self-spawn.
|
||||
- `synthesizeIntentToolOptions` covers ephemeral and model-spec agents.
|
||||
- Capability off means no injection, on all four routes.
|
||||
- `---` vs `_` action-name aliasing resolves (#14407's lesson).
|
||||
- Deferred discovery path injects.
|
||||
- Activity-label batch entries retain `intent` after the handler strip.
|
||||
- Plus the full §10.9 composition matrix.
|
||||
|
||||
**Client**
|
||||
- `readStreamingIntent` against a fragment table: empty, `{`, `{"in`, `{"intent"`, `{"intent":"`, `{"intent":"Sea`, escaped quote mid-value, escaped backslash, `\n`, partial `\uD83D`, complete value, complete object, reordered keys, malformed garbage. None throw.
|
||||
- Header shows latest in-flight intent while any call is in flight.
|
||||
- Header shows latest settled outcome when all settled, and does not revert to `Used N tools`.
|
||||
- A resolved activity label takes over the header and is not displaced by earlier outcomes (§5.2 phase 3).
|
||||
- An intent-labeled group without an activity label does not auto-collapse.
|
||||
- Out-of-order settlement does not rewind the header.
|
||||
- Two sibling `search_code` calls render two distinct card labels (assert the reference case directly).
|
||||
- Subagent and ask-question groups keep glyphs while showing intent text.
|
||||
- No intent anywhere falls back to current labels exactly.
|
||||
- Error and cancelled states render their own framing.
|
||||
- Labels survive a reload from persisted args.
|
||||
- Long label truncates without changing group height.
|
||||
- Backgrounded dispatch keeps the in-flight framing.
|
||||
|
||||
**Builder**
|
||||
- Toggle writes and clears `tool_options[id].describe_intent` without disturbing sibling keys.
|
||||
- Capability off hides the switch.
|
||||
- Bulk toggle applies across a server.
|
||||
|
||||
**Manual verification for the PR**
|
||||
Enable `tool_intents`, attach a GitHub MCP server, ask the agent to search the repo for two different things in one turn. Confirm: two distinct streaming labels, header tracking the live call, header settling on the last outcome, reload preserving labels, capability off restoring `Used 2 tools — github`. Then repeat with `run_in_background` also enabled on the same tool and confirm the dispatch keeps its in-flight framing until harvest. Finally, repeat with `activityLabel: true` on the endpoint and confirm the three-phase header: live intent while streaming, outcome at settle, fast-model header taking over when it resolves.
|
||||
|
||||
---
|
||||
|
||||
## 15. Slicing
|
||||
|
||||
Six PRs, each independently reviewable and shippable. Dependencies are explicit because three of them sequence against in-flight work:
|
||||
|
||||
1. **SDK core** (`danny-avila/agents`). `intentArg.ts`, `applyOutcome`, `outcome` / `outcome_patch` on results, `ToolNode` threading, unit tests. No behavior change until a tool opts in.
|
||||
2. **SDK native tools** (`danny-avila/agents`). Schema-level injection per §6.2, coverage pin test, all three engines. Slices 1–2 can ship as one minor release (3.4.x), mirroring how #327 shipped ahead of its host PR.
|
||||
3. **Host** (LibreChat, requires the published SDK bump). `intent.ts`, `handlers.ts` strip, capability plumbing through all four routes, schema and config surface, `web_search`, `create_file` / `edit_file`, memory tools, tests cloned from `background.spec.ts`.
|
||||
4. **UI** (LibreChat, **rebases on #14391's `ToolCallGroup` shape — land after it**). Partial-JSON parser, per-card label, three-phase group header, shimmer, sanitization, i18n fallbacks.
|
||||
5. **Builder toggles** (LibreChat, **after #14407** so the switches extend its shared component rather than forking it). Including encoded-domain aliasing and the bulk toggle.
|
||||
6. **Anchors, traces, and activity-label integration** (both repos, **after #14391 merges**). Langfuse span annotation per §11.3, batch-entry enrichment per §10.10, ledger derivation, compaction survival, HITL payload threading, named checkpoints.
|
||||
|
||||
**Documentation** in `librechat.ai`: a single **"Tool capability conventions"** page documenting `defer_loading`, `allowed_callers`, `run_in_background`, and `describe_intent` as one family, with the shared shape spelled out (per-tool `tool_options` key, admin capability gate, injection or metadata mechanism, self-spawn stripping obligation, token cost). Plus a `librechat.example.yaml` entry, and a cross-reference from the activity-labels docs explaining the two-layer labeling model (§11.2) so admins understand they compose rather than compete. This page is the deliverable that turns four features into a convention, so it is not optional.
|
||||
Loading…
Add table
Add a link
Reference in a new issue