Skip to content

feat: Add a Fixed Tool Dispatcher for Mid-Conversation Tools - #614

Open
Shabonasar wants to merge 2 commits into
LibreChat-AI:mainfrom
Shabonasar:feat/tool-dispatcher
Open

Shabonasar wants to merge 2 commits into
LibreChat-AI:mainfrom
Shabonasar:feat/tool-dispatcher

Conversation

@Shabonasar

@Shabonasar Shabonasar commented Oct 15, 2026 •

Copy link
Copy Markdown

Summary

Providers serialize tools ahead of the system prompt, so binding a tool in the middle of a conversation (an MCP server switched on, a tool found with tool_search) resets the prompt cache from the first byte. With the new opt-in AgentInputs.toolDispatch, a fixed call_tool dispatcher is bound from the first turn; deferred registry tools are reached through it, so tools stays byte-identical while the host announces new tools in the history. Without the option nothing changes.

Related to LibreChat-AI/LibreChat#17102. Depends on #613 (prompt-cache harness). Used by the LibreChat PR "tool groups and the SDK dispatcher" behind endpoints.agents.promptCache.stableContext.

How it works

turn 1:  [tools: A B call_tool][S] u1 a1
turn 3:  host announces "tool X is available, call it with call_tool" in a user message
         [tools: A B call_tool][S] u1 a1 u2 a2 [announce X] u3
model:   call_tool {name: X, arguments: {...}}
ToolNode: canonicalize -> X(args)  -> hooks / approval / HITL see X -> execute -> ToolMessage answers call_tool
stored:  tool_call { name: call_tool, args, dispatched: {name: X, args} }   (history names only declared tools)
  • A dispatcher call is resolved to its target (same call id, arguments coerced and validated against the target schema) before hooks, approval, HITL and execution. resolveDispatchedToolCall is exported for permission layers outside ToolNode.
  • A direct call to a dispatchable tool is rewritten into a dispatcher call in the assistant message. Deferred tools are never bound while the dispatcher is on; tool_search returns full names and schemas and points to the dispatcher.
  • LCTool.disabled keeps a tool bound but refuses its calls with a tool error before any hook. A schema the validator cannot apply becomes a tool error, not an exception. ToolDispatchConfig.canDispatch lets a host narrow the targets.

Measurements

G2: web search + 3 MCP servers toggled mid-chat, 8 turns dev lever off lever on uncached prompt tokens, dev -> lever on
OpenAI chat format (LiteLLM endpoint) 17.1% 23.4% 74.1% 93318 -> 62523
Anthropic Messages format (LiteLLM endpoint, cache_control ignored by the backend) 28.6% 22.8% 73.7% 95882 -> 62680

Run: group G2 measured on Qwen3.8-27B (vLLM behind NVIDIA Dynamo, through LiteLLM), OpenAI chat and Anthropic Messages, dev / lever off / lever on; the same self-hosted model answers both formats, the real OpenAI and Anthropic APIs were not measured; full method in #17102. With the lever on, the 13 requests of each G2 run have one tools sha and one system sha, and every call_tool was executed.

Note: the backend caches in 1600-token blocks, so a request reuses cache only in whole blocks and the first turns of a short conversation (prompt under two blocks) cannot hit; shares are therefore lower on short chats than on long ones.

Testing

npx jest src/tools/__tests__/toolDispatch.test.ts src/specs/prompt-cache/tool-dispatch.test.ts
  • toolDispatch.test.ts: unwrapping, refusals, schema validation, hooks and HITL on the target, direct-call rewriting, tool_search guidance, binding.
  • tool-dispatch.test.ts (harness): for every provider case a deferred tool is announced and called through the dispatcher; tools stay byte-identical, every request starts with the previous one, the result answers the dispatcher call and the history names only declared tools.

Type of change

  • Feature

Checklist

…Position

Adds conversation_instructions: context that is stable within a conversation.
It stays in the system message right after the static instructions and is never
moved: its own content block with a cache breakpoint for Anthropic and
OpenRouter, a block followed by a cache point for Bedrock, appended text for
other providers. additional_instructions keeps its per-turn behaviour.

A host that passes conversation_instructions gets the compaction summary, both
when created inside a run and when restored as initialSummary, as one stable
user message right after the system message, with a breakpoint of its own while
the request has one left. Token accounting counts it as instruction overhead.
Both paths render the same carrier text, so the requests after a compaction
form one continuous prefix. Hosts that do not pass the field keep the earlier
placement and accounting.

Adds a prompt cache harness (src/specs/prompt-cache) that runs declarative
scenarios through Run and each provider class, captures the serialized request
before the network, and asserts the cache invariants per provider.
Providers serialize `tools` ahead of the system prompt, so binding a tool
mid-conversation resets the prompt cache from the first byte. With the new
opt-in `AgentInputs.toolDispatch`, a fixed `call_tool` dispatcher is bound
from the first turn and deferred registry tools are reached through it, so
`tools` stays byte-identical while the host announces new tools in the
history.

- ToolNode canonicalizes every batch before hooks, approval, HITL, and
  execution: a dispatcher call becomes its target (same call id, arguments
  coerced and validated against the target schema), so PreToolUse, approval
  policies, `action_requests`, and ON_TOOL_EXECUTE see the real tool. The
  same `resolveDispatchedToolCall` is exported for permission layers outside
  ToolNode.
- Results and completion events keep the model form: the ToolMessage answers
  the dispatcher call under its name, and the completed tool call carries the
  real tool in `dispatched`, so stored history names only declared tools.
- A direct call to a dispatchable tool runs the same way and is rewritten into
  a dispatcher call in the assistant message, since the model's own message
  would otherwise put an undeclared tool name into the history.
- Deferred tools are never bound while the dispatcher is enabled, even after
  discovery. tool_search results carry full names and parameter schemas and
  point the model to the dispatcher.
- `LCTool.disabled` keeps a tool bound but refuses its calls with a tool error
  before any hook, without changing `tools`.
- A schema the validator cannot apply, or any error while resolving a call,
  becomes a tool error message instead of an exception.
  `ToolDispatchConfig.canDispatch` limits dispatchable tools to those the host
  declared.
- Dispatcher calls, dispatchable tools, and disabled tools are excluded from
  eager prestart.

Tests: unit coverage in toolDispatch.test.ts; the prompt-cache harness gains a
dispatched-call scenario for every provider case.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant