Skip to content

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

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

Shabonasar wants to merge 9 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. This PR adds an opt-in AgentInputs.toolDispatch. It binds a fixed call_tool dispatcher 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. Without the option nothing changes.

Related to LibreChat-AI/LibreChat#17102 (tracking; design discussion LibreChat-AI/LibreChat#17481). Base: main, no dependency on other PRs.

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: unwrap -> X(args) -> hooks / approval / HITL / execution see X -> ToolMessage answers call_tool
stored:   tool_call { name: call_tool, args, dispatched: {name: X, args} }   history names only bound tools
piece behaviour
toolDispatch: true | { canDispatch } binds call_tool after the other tools; deferred tools (defer_loading: true) are never bound, even once discovered; canDispatch narrows the targets
dispatcher call resolved to its target (same call id, arguments parsed as a JSON object) before hooks, approval, HITL and execution; result and completion event keep the dispatcher form with the target in dispatched
direct call to a deferred tool runs the same way and is rewritten into a call_tool call in the assistant message, including provider-native copies (content blocks, raw calls, Responses output items)
LCTool.disabled the tool stays bound; its calls are refused with a tool error before any hook, with or without the dispatcher
tool_search with toolDispatch results carry full names and parameter schemas and tell the model to call found tools with call_tool
eager event execution dispatcher calls, deferred and disabled tools are not prestarted
  • The dispatcher schema is counted with the other tool schemas. TOOL_DISPATCHER_NAME is exported.
  • A target that is not deferred, is rejected by canDispatch, or has arguments that are not a JSON object is answered with a tool error and does not run.

Type of change

  • Feature

Testing

Tested environments/configuration:

Automated tests:

npx jest src/messages src/llm src/graphs src/tools src/agents src/summarization
npx tsc --noEmit
  • This PR: 4808 passed. 3 suites fail because they need API keys (anthropic, google, vertexai llm.spec.ts).
  • ToolNode.dispatchTiming.test.ts: target runs on the host and answers under the dispatcher call, non-deferred target refused, direct call stored as a dispatcher call, canDispatch refusal, disabled tool, failed dispatched call completed under the dispatcher call, calls arriving through Send.
  • AgentContext.test.ts (binding, discovered deferred tool stays unbound, no dispatcher without opt-in, schema token count), ToolSearch.test.ts, aggregator.test.ts (dispatched kept on the completed part), stream.eagerEventExecution.test.ts (no prestart).
  • All five SDK PRs together: 6987 passed. Failing: the 3 suites above, plus durability-checkpoint.integration.test.ts and persistent-handoffs.test.ts, which need MongoDB and fail on main too.
  • LibreChat agent suites against all five together: packages/api src/agents 6913 passed (1 failure in handlers.shutdown.spec.ts, a shutdown timing test); api server/controllers/agents 1371 passed.

Risk / compatibility

Checklist

  • I reviewed my own changes
  • Relevant tests have been added or updated
  • Existing relevant tests pass
  • The change does not introduce new warnings or errors
  • User-facing or complex behavior is documented where necessary
  • Required dependency changes have been merged/published (none)

…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.
With prompt caching on Bedrock, additional_instructions was appended to the system
message after its last cachePoint. Bedrock reads tools, system and messages in that
order, so a tail that changes between turns sits in front of the whole history and
every message checkpoint misses.

The tail now moves to a message right before the newest user message, as for
Anthropic and OpenRouter, with one cachePoint at the end of the history in front of it
and one at the end of the request. The conversation block keeps its own cachePoint
only while the four-checkpoint limit leaves room for it (tools, stable block, history,
request end come first). Without prompt caching the tail stays in the system message.
…Marks Two Messages

The graph can place a second message checkpoint for the latest human message. Beside the
system checkpoints and the tool list that overran the limit of four in two places:
- the conversation block kept its own cachePoint only on the moved-tail path; the Bedrock
  system message now drops it whenever the checkpoints already in the messages leave no
  room, with or without a moved tail;
- the opening message of the history in front of a moved tail kept the graph's checkpoint
  on top of the two the tail path places; it is stripped now.
An opt-in call_tool dispatcher is bound from the first turn; ToolNode unwraps its calls to the deferred target before hooks and execution, and tool_search points to it, so tools can be added mid-conversation without changing the bound tools.
@Shabonasar

Copy link
Copy Markdown
Author

Reduced to simplify dependencies and the review: based on main only, no dependency on other PRs (#613 no longer included), +4178 -> +1105 lines.

  • Moved out: the per-provider dispatch harness and full test matrix (measurement kit), docs (now in the description). Dropped: schema validation of dispatched arguments and the resolveDispatchedToolCall export; arguments must still be a JSON object.
  • Kept: opt-in toolDispatch with a fixed call_tool, deferred tools never bound, unwrapping before hooks/approval/HITL, direct calls stored as call_tool, LCTool.disabled, canDispatch, tool_search guidance; regression tests fail without the fix.
  • Checked: SDK suites; LibreChat agent suites against all five SDK PRs together; live Claude Haiku 5.5: tools toggled (G2) 85.5% cached, 0 misses, no API errors.

No file changes (tree of main c56fc5e). Joins an earlier snapshot that the branches of
the series share (83eea26), so any two of them have a single merge base and merge cleanly.
@Shabonasar

Copy link
Copy Markdown
Author

Branch updated with a merge commit (no file changes) so that the agents PRs of LibreChat-AI/LibreChat#17102 merge together with a plain git merge; the PR diff is unchanged. All five together: integration/prompt-cache.

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