Skip to content

fix(tools): persist tool output reference labels and keep keys unique across runs - #629

Open
Shabonasar wants to merge 4 commits into
LibreChat-AI:mainfrom
Shabonasar:fix/stable-tool-output-ref-labels
Open

Shabonasar wants to merge 4 commits into
LibreChat-AI:mainfrom
Shabonasar:fix/stable-tool-output-ref-labels

Conversation

@Shabonasar

@Shabonasar Shabonasar commented Oct 20, 2026 •

Copy link
Copy Markdown

Part of LibreChat-AI/LibreChat#17102

Summary

With tool output references on, ToolNode labels each tool result with [ref: tool<i>turn<n>] in the request it sends live. The label was shown only while the key was in the current run's registry, and every run restarted at turn0. As a result, a restored history lost the labels: the first request of each later turn missed the prompt cache, and Claude dropped thinking blocks bound to the old prefix. Keys also repeated between turns, so {{tool0turn0}} from an old turn silently resolved to the current turn's output. With this PR the label is the stored name of the output: it is saved with the result, restored with the history, and turn numbers continue across runs. A restored label is kept in a run with the references off too (in LibreChat: the code interpreter switched off since the round ran), so switching the code interpreter off or on does not rewrite earlier tool results.

Fixes #627
Fixes #669

Related to LibreChat-AI/LibreChat#17102. Base: main, no dependency on other PRs. LibreChat needs no change: the fields ride in the stored tool_call part.

How it works

live:     ToolNode key tool0turn7 -> ON_RUN_STEP_COMPLETED.tool_call { output, ref, unresolved_refs? } -> host stores it
restore:  formatAgentMessages: ref -> ToolMessage.additional_kwargs._refKey
          Run: turn counter = highest restored turn + 1   (keys never repeat)
request:  annotateMessagesForLLM labels by _refKey, no registry lookup, after truncation (byte-equal to live)
  • The completion event carries ref / unresolved_refs for direct tools, event-driven tools (batch and early completion) and eager executions.
  • An old key is not in the registry, so it stays literal with the existing [unresolved refs: ...] warning.
  • Handoff agents share one registry (one seed); subagents have their own run and history.
  • Tests that expected "no label when the key is missing from the registry" now expect the label.
  • annotateMessagesForLLM labels a restored _refKey also without a registry (references off in this run). Without any ref metadata in the history it still returns the input array unchanged; the early return without a registry is gone.
  • Not changed: a placeholder in the same parallel batch as the call it names stays literal without a warning.

Changes from the previous version

  • A restored label no longer depends on the references being on in the current run (Restored tool output label is dropped when tool output references are off in the run #669, report: execute_code switched off mid-chat, 57.1% cached, the cache stopped at the first tool round). +64 -40 in 3 files, of which source +5 -8; the PR against main is now +544 -158.
  • Three existing tests asserted the early return and are changed: annotateMessagesForLLM.test.ts "does not iterate any message when the feature is disabled (registry undefined)" is replaced by "labels a restored _refKey without a registry (feature off in this run)", and "returns the input array reference when registry is undefined" now uses a message without _refKey (renamed "... without a registry when no ToolMessage carries metadata"); invoke.test.ts "passes messages unchanged when no registry is exposed on context (e.g. summarization)" now expects the label (renamed "labels a restored _refKey when no registry ...").
  • Live run of this change, LibreChat integration/prompt-cache (stableContext: true), custom endpoint with provider: anthropic, Qwen3.8-27B (vLLM behind NVIDIA Dynamo, through LiteLLM); 7 turns, bash_tool round on turn 2, code interpreter off on turns 4-5, on again on turn 6; two runs each:
agents integration/prompt-cache cached misses prefix broken at the label
before this change 62.8% / 63.2% 1 / 2 (turn 4: 6400 of 11200 read) turns 4 and 6
with this change 66.1% / 68.2% 0 / 0 none

Re-enabling on turn 6 costs little before the change because the provider still holds the labelled prefix of turns 1-3. Not run: the Anthropic API itself; web_search was loaded but not called.

Type of change

  • Bug fix

Testing

Tested environments/configuration:

The table comes from an earlier run, before the last change; with the references on in every turn, as there, the requests are the same. Real Anthropic API through LibreChat, 6 turns, adaptive thinking, code interpreter running code, chained bash_tool calls, MCP tool, restart after turn 3, turn 5 references a key from turn 1. "series" is the LibreChat series plus agents #613, #614, #615, #619 (LibreChat-AI/LibreChat#17102); dev is LibreChat dev with agents main. "before" used a 1h cache TTL, "after" 5m.

model LibreChat agents cache hit misses dropped thinking blocks 400
Haiku 5.5 series before 87.9% 4 28 0
Haiku 5.5 series this PR 92.9% 0 0 (2nd run: 1, from #618) 0
Sonnet 5.5 series before 86.1-88.4% 3 10 0
Sonnet 5.5 series this PR 90.8% 0 0 0
Haiku 5.5 dev before 88.6% 5 0 0
Haiku 5.5 dev this PR 87.5% 5 0 0

With the series, keys run tool0turn0...tool0turn8 without repeats, and the turn-5 probe returns [unresolved refs: tool0turn0] instead of a wrong output. On dev the labels are now stable too; its misses come from other restore differences that the series fixes.

Automated tests:

npx jest src/messages src/llm src/graphs src/tools src/agents src/summarization
npx tsc --noEmit
  • This PR: 5683 passed. 5 suites fail on main too: 3 need API keys (anthropic, google, vertexai llm.spec.ts), LocalExecutionTools.test.ts and localToolResultCaps.test.ts fail in the Alpine container.
  • ToolNode.outputReferences.test.ts: key and unresolved refs on the completed tool_call (direct and event-driven), none when the feature is off, an earlier run's key reported as unresolved, a run seeded from the keys in its input history. invoke.test.ts: hydrated run 1 messages labelled, run 2 keys not repeating them.
  • formatAgentMessages.tools.test.ts: stored key and unresolved refs restored and labelled as live, including the parts stored by the content aggregator. toolOutputReferences.test.ts, annotateMessagesForLLM.test.ts: turn counter continuity, labelling by _refKey without a registry entry and without a registry. invoke.test.ts "sends a restored tool round with the same bytes when tool output references are off in this run": the OpenAI request built from a restored round is byte-equal with and without the registry (fails without the change at the tool message).
  • All five SDK PRs together (fix: Keep Per-Conversation Context and the Summary at a Fixed System Position #613, feat: Add a Fixed Tool Dispatcher for Mid-Conversation Tools #614, fix: Restore Tool Rounds Exactly as the Live Request Sent Them #615, feat: Replay Signed Reasoning in Restored History #619, fix(tools): persist tool output reference labels and keep keys unique across runs #629): 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: packages/api src/agents 6913 passed (1 failure in handlers.shutdown.spec.ts, a shutdown timing test); api server/controllers/agents 1371 passed.

Screenshots / recordings

No user-facing change.

Risk / compatibility

  • A restored history now carries the labels the live request had, also in a run with the references off (this includes summarization calls, which have no registry); such a run walks the messages once to find ref metadata instead of returning at once. A history stored before this change has no ref and stays unlabelled, as it is today.

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
  • Required dependency changes have been merged/published (none)
  • Required documentation PR: N/A

… across runs

[ref: toolNturnM] labels were added only when the key was live in the current
run's registry, and every run restarted its turn counter at 0. A restored
history therefore had no labels (the first labelled result of each later turn
changed the prefix and missed the provider cache), and keys repeated between
turns, so {{tool0turn0}} from an old turn silently resolved to the current one.

- ON_RUN_STEP_COMPLETED tool_call now carries `ref` and `unresolved_refs`
  (direct, early, eager and batch completions); the content aggregator keeps them
- formatAgentMessages restores them as `_refKey` / `_unresolvedRefs`
- annotateMessagesForLLM labels by `_refKey` without a registry lookup
- Run seeds the registry turn counter past the highest restored key, so a
  placeholder naming an older turn is reported as unresolved

Fixes LibreChat-AI#627
… across runs

[ref: toolNturnM] labels were added only when the key was live in the current
run's registry, and every run restarted its turn counter at 0. A restored
history therefore had no labels (the first labelled result of each later turn
changed the prefix and missed the provider cache), and keys repeated between
turns, so {{tool0turn0}} from an old turn silently resolved to the current one.

- ON_RUN_STEP_COMPLETED tool_call now carries `ref` and `unresolved_refs`
  (direct, early, eager and batch completions); the content aggregator keeps them
- formatAgentMessages restores them as `_refKey` / `_unresolvedRefs`
- annotateMessagesForLLM labels by `_refKey` without a registry lookup
- Run seeds the registry turn counter past the highest restored key, so a
  placeholder naming an older turn is reported as unresolved

Fixes LibreChat-AI#627
@Shabonasar

Copy link
Copy Markdown
Author

Updated to current main and reduced to simplify the review: no dependency on other PRs, +546 -> +480 lines.

  • Code unchanged (one block in stream.ts written shorter, same behaviour); tests consolidated: the separate ToolNode.persistedReferences.test.ts is folded into the existing ToolNode.outputReferences, formatAgentMessages.tools and toolOutputReferences suites.
  • Checked: SDK suites (4803 passed, the 3 llm.spec.ts suites need API keys); all five SDK PRs together 6987 passed; LibreChat agent suites against all five.

Shabonasar added a commit to Shabonasar/agents that referenced this pull request Nov 2, 2026
Convenience build for trying the prompt-cache series together; not for review.
Tree = main 708c911 + the following PRs (they merge cleanly with each other):

- LibreChat-AI#613 fix/prompt-cache-stable-dynamic-placement b38670e
- LibreChat-AI#614 feat/tool-dispatcher 83298af
- LibreChat-AI#615 fix/responses-tool-round-restore 35020f0
- LibreChat-AI#619 feat/reasoning-replay c618501 (on LibreChat-AI#615)
- LibreChat-AI#629 fix/stable-tool-output-ref-labels 4575818

Used by LibreChat branch integration/prompt-cache (LibreChat-AI/LibreChat#17102).
A tool result restored with its stored _refKey was labelled only while
the run had a registry. With code execution switched off since the round
ran, the run has none, the label disappeared and the request stopped
being a prefix continuation of the earlier turns at that tool result.
Shabonasar added a commit to Shabonasar/agents that referenced this pull request Nov 6, 2026
Convenience build for trying the prompt-cache series together; not for review.
Tree = main 708c911 + the following PRs and fixes:

- LibreChat-AI#613 fix/prompt-cache-stable-dynamic-placement b38670e
- LibreChat-AI#614 feat/tool-dispatcher 83298af
- LibreChat-AI#615 fix/responses-tool-round-restore 35020f0
- LibreChat-AI#619 feat/reasoning-replay c618501 (on LibreChat-AI#615)
- LibreChat-AI#629 fix/stable-tool-output-ref-labels 4575818
- fix/prompt-cache-dynamic-only-tail c087c4f
- LibreChat-AI#629 follow-up 42c7cbc: keep restored tool output
  labels when references are off in the run

Resolved: the dynamic-tail condition in AgentContext (LibreChat-AI#613 and the fix both
change it): the tail moves whenever it is set, for Anthropic, OpenRouter and
Bedrock with prompt caching; the system message is omitted when nothing is
left in it.

Used by LibreChat branch integration/prompt-cache (LibreChat-AI/LibreChat#17102).

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

1 participant