Skip to content

chore: merge upstream/main (v3.2.x); prune customizations per upstream PR status#1

Merged
JumpLink merged 182 commits into
mainfrom
chore/sync-upstream-2026-06
Jun 19, 2026
Merged

chore: merge upstream/main (v3.2.x); prune customizations per upstream PR status#1
JumpLink merged 182 commits into
mainfrom
chore/sync-upstream-2026-06

Conversation

@JumpLink

Copy link
Copy Markdown
Collaborator

Sync to upstream/main. Kept: vision (re-engineered onto upstream streaming; LibreChat#11501 rejected, load-bearing), custom reranker (danny-avila#66), processArtifact/MCP-tuple/loadRuntimeTools, custom messagesStateReducer. Dropped: handoff wrong-tool (danny-avila#61 rejected, upstream supersedes), user-after-tool guard (danny-avila#55 rejected, upstream handles in MultiAgentGraph). Verified: build (tsdown+tsc, 0 errors) + 2337 unit tests pass. See faktenforum/ai-chat-interface#33.

dustinhealy and others added 30 commits March 31, 2026 13:48
Pull the primary agent's human-readable name from AgentContext and
include it in traceMetadata for both processStream and generateTitle.
This lets Langfuse users filter/search traces by friendly agent name
(e.g. "DWAINE") instead of only by agent ID.

Purely additive — no existing fields changed or removed.
* chore: update handlebars to v4.7.9 and ts-jest to v29.4.6 in package.json

* chore: update lint-staged to v16.4.0 in package.json and refresh package-lock.json

* chore: update @rollup/plugin-commonjs to v29.0.2 in package.json and refresh package-lock.json

* chore: refresh package-lock.json after updates to dependencies

* chore: remove handlebars dependency from package.json and refresh package-lock.json
…ila#82)

* chore: update axios to version 1.13.6 in package.json and refresh package-lock.json

* v3.1.63
* fix: preserve server_tool_use block types in multi-turn Anthropic conversations

Server tool blocks (web_search) with srvtoolu_ IDs were losing their
type through the message pipeline, becoming tool_use blocks. On the
second turn, the API rejected the request because tool_use requires a
matching tool_result, but server tools use inline web_search_tool_result.

Three fixes:
- Add server tool types to Anthropic allowed types in modifyContent
- Correct tool_use blocks with srvtoolu_ IDs back to server_tool_use
- Filter server tool calls from string content path

* fix: filter empty text blocks and fix empty content fallback

- Filter out empty text blocks ({type:'text', text:''}) from
  _formatContent output — these can appear when the model emits a
  content_block_start with empty text before server tool calls
- Fix empty-string-content fallback: when only server tool calls exist,
  keep them as tool_use rather than emitting an empty text block

* fix: add final-pass sanitizer for server tool blocks in formatted messages

The _formatContent corrections handle individual blocks correctly, but
somewhere between formatting and the API request, tool_use blocks with
srvtoolu_ IDs can still survive. Add _fixServerToolBlocks as a catch-all
that runs on the final merged messages array — mutates any remaining
tool_use blocks whose id starts with srvtoolu_ back to server_tool_use,
regardless of how they got there (state deserialization, chunk concat
variance, etc.).

* fix: produce server_tool_use at source, remove extra traversal

_convertLangChainToolCallToAnthropic always produced type:'tool_use'
even for srvtoolu_ IDs — fix it to emit 'server_tool_use' directly.
Remove the _fixServerToolBlocks full-message traversal since the
per-block corrections in _formatContent + the factory fix now cover
every code path.

* fix: rebuild server tool blocks cleanly regardless of input type

The previous correction only matched blocks with type==='text'. After
adding server tool types to the modifyContent allowlist, blocks arrive
with their correct type (server_tool_use, web_search_tool_result) but
carry extra streaming properties (input, index) that the API rejects.

Now the checks match on the srvtoolu_ ID prefix regardless of the
block's current type, and always rebuild a clean block with only the
properties the API expects — no leftover streaming artifacts.

* fix: stop re-including server tool calls in empty-content fallback, add debug log

The empty-string-content fallback was re-including server tool calls
(as server_tool_use blocks) when no client tool calls existed, but
their web_search_tool_result blocks are lost when content is a string.
This caused "web_search tool use without web_search_tool_result" errors.

Also adds a temporary debug log that fires when an AI message with
srvtoolu_ tool calls is formatted — prints whether content is string
vs array and the content block types. This will identify the exact
state of the message on the second turn.

* chore: remove server-tool-debug logging
…y-avila#105)

* 🔍 fix: exclude deferred tools from instruction token accounting

Deferred tool definitions (used for tool_search) were counted in
`toolSchemaTokens` and `getTokenBudgetBreakdown().toolCount` even though
they are never bound to the model until discovered. This mismatch with
`getEventDrivenToolsForBinding` inflated the reported instruction token
count (e.g. 292 tools for a LibreChat MCP registry) and produced
spurious context-overflow errors.

Both paths now go through a new `getActiveToolDefinitions()` helper that
filters out `defer_loading === true` entries unless they are already in
`discoveredToolNames`, matching the filter applied at bind time.

Refs: danny-avila/LibreChat#12702

* chore: address review feedback on token accounting fix

- Correct JSDoc on getActiveToolDefinitions: it filters for token
  accounting, not bind-time (code_execution-only tools still pass).
- Note the staleness asymmetry on getTokenBudgetBreakdown: toolCount is
  live after markToolsAsDiscovered but toolSchemaTokens is a snapshot.
- Add regression test pinning the snapshot semantic so future changes
  must intentionally re-evaluate whether discovery should recompute.
…ve display (danny-avila#106)

Extends `ThinkingConfig` with a `ThinkingConfigAdaptive` variant carrying
an optional `display: 'summarized' | 'omitted'` field, and overrides the
`thinking` property on `AnthropicClientOptions` via `Omit<AnthropicInput,
'thinking'>` so callers can pass `{ type: 'adaptive', display: 'summarized' }`
without casting.

Claude Opus 4.7 omits reasoning content by default unless the caller opts
in via `thinking.display = 'summarized'`, so downstream consumers
(LibreChat `packages/api/src/endpoints/anthropic/helpers.ts`) previously
had to cast through `AnthropicClientOptions['thinking']`.

Ref: https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7#thinking-content-omitted-by-default
…avila#108)

* 🔊 fix: warn when `trigger.type` is unrecognized

`shouldTriggerSummarization` silently returned `false` whenever the
configured `trigger.type` was not one of the three it implements
(`token_ratio`, `remaining_tokens`, `messages_to_refine`). That turned
any upstream schema/SDK drift into a silent no-op — summarization just
never fired, with no signal to the operator.

Emit a one-time `console.warn` per unrecognized type so the
misconfiguration surfaces in logs without spamming every agent turn.

Behavior is unchanged for all valid trigger types and for the
no-trigger-configured default.

* 🧹 fix: bound the unrecognized-trigger dedup set

Per Codex review: the dedup `Set` for warned trigger types was
process-global and grew unboundedly. Consumers that thread dynamic or
user-provided values through `trigger.type` could accumulate unique
keys in long-lived workers — a minor but real memory vector.

Cap the set at 32 entries and evict the oldest on overflow (Set
preserves insertion order, so this is FIFO-ish). 32 is well above the
handful of typos a legitimate deployment would ever see, and the dedup
contract still holds: we never warn twice for a type that is currently
in the set.
danny-avila#109)

* 🔊 fix: include provider, model, and HTTP status in summarization error logs

When summarization fails, host applications (e.g. LibreChat's default console
formatter) frequently strip metadata from the winston info object, leaving
users with only a generic "Summarization LLM call failed" message — no
indication of which provider/model misbehaved or why.

Fold the critical bits (provider/model label and HTTP status when present)
into the log message string itself so they survive any downstream formatter.
Keep the structured metadata bag intact for JSON/structured backends. Apply
the same treatment at each summarization error site (primary failure,
fallback failure, metadata-stub fallback).

* 🛡️ fix: guard nullish throws, attribute fallback failures to the right provider

Two issues surfaced by code review on the initial logging pass:

1. `describeProviderError` cast `err` to a non-nullable object and read
   `.status`/`.statusCode`/`.response?.status`; TypeScript's no-unnecessary-
   condition rule then stripped the optional chaining on `errObj`. If a
   provider throws `null` or `undefined` (both legal throw targets in JS),
   the status extraction would itself raise `TypeError` and mask the
   original summarization failure. Extract status through a dedicated
   helper with an explicit `err == null` guard so the intended metadata-stub
   recovery path is preserved for any thrown value.

2. The fallback-exhausted warning reused the primary provider/model label
   for the error thrown out of `tryFallbackProviders`. That error actually
   comes from the last attempted fallback and is not necessarily the
   primary integration. Introduce `describeFallbackError` which labels the
   log with the list of fallback providers attempted, so operators are
   pointed at the right deployment when diagnosing incidents.

Reviewed-by: Codex (P1 + P2 findings on PR danny-avila#109).

* 🛡️ fix: defend against malformed fallback config in error-logging path

`describeFallbackError` previously trusted that every element in the
`fallbacks` array was a non-null object with a `provider` field. If
runtime config is malformed (null/undefined entries, missing provider),
reading `.provider` from inside the surrounding `catch (fbErr)` block
would throw a fresh TypeError, promoting a recoverable summarization
failure into an uncaught exception and blocking the metadata-stub
fallback.

Widen the parameter to `ReadonlyArray<unknown>`, filter out malformed
entries, and fall back to `no-fallbacks` label when none remain. The
logging path is now total on any input.

Reviewed-by: Codex (P2 finding on PR danny-avila#109, commit 8b6d27a).

* 🛡️ fix: validate fallback list shape before mapping in error formatter

`describeFallbackError` previously assumed `fallbacks` was an array because
the type signature said so. In practice the value originates from
`clientConfig.clientOptions` via an unchecked `unknown` cast, so a
malformed runtime config (e.g. a string with truthy `length`) would reach
`.map(...)` and throw `TypeError: fallbacks.map is not a function` —
escaping the surrounding `catch (fbErr)` and blocking the metadata-stub
recovery path.

Widen the parameter to `unknown`, coerce to an empty array when it isn't
one. Also coerce the call site's `fallbacks` binding with `Array.isArray`
so `tryFallbackProviders` doesn't see a non-array either.

Reviewed-by: Codex (P2 finding on PR danny-avila#109, commit dc5456e).
…a#111)

Adds a `validateEdgeAgents()` check in the `MultiAgentGraph` constructor
that throws a descriptive error when any edge (on `from` or `to`) points
at an agent not present in `agentContexts`.

Without this guard, `StateGraph.compile()` later throws the opaque
`Found edge ending at unknown node "<id>"` error that gives callers no
indication of why — this was the surface error from LibreChat's
#12726 where the host passed `edges` into a multi-agent run without
also loading the sub-agents. The new error explicitly instructs callers
to include every referenced agent or filter orphaned edges upstream.
…#112)

Move `initializeModel` inside the try/catch in `executeSummarizationWithFallback`
so that errors like `Unsupported LLM provider: <name>` are surfaced through the
`log('error', ...)` path and fall through to the metadata-stub fallback, instead
of bubbling up silently.

This is the defense-in-depth half of the fix for the LibreChat Discussion #12614
report: when a caller forwards an unrecognized summarization provider name
(e.g. a custom-endpoint label), the error is now observable in operator logs
rather than only the client UI.
…vila#87)

* feat: add inert hook registry and executor scaffolding (phase 1/1)

Introduces `src/hooks/` with types, registry, and executor for a 12-event
hook lifecycle. Purely additive — not exported from `src/index.ts` and not
yet wired into `Run`, `Graph`, or `ToolNode`. Integration lands in the
next PR so this change is shippable independently with zero behavioral
impact on hosts that don't opt in.

- Discriminated-union input/output per event; generic `HookCallback<E>`
  and `HookMatcher<E>` for type-safe registration.
- `HookRegistry` uses `Map<sessionId, bucket>` (not `Record`) to avoid
  O(n^2) churn under parallel registration in multi-tenant hosts.
- `executeHooks` fans out in parallel, races hooks against a combined
  parent + timeout `AbortSignal` so non-listening hooks still time out,
  folds decisions with `deny > ask > allow` precedence, accumulates
  `additionalContext`, and self-removes `once: true` matchers after any
  successful hook. Errors are non-fatal: swallowed into the aggregated
  result and routed through an optional winston logger (falling back to
  `console.warn`), with internal matcher errors suppressed.

47 tests under `src/hooks/__tests__/` cover registry scoping, regex
matching, precedence folding, once-self-removal (success/failure/mixed),
timeout enforcement (including non-listening hooks), error non-fatality,
and parent signal combination.

* fix(hooks): address review findings — rename field, wire updatedOutput, cache patterns

Resolves the comprehensive review on danny-avila#87:

- Rename `HookMatcher.matcher` -> `HookMatcher.pattern` to fix the
  self-referential naming (`m.matcher` reads as "the matcher's matcher").
- Wire `PostToolUseHookOutput.updatedOutput` through `AggregatedHookResult`
  and `fold()`. The type was previously a promise the executor didn't
  fulfill — a hook returning `{ updatedOutput: ... }` was silently dropped.
- Correct the JSDoc on `updatedInput`/`updatedOutput`. The previous copy
  claimed non-determinism "in Promise.all resolution order" — but
  `Promise.all` preserves input order, so the fold is deterministic in
  registration order (outer loop over matchers, inner loop over each
  matcher's hooks). Updated the `executeHooks` function docstring to match.
- Add regex compilation cache and length bound to `matchers.ts`. Patterns
  compile at most once per unique string and are reused across calls;
  patterns over MAX_PATTERN_LENGTH (512) are rejected outright as a cheap
  ReDoS mitigation. Documented the trust model (patterns are trusted input;
  hosts must validate user-supplied patterns upstream).
- Document the wildcard-only semantics on query-less events: a non-empty
  pattern on an event that doesn't supply a matchQuery (RunStart, Stop,
  etc.) never matches.
- Document the `once: true` concurrent-dispatch limitation: two parallel
  `executeHooks` calls each snapshot the matcher list, so `once` means
  "once per call", not "once globally". Matches Claude Code's semantics.
  Added a test that pins this behavior.
- Merge `matchers.filter(...)` + task-build loop into a single pass per
  AGENTS.md "consolidate sequential O(n) operations."
- Scope `eslint-disable no-console` to the single `console.warn` call in
  `reportErrors` instead of disabling it file-wide.
- Fix the timeout-ignoring-hook test: clear the dangling `setTimeout` and
  assert the error surfaces an abort-shaped label.
- Add tests: multi-hook `preventContinuation` (first-writer-wins on
  stopReason, flag without reason), `updatedOutput` flow + registration
  order, registration-order determinism for `updatedInput`, pattern
  length bound, compilation cache hit/miss/clear, concurrent `once`
  dispatch. Total: 47 -> 59 tests.

Findings audited and resolved:
- #1 ReDoS: cache + length bound + trust model doc (full fix blocked on
  host-side validation)
- #2 Wrong updatedInput ordering JSDoc: fixed
- #3 Dead `updatedOutput` type: wired through
- danny-avila#4 Concurrent `once` race: documented + test
- danny-avila#5 `HookMatcher.matcher` naming: renamed to `pattern`
- danny-avila#6 `WideCallback` dead code: rejected as inaccurate (used in `runHook`)
- danny-avila#7 Eslint-disable scope: line-scoped
- danny-avila#8 File-path comments: rejected; matches existing project convention
- danny-avila#9 Two-pass filter: single pass
- danny-avila#10 Regex recompilation: cached
- CL-4 Undocumented wildcard semantics: documented
- RT-3 Timeout test dangling timer: fixed + error content verified
- RT-4 No multi-hook preventContinuation test: added

* perf(hooks): atomic once, bounded LRU, ReDoS heuristic, shared signal

Rework the phase-1 hooks scaffolding for the multi-tenant scale we
actually operate at. These are behavior changes (semantics of `once`
tightened, new pattern-rejection path) but none of them are wired into
the runtime yet — this PR is still inert — so the blast radius is the
hooks directory only.

## Atomic `once: true`

Removal now happens synchronously inside `executeHooks`, between
`registry.getMatchers` and the first `await`. Node's event loop
serialises sync work, so two concurrent `executeHooks` calls can never
both observe the same `once` matcher — whichever call runs its sync
prefix first consumes it, and the loser sees an empty bucket.

Semantic change: `once` is now at-most-one-dispatch, not
at-most-one-successful-execution. If every hook in a `once` matcher
throws, the matcher is still gone. The old retry-on-failure behavior
was Claude Code's model — fine for a single-user CLI, but in a
multi-tenant server it opens a window where concurrent bursts can
re-dispatch a "once" hook non-idempotently. "At most once, ever" is
the correct semantic for a shared registry. Hosts that need retry
should register a normal matcher and self-unregister via the callback
returned from `registry.register`.

Dropped `collectOnceMatchersForRemoval` + its Map/array allocations —
the post-hoc collection step is no longer needed.

Tests: replaced "keeps the matcher when every hook throws" with
"removes the matcher even when every hook in it throws". Added
"fires exactly once across 3 concurrent calls" and "fires exactly once
across 8-way concurrent dispatch when hooks are slow" (the slow test
deliberately uses a 10ms hook to force overlap).

## Bounded LRU regex cache

`matchers.ts` was unbounded. Under multi-tenant use where different
tenants register different patterns, the cache would leak. Capped at
`MAX_CACHE_SIZE = 256` entries with LRU eviction: hits refresh position
(delete-then-set), misses at capacity evict the oldest key. Failed
compiles are cached the same way so a tenant spamming bad patterns
doesn't burn CPU on every call.

Exposed `getMatcherCacheSize` and `MAX_CACHE_SIZE` for test
observability. Tests verify eviction order (cold patterns recompiled
after overflow) and that hot patterns refreshed mid-stream survive
eviction pressure.

## ReDoS heuristic: nested-quantifier detector

`hasNestedQuantifier` walks a pattern linearly, tracks paren depth,
and rejects any pattern where a quantified group contains another
quantifier — the `(a+)+`, `(.*)*`, `(\w+)+` shape that is the #1
catastrophic-backtracking source. Character classes (`[*+?]`) and
escaped quantifiers (`\+`) are correctly ignored. Rejection happens
before `new RegExp`, so adversarial input never reaches the compiler.

This is a floor, not a ceiling — it doesn't catch ambiguous-alternation
ReDoS like `(a|a)+`. Hosts that accept user-supplied patterns must
still validate upstream. Documented in the `HookMatcher.pattern` and
`matchesQuery` JSDoc.

Test verifies that `(a+)+$` on a 35-char adversarial input resolves in
under 50ms (would be seconds without the heuristic).

## Shared `AbortSignal` per matcher

Was: one `AbortSignal.timeout(ms)` + one `AbortSignal.any([parent,
timeout])` per hook. For a matcher with N hooks and the same timeout,
that's N timers + N composite signals.

Now: one signal per matcher, shared across all its hooks. Since hooks
in a matcher already share `matcher.timeout`, they may as well share
the timer. Behavior is identical (all hooks fire and race the same
deadline) but allocation drops from N to 1 per matcher.

## Tests

72 passing (was 59, +13):
- Atomic once: 2 concurrent tests + 1 failure-removal test updated
- LRU: cache size cap, eviction order, hit refresh
- Nested quantifier: classic shapes, deep nesting, escaped chars,
  character classes, false positives on legitimate patterns
- ReDoS mitigation: rejection path, <50ms response on adversarial input

* fix(hooks): fix ReDoS heuristic false-positives on (?:...) groups

Resolves the follow-up review on danny-avila#87. Four findings, all valid.

## #1 MAJOR — hasNestedQuantifier false-positives on group syntax

The previous scanner treated `?` as a quantifier at any depth > 0, but
`?` immediately after `(` is group syntax, not a quantifier:
`(?:...)`, `(?=...)`, `(?!...)`, `(?<name>...)`, `(?<=...)`, `(?<!...)`.
Patterns like `(?:pre_)?tool_name` were silently rejected and the
registering hook never fired — with no error surfaced to the caller.

Fix: explicit group-syntax prefix skip inside the `(` handler. When the
scanner enters a group it peeks for `?` and advances past the modifier
characters (`:`, `=`, `!`, `<=`, `<!`, or a named-group label `<name>`)
before processing the group body. The `?` is therefore never considered
a quantifier at the start of a group.

While in there I also fixed a second correctness bug the reviewer
didn't catch: the old depth-indexed array didn't propagate a child
group's "contains quantifier" signal up to its parent, so `(?:(a+))+`
(outer quantifier over a wrapped quantified group, equivalent to
`(a+)+`) escaped detection. The scanner now uses an explicit stack of
`QuantifierFrame` records, and when a child group closes it propagates
`hasBacktrackRisk` to the parent frame — whether the child itself was
quantified or not. This catches `(?:(a+))+` correctly while still
allowing non-risky wrappers like `(?:(ab))+`.

Added 9 tests covering non-capturing groups, lookaheads, lookbehinds,
named captures, `(?:(a+))+` risk propagation, and `((ab)+)+` deep
wrapping. Verified existing ReDoS-detection tests still pass.

## #2 MINOR — executeHooks.test.ts did not clear the pattern cache

Matcher cache is module-level. `matchers.test.ts` clears it in
`beforeEach`; `executeHooks.test.ts` did not, so patterns compiled
during one test persisted across subsequent tests in the same file.
Added `clearMatcherCache()` to `executeHooks`'s `beforeEach` — no
test failures today, but restores test independence.

## #3 MINOR — Test utilities leaked into production barrel

`clearMatcherCache` and `getMatcherCacheSize` are test-only helpers
(their JSDoc says so) but were exported from `src/hooks/index.ts`.
When the integration PR eventually re-exports `src/hooks` from
`src/index.ts` they would become public API.

Removed both from the barrel. Tests already import them directly from
`../matchers`, so no test changes needed. `hasNestedQuantifier`,
`MAX_PATTERN_LENGTH`, and `MAX_CACHE_SIZE` remain exported — hosts can
legitimately use them for upstream validation.

## danny-avila#4 NIT — LRU refresh overhead at low cache pressure

Every `compile()` cache hit was doing `delete + set` to refresh LRU
position, even when the cache was nowhere near capacity. With
`MAX_CACHE_SIZE = 256` and typical working sets of tens of patterns,
eviction pressure is near-zero and the refresh is pure overhead.

Added a `LRU_REFRESH_THRESHOLD = 75%` gate: refresh only runs when the
cache is above 192 entries. Below that the code fast-paths straight
back from `compile()`. Above it the LRU semantics kick in so hot
patterns still survive evictions — the existing "refreshes LRU
position on hit" test still passes because by the time it runs the
check, the cache is at capacity.

Tests: 72 -> 81 (+9 for group-syntax and risk-propagation coverage).
* feat: wire run-level hooks into processStream (phase 1/2)

Fires four hook lifecycle events from `Run.processStream`, making the
inert hooks module from PR danny-avila#87 live. Hosts pass a pre-constructed
`HookRegistry` via `RunConfig.hooks`; when absent, all hook dispatch
is skipped (zero overhead for non-adopters).

## Integration points

- `RunConfig.hooks?: HookRegistry` — new optional field, mirrors the
  existing `customHandlers` pattern
- `Graph.hookRegistry` — field alongside `handlerRegistry`, cleared in
  `clearHeavyState()`
- `Run` constructor wires the registry onto both `this.hookRegistry`
  and `this.Graph.hookRegistry`

## Hook fire points in `processStream`

1. **RunStart** — after config setup, before stream creation. Receives
   `inputs.messages`, `runId`, `threadId`, `agentId`.
2. **UserPromptSubmit** — immediately after RunStart. Extracts prompt
   text from the last human message. If the hook returns
   `{decision: 'deny'}` or `{decision: 'ask'}`, the run aborts early
   and returns `undefined`. The `ask` case is an abort in v1 — the SDK
   returns the decision and the host decides how to implement
   interactive approval (see Phase 2 plan in the skills design report).
3. **Stop** — at the end of the try block, after the for-await loop.
   Receives the accumulated messages from `Graph.getRunMessages()`.
   Only fires on successful completion (not on error).
4. **StopFailure** — in a new catch block between try and finally.
   Receives the error message and last assistant message. Hook errors
   are swallowed (`.catch(() => {})`) so the original error propagates.

## Session teardown

`hookRegistry.clearSession(runId)` is called in the finally block,
guaranteeing cleanup even on error. Fulfills §3.9 rule danny-avila#5 from the
design report.

## Public API

`src/hooks/` is now re-exported from `src/index.ts`. Hosts can import
`HookRegistry`, `executeHooks`, and all hook types directly from
`@librechat/agents`.

## Helper functions

Three module-level functions added to `run.ts`:
- `findLastHumanMessage` — backward scan for `getType() === 'human'`
- `findLastAssistantMessage` — backward scan for `getType() === 'ai'`
- `extractPromptText` — handles string and complex content arrays

## Tests

10 new integration tests using `Run.create` + `overrideTestModel` +
`processStream` (same pattern as `custom-event-await.test.ts`):
- RunStart fires with correct fields
- UserPromptSubmit extracts prompt, deny aborts, ask aborts
- Stop fires on success, does not fire on error
- StopFailure fires on error, preserves original exception
- Session cleared in finally (both success and error paths)
- No-hooks baseline works identically

Total: 81 existing + 10 integration = 91 passing.

* fix(hooks): address PR 2 review — DRY helpers, test coverage, guards

Resolves all findings from the comprehensive review on danny-avila#88.

- #1 MAJOR: Add 3 tests for `extractPromptText` — multi-part content
  (text + image blocks yields 'hello\nworld'), image-only content
  (yields ''), and empty content array (yields '').
- #2 MINOR: Merge `findLastHumanMessage`/`findLastAssistantMessage`
  into parameterized `findLastMessageOfType(messages, type)`.
- #3 MINOR: Rewrite `extractPromptText` array path from
  `.filter().map().join()` triple-pass to single `for...of` loop
  accumulating into `parts[]` then `.join('\n')`.
- danny-avila#4 MINOR: Add `config.callbacks = undefined` on deny/ask early
  return path so Langfuse handler reference is cleaned up even
  though no stream ran.
- danny-avila#5 MINOR: Add comment on `UserPromptSubmit` fire point noting
  `attachments` is not yet wired (Phase 2).
- danny-avila#6 MINOR: Strengthen `StopFailure` test assertion from
  `toBeTruthy()` to `typeof === 'string'` + `length > 0`.
- danny-avila#7 MINOR: Add test for empty-content human message — verifies
  `UserPromptSubmit` fires with `prompt: ''`.
- danny-avila#8 NIT: Add inline comment on `stopHookActive: false` explaining
  it will be `true` in Phase 2 when stop is triggered by a hook.
- danny-avila#9 NIT: Use `hasHookFor('Stop', sessionId)` and
  `hasHookFor('StopFailure', sessionId)` guards before
  `getRunMessages()` to avoid the array copy when no hooks are
  registered for the event.
- CL-2: Wrap Stop hook dispatch in its own `.catch()` so an
  infrastructure error in `executeHooks` (theoretical — it catches
  internally) cannot masquerade as a stream failure in the catch
  block.

Tests: 91 -> 94 (+3 for extractPromptText coverage).
…vila#90)

* feat: wire tool hooks into event-driven ToolNode (phase 1/3)

Fires PreToolUse, PostToolUse, PostToolUseFailure, and PermissionDenied
hooks inside `ToolNode.dispatchToolEvents` — the single chokepoint for
all event-driven tool execution in LibreChat.

## Hook lifecycle in dispatchToolEvents

1. **PreToolUse** fires per call in parallel before ON_TOOL_EXECUTE
   dispatch. Denied calls (deny or ask) produce error ToolMessages and
   fire PermissionDenied; surviving calls proceed with optional
   updatedInput applied to tool args.

2. Only approved calls are batched into ON_TOOL_EXECUTE and sent to the
   host. If all calls are denied, the batch dispatch is skipped entirely.

3. **PostToolUse** fires per successful result. If a hook returns
   updatedOutput, the ToolMessage content is replaced before the message
   is appended to the graph.

4. **PostToolUseFailure** fires per error result. Observational only —
   cannot modify the error message.

Post hooks are awaited (not fire-and-forget) so updatedOutput takes
effect before the ToolMessage is consumed. Hook errors are swallowed
via .catch() to avoid disrupting the tool result flow.
PermissionDenied is fire-and-forget since it's purely observational.

## Plumbing

- `ToolNodeOptions.hookRegistry?: HookRegistry` — new optional field
- `ToolNode` constructor stores `this.hookRegistry`
- `Graph.createToolNode()` passes `this.hookRegistry` at both
  construction sites (event-driven and traditional)
- `hookRegistry` is set on Graph BEFORE `createWorkflow()` is called
  (moved from the post-create assignment in Run constructor into
  `createLegacyGraph` and `createMultiAgentGraph`) so ToolNode receives
  a non-undefined registry at construction time

## hasHookFor guards

All four hook fire points use `hasHookFor(event, runId)` before calling
`executeHooks`, so when no hooks are registered for the event the
overhead is a single Map lookup + array length check — no Promise
allocations, no function calls.

## Tests

9 new integration tests in `src/hooks/__tests__/toolHooks.test.ts`
using event-driven mode (toolDefinitions + ON_TOOL_EXECUTE handler):

- PreToolUse fires with correct fields
- PreToolUse deny blocks execution (tool handler never called)
- PreToolUse ask blocks execution (v1)
- PreToolUse updatedInput rewrites args before dispatch to host
- PreToolUse hook errors are non-fatal (tool still executes)
- PermissionDenied fires after deny with reason
- PostToolUse fires with tool output
- PostToolUseFailure fires on error result
- No-hooks baseline works identically

Total: 94 existing + 9 new = 103 passing.

* fix(hooks): address PR 3 review — step completion, ordering, catch, tests

Resolves all 10 findings from the review on danny-avila#90.

## Structural rewrite of dispatchToolEvents

The post-processing loop was rewritten to fix #1, #2, danny-avila#6, danny-avila#8, danny-avila#10:

- #1 MAJOR: Denied calls now dispatch ON_RUN_STEP_COMPLETED via a
  shared `dispatchStepCompleted` helper (extracted from the duplicate
  logic in the executed-results loop). Without this, the frontend
  would show stuck spinners for denied tool calls.

- #2 MAJOR: Output messages are now collected in a Map keyed by
  tool_call_id, then returned in the original `toolCalls` input order
  via `toolCalls.map(call => messageByCallId.get(call.id))`. Before,
  denied messages were prepended to executed messages, breaking the
  implicit ordering contract.

- danny-avila#6 MINOR: `toolUsageCount` is now incremented only for approved
  calls (moved from the initial `preToolCalls.map` into the
  `approvedEntries.map` that builds ToolCallRequests). Denied calls no
  longer inflate the turn counter.

- danny-avila#8 MINOR: Eliminated the double `result.status === 'error'` check.
  ToolMessage is now constructed inside each status branch directly
  after hook dispatch.

- danny-avila#10 NIT: `requests.find()` replaced with a pre-built
  `Map<id, ToolCallRequest>` for O(1) lookup per result, per AGENTS.md
  guidance on Map/Set over Array.find.

## Error protection

- #3 MAJOR: PreToolUse `Promise.all` now wraps each `executeHooks`
  call in `.catch(() => emptyResult)` so an infrastructure error in
  the hooks subsystem cannot crash the entire tool batch. Matches the
  stated "hook errors are non-fatal" contract and is consistent with
  PostToolUse/PostToolUseFailure which already had `.catch()`.

## New tests

- danny-avila#4 MAJOR: Added `updatedOutput replaces the ToolMessage content` —
  registers a PostToolUse hook returning `{updatedOutput: 'REDACTED'}`,
  verifies the ToolMessage content in the graph's run messages is
  `'REDACTED'` (not the original output).

- danny-avila#7 MINOR: PermissionDenied test replaced 50ms `setTimeout` with a
  `Promise` resolved by the hook callback. No more flaky sleep.

## Cleanup

- danny-avila#9 NIT: Updated stale "once the tool-hook PR lands" comment in
  `src/hooks/index.ts`.

Tests: 103 -> 104 (+1 for updatedOutput).

* fix(hooks): off-by-one in dispatchStepCompleted index, test guard, turn fallback

- #1 MAJOR: dispatchStepCompleted read post-increment toolUsageCount
  for the `index` field. Added `turn?: number` parameter; approved-call
  site passes `request?.turn` (the pre-increment value), denied-call
  site omits it (fallback is correct since count was never bumped).
- #2 NIT: updatedOutput test now asserts `expect(toolMsg).toBeDefined()`
  before content extraction so a missing tool message produces a clear
  failure rather than "Expected undefined to be 'REDACTED'".
- #3 NIT: PreToolUse input `turn` field restored `?? 0` fallback so
  first-use tools see `turn: 0` instead of `turn: undefined`.

* fix(hooks): batch tests, turn JSDoc, freeze sentinel, remove unused hookRegistry

Addresses follow-up review findings on danny-avila#90.

- #1 MAJOR: Add multi-call batch tests — partial deny (echo denied,
  math approved, order preserved) and all-denied (ON_TOOL_EXECUTE
  handler never called). Uses counter-based IDs (danny-avila#7 fix) to avoid
  Date.now() collisions across calls in the same test.
- #2 MINOR: Document PreToolUseHookInput.turn semantics — within a
  single batch, all calls to the same tool share the same turn value.
  Per-call discrimination within a batch is not supported in v1.
- #3 MINOR: Remove hookRegistry from the traditional (non-event-driven)
  ToolNode construction site in Graph.ts. Hooks only fire in
  dispatchToolEvents; passing the registry to a ToolNode that never
  uses it created a false impression of hook support.
- danny-avila#4 MINOR: Add PostToolUse error non-fatality test — throwing hook
  does not crash the batch, original content is preserved.
- danny-avila#5+danny-avila#8 MINOR: Freeze the hook-error fallback sentinel via
  Object.freeze and rename from emptyResult to HOOK_FALLBACK. Prevents
  future mutation bugs if someone pushes to the shared arrays.
- danny-avila#6 MINOR: Document in ToolNodeOptions.hookRegistry JSDoc that hooks
  only fire for event-driven calls — directToolNames bypass dispatch.

Tests: 104 -> 107 (+3 for batch partial-deny, all-denied, and
PostToolUse error resilience).

* nit: simplify test assertions, keep shallow freeze (deep conflicts with mutable type)
…anny-avila#91)

Phases 1+2 of the Skills system, rebased on hooks PRs (danny-avila#88, danny-avila#90):

Types:
- InjectedMessage type in tools.ts (generic, any tool can use it)
- SkillCatalogEntry type in skill.ts
- ToolExecuteResult.injectedMessages field
- Constants.SKILL_TOOL = 'skill'

SkillTool (src/tools/SkillTool.ts):
- JSON Schema (single source of truth), LCTool definition, createSkillTool()
- Runtime-agnostic description (files "may" become accessible)
- Requires event-driven execution mode

ToolNode integration (src/tools/ToolNode.ts):
- convertInjectedMessages() converts to HumanMessage (both roles, avoids
  Anthropic/Google SystemMessage rejection). Original role in additional_kwargs.
- dispatchToolEvents returns { toolMessages, injected } tuple
- Injected messages placed AFTER ToolMessages (provider ordering)
- try/catch isolation around injection conversion
- Works with hooks-based PreToolUse/PostToolUse lifecycle

Catalog formatter (src/tools/skillCatalog.ts):
- formatSkillCatalog() with truncation ladder and budget enforcement
- fitNamesOnly drops trailing entries when names-only exceeds budget

Tests: 30 total (18 SkillTool + 12 skillCatalog)
…nny-avila#93)

* feat: add local bash execution and bash programmatic tool calling tools

Implement BashExecutor for local bash command execution via child_process
and BashProgrammaticToolCalling for multi-tool orchestration using bash
scripts with a local HTTP server for tool IPC via curl.

* fix: use remote Code API for bash tools instead of local child_process

BashExecutor now sends `lang: 'bash'` to the same /exec endpoint as
CodeExecutor. BashProgrammaticToolCalling sends bash code to
/exec/programmatic with the same round-trip loop as PTC. Both tools
share session/file handling with their counterparts. ToolNode session
injection and storage updated for all four code execution tool names.
Updated the constant name from EXECUTE_BASH to BASH_TOOL in the Constants enum and adjusted references in BashExecutor and ToolNode to reflect this change. This improves clarity and aligns with naming conventions.
…y-avila#94)

When the skill handler uploads files to the code env and returns
artifact: { session_id, files }, ToolNode now stores that session
context (via storeCodeSessionFromResults). This makes skill-primed
files available to subsequent bash/code tool calls in the same run.

Also adds SKILL_TOOL to the codeSessionContext request building in
dispatchToolEvents, so the handler receives the current session
context and can check if files are already uploaded.
…anny-avila#95)

* feat: add initialSessions to RunConfig for seeding Graph session state

Allows hosts to seed the Graph's ToolSessionMap at run creation time.
Used by skill file re-priming: after uploading skill files to the code
env at run start, the host passes the session info so ToolNode can
inject session_id + files into subsequent bash/code tool calls.

* refactor: add CODE_EXECUTION_TOOLS set, DRY ToolNode checks

Add CODE_EXECUTION_TOOLS ReadonlySet to common/enum.ts containing
all four code execution tool constants (EXECUTE_CODE, BASH_TOOL,
PROGRAMMATIC_TOOL_CALLING, BASH_PROGRAMMATIC_TOOL_CALLING).

Replace 5 repeated 4-constant inline checks in ToolNode with
CODE_EXECUTION_TOOLS.has(). SKILL_TOOL remains a separate check
where needed since it's not a code execution tool itself.
…nny-avila#96)

When a skill is invoked, the body is injected as a HumanMessage into
LangGraph state but NOT persisted to conversation history. On follow-up
runs the skill body is lost.

formatAgentMessages now accepts an optional invokedSkillBodies Map
(skillName → body) and reconstructs HumanMessages at the correct
position in the message sequence — after the skill's ToolMessage,
matching where ToolNode originally injected them.

Detection happens inside the existing tool_call processing loop (both
with and without tools filtering), so there are zero extra message
iteration passes.
…ny-avila#97)

Critical fix:
- Capture endMessageIndex BEFORE skill body HumanMessage injection so
  injected messages are excluded from the assistant message's token
  distribution range. Prevents indexTokenCountMap corruption.

Review fixes:
- Remove dead else-if(!discoveredTools) branch inside if(discoveredTools)
  block and revert redundant optional chaining
- Extract extractSkillName() helper to eliminate DRY violation (identical
  args parsing IIFE was duplicated in two locations)
- Use Set<string> instead of string[] for pendingSkillNames (dedup)
- Lazy allocation via ??= (only allocated when skills are present)
danny-avila and others added 29 commits June 6, 2026 11:53
… Sort imports, Bump Node Engine, Test Sharding (danny-avila#226)

* fix: Update import path for isGoogleLike utility in stream.ts due to circular dependency

* ci: add circular dependency check to CI workflow

* feat: add import sorting script and update ESLint config to include scripts/**/*.ts

- Introduced a new script to sort imports in TypeScript files according to project conventions.
- Updated ESLint configuration to include TypeScript files in the scripts directory for linting.
- Added new commands in package.json for sorting imports and checking import order.

* chore: sort imports

* chore: update TypeScript configuration for scripts and ESLint

- Added a new tsconfig.json for the scripts directory, extending the main tsconfig.json.
- Updated ESLint configuration to include the new scripts tsconfig for linting.
- Modified main tsconfig.json to specify types for node and jest, and adjusted included files.

* chore: update Node.js version and ESLint configuration

- Added .nvmrc file to specify Node.js version 24.
- Updated ESLint configuration to disable specific rules for test files.
- Modified package.json to require Node.js version 24 and updated @types/node dependency.
- Adjusted CI and publish workflows to use Node.js version 24.
- Enhanced tsconfig-paths-bootstrap.mjs for better baseUrl handling.
- Refactored logging.ts to improve type safety in write methods.

* chore: remove unused inline attachment mime types from attachments.ts

- Deleted the SUPPORTED_ATTACHMENT_MIMES constant, which included image and PDF mime types.
- This change simplifies the attachment handling by relying solely on SUPPORTED_IMAGE_MIMES.

* chore: update ESLint configuration to enforce no-nested-ternary rule as error

- Changed the severity of the "no-nested-ternary" rule from warning to error in the ESLint configuration, promoting stricter code quality standards.

* refactor: enhance workspace policy hook and improve Cloudflare execution engine

- Updated the `createWorkspacePolicyHook` to introduce a `resolvePolicy` function for clearer policy resolution based on tool names.
- Modified the `CloudflareSandboxExecutionEngine` to use a function for checking if the state is closed, preventing potential issues with concurrent mutations.
- Simplified promise handling in `LocalCodingTools` by removing unnecessary checks and added a new utility function for formatting fallback grep display.

* chore: update import sorting script to use Node instead of Bun

- Changed the shebang in `sort-imports.ts` from Bun to Node for better compatibility.
- Updated `package.json` scripts to reflect the change in runtime for sorting imports.
- Removed Bun types from `tsconfig.json` to streamline type definitions.
- Adjusted the import sorting logic to improve handling of file paths and sorting criteria.

* ci: refactor CI workflow to introduce unit and integration test jobs

- Renamed the existing test job to unit-tests and added a matrix strategy for sharding.
- Created a new integration-tests job for change-gated integration testing.
- Updated test execution commands to accommodate sharding in unit tests.

* ci: enhance CI workflow with summarization tests and environment variables

- Added a new job for summarization tests to the CI workflow.
- Updated the unit test command to exclude an additional test file.
- Introduced environment variables for various API keys to support the new summarization tests.
…avila#227)

* chore: Restructure CI workflows to introduce a validation job

- Added a new `validate` job in the CI workflow to handle installation, linting, type-checking, and circular dependency checks.
- Updated the `publish` job to depend on the `validate` job, ensuring all validations are completed before publishing.
- Removed redundant steps from the `publish` job, streamlining the workflow and improving maintainability.
- Introduced a new `validate.yml` file to encapsulate validation logic, enhancing modularity and reusability across workflows.

* ci: Increase timeout for LLM and unit test jobs in CI workflow

- Added a timeout of 6 minutes to the Google, Anthropic, PTC, and Tool Search unit test jobs in the CI workflow to prevent premature job termination during execution.
….31`, with Anthropic tool-input compat fix (danny-avila#232)

* chore(deps): Bump @langchain/core to 1.1.48 and @langchain/google-* to 2.1.31

Routine maintenance bump of the Google/LangChain stack:
- @langchain/core 1.1.44 -> 1.1.48
- @langchain/google-common / google-gauth / google-genai / google-vertexai 2.1.28 -> 2.1.31

These must move together: google-common 2.1.31 imports `v4` from
`@langchain/core/utils/uuid` (reverting 2.1.28's standalone-`uuid` import),
which is the same path that crashed in LibreChat#13003. core 1.1.48 fixes the
Rolldown CJS emit so that export resolves to a callable function again
(verified: `require('@langchain/core/utils/uuid').v4()` returns a UUID), so the
crash does not recur. Bumping google-common alone (core pinned at 1.1.44) would
reintroduce it.

Build (rollup + tsc) passes. Does not address the Gemini union-type schema
throw (langchain-ai/langchainjs#9691, still open) — that is handled downstream
in LibreChat.

* fix(anthropic): restore tool input from tool_calls under core v1 aggregation

@langchain/core >= 1.1.46 changed streaming content-block aggregation ("keep
different block types separate" + "v1 message casting"). For Anthropic tool
calls it now keeps the input_json_delta as a separate content block, v1-casts it
to a `text` block carrying `input` but no `text`, and leaves the sibling
tool_use block with an empty inline input. Re-serializing such a message threw
"Unsupported message content format" in _formatContent.

Align _formatContent with upstream @langchain/anthropic's handling, without
disturbing the fork's Vertex/GCP/Azure-specific corrections:
- restore an emptied tool_use input from the matching message.tool_calls args
- drop orphaned input_json_delta blocks and the v1-cast `text`-with-`input`
  variant instead of throwing

Add a regression test for the aggregated-streaming tool-input scenario.

* fix(anthropic): emit streamed tool-input deltas type-less so core merges them

Root-cause fix for the core >= 1.1.46 aggregation change. _makeMessageChunkFromAnthropicEvent
emitted the input_json_delta as a separate content block typed `input_json_delta`;
core now keeps differently-typed blocks separate, leaving the sibling tool_use
block's input empty and the delta orphaned. Every consumer that reads tool input
from message.content then breaks — re-serialization (_formatContent) and, e.g.,
@langchain/anthropic's AnthropicToolsOutputParser used by withStructuredOutput
(title generation), which threw OUTPUT_PARSING_FAILURE on empty input.

Emit the partial input as an untyped `{ index, input }` block so core merges it
into whichever block (tool_use or server_tool_use) sits at that index, restoring
the assembled input in message.content. Fixes all content-reading consumers at
the source; the message_inputs defensive handling stays as a safety net.

Add a unit test covering chunk emission + aggregation.

* fix(anthropic): merge sibling input_json_delta fallback + port upstream tool tests

_formatContent now restores an emptied tool_use input from sibling
input_json_delta blocks (by index) when there is no matching message.tool_calls
entry — aligning with @langchain/anthropic and covering persisted messages that
predate the chunk-emission fix.

Port two upstream @langchain/anthropic regression tests that guard this path:
- "partial tool input is correctly merged before calling Anthropic API" (unit)
- "converting messages doesn't drop tool input" (live), adapted to CustomAnthropic

* chore(deps): update @anthropic-ai/sandbox-runtime to version 0.0.54 in package.json
…che (danny-avila#231)

* fix: dedupe skill bodies & skip synthetic skill messages in prompt cache

formatAgentMessages: add optional `skipSkillBodyNames` so historical `skill`
tool_calls are not reconstructed into a HumanMessage when the same skill is
already primed fresh this turn — preventing the SKILL.md body from being
injected twice. Names absent from the set still reconstruct from history.

cache: add `isSyntheticMetaMessage` and skip those messages when anchoring
fresh prompt-cache markers in addCacheControl (Anthropic/OpenRouter) and
addBedrockCacheControl. Stale markers are still stripped; the marker moves to
the next real user message instead of pinning to a volatile skill prefix.

Adds tests for both behaviors.

* fix: skip synthetic skill messages in stable-prefix cache path

Addresses Codex P2: addCacheControlToStablePrefixMessages (the dynamic-tail
path used by AgentContext) delegates to addCacheControlToRecentMessages, whose
cacheable fallback did not check isSyntheticMetaMessage. When the stable prefix
held a reconstructed skill HumanMessage after a skill-only (text-less) assistant
tool call, the fallback could cache-anchor the synthetic SKILL.md body.

Guard canAddCache with !isSyntheticMetaMessage so both the assistant pass and
the cacheable fallback skip synthetic skill/meta messages. Adds a regression
test for the stable-prefix fallback.
Replace the Rollup half of the build with tsdown (rolldown): the JS step
drops from ~4.1s to ~0.3s, taking the full build from ~6.5s to ~2.6s. The
declaration step is unchanged (`tsc -p tsconfig.build.json`, byte-identical
dist/types output) — the source isn't isolatedDeclarations-clean (~99
errors, mostly zod tool schemas), so the oxc dts fast path is a possible
follow-up rather than part of this change.

- `unbundle: true` mirrors Rollup's `preserveModules`: per-module output at
  the same src-relative paths, so all 13 exports-map subpaths resolve
  unchanged (verified: every entry present in both formats, and 367/367
  export parity with the Rollup build for cjs and esm).
- `fixedExtension: true` keeps `.mjs`/`.cjs` filenames matching the
  previous `entryFileNames` and the exports map.
- Enable rolldown's `circularDependency` check (off by default) so the CI
  circular-dependency guard in validate.yml — which greps build output for
  "Circular depend" — keeps working; reworded its error message that
  referenced Rollup. Verified the codebase currently emits 0 warnings.
- Remove the seven Rollup devDependencies; `build:dev` is now plain
  `tsdown` and `build` cleans dist first (Rollup's cleandir did this
  before).
…la#234)

* 🚰 fix(search): Plug Scraped Content Leak & Cap Web Search Payloads

Continues the work from danny-avila#63 by @dan-and on current main:

- Strip content/references from sources without highlights in
  expandHighlights — the early-return path leaked full scraped pages
  into the search artifact
- Cap scraped content per source at 50,000 chars (maxContentLength
  config / SEARCH_MAX_CONTENT_LENGTH env var) at storage time and
  before chunking, bounding reranker request sizes
- Dedupe merged topStories by link in executeParallelSearches and cap
  them to the requested source count in processSources

* 🚰 fix(search): Cap topStories Before Early Returns in processSources

Codex review: the cap was placed after the no-organic and empty-links
early returns, leaving those paths unbounded. Capping up front also
avoids scraping news entries the cap would discard.
…ny-avila#237)

* 🦭 feat: Mid-Stream Tool Call Seals for Bedrock Converse & Google

Extend eager event tool execution's mid-stream prestart beyond Anthropic
and OpenAI Responses by emitting explicit streamed tool-call seals from
providers whose wire protocols guarantee argument completeness:

- Bedrock Converse: stamp the adapter on toolUse start/delta chunks and
  emit a single-index seal chunk at contentBlockStop, where the protocol
  guarantees the block's input is complete
- Google GenAI: stamp an on-arrival seal (kind: all) on function-call
  chunks, since the API delivers calls as whole objects, never deltas
- Vertex AI: seal complete streamed tool calls in the stream wrapper
  after google-common's parse check
- stream.ts: prestart on-arrival sealed tool calls that lack a final
  tool_calls/tool_use finish signal, under the existing direct-tool guard

Chat Completions-compatible providers stay on the final-signal path: the
protocol has no per-call completion event and index advancement is not a
reliable seal (e.g. Kimi/Moonshot revise prior args after advancing).

* 🛰️ fix: Dispatch Bedrock Tool Chunks Through Callback Stream Path

Registered stream handlers consume chunks via handleLLMNewToken callback
events, not the yielded generator (attemptInvoke skips manual dispatch
when a CHAT_MODEL_STREAM handler is registered). The Converse loop only
dispatched contentBlockDelta chunks, so both the toolUse start chunk
(carrying the id/name that eager chunk state requires) and the new stop
seal chunk were invisible on that path — eager prestart never fired.

Dispatch both through runManager.handleLLMNewToken, mirroring the delta
branch, and cover the stream loop with a stubbed-client test asserting
chunks reach the callback path.

Addresses codex review finding on PR danny-avila#237.

* 🛡️ fix: Skip Bedrock Tool Seals When Guardrails Can Intervene

Address @codex review on PR danny-avila#237:

- Skip contentBlockStop seal emission when guardrailConfig is set on the
  model or call options: guardrails can reject an already-streamed
  toolUse block at messageStop (guardrail_intervened), after the seal
  would have prestarted the tool (P2)
- Lock in on-arrival seal gating with tests: hooks/HITL, programmatic
  tool calling, and disabled eager execution never prestart — the
  guards live inside createEagerToolExecutionPlan, so the reported
  bypass (P1) does not exist
- Prove Vertex callback ordering with a stubbed-connection test driving
  the real google-common stream loop: chunks reach handleLLMNewToken
  consumers already sealed, since google-common yields before the
  callback and only resumes after the override stamps the seal (P1)
…-avila#238)

* 📮 feat: Sequential Tool Call Seals for Official OpenAI & Azure

Extend mid-stream eager tool prestart to official OpenAI and Azure
Chat Completions via next-index sealing, gated on the wire endpoint:

- Stamp an openai_chat_sequential streamed tool-call adapter on
  Chat Completions tool-call deltas when the configured baseURL is
  api.openai.com (or unset), and unconditionally for Azure OpenAI —
  OpenAI-compatible endpoints (custom baseURL: Kimi/Moonshot,
  OpenRouter, LiteLLM, etc.) are never stamped and stay on the
  final-signal path
- Allow next-index sequential sealing in the stream handler when a
  chunk carries a sequential-capable adapter, alongside the existing
  Anthropic provider allowlist

Empirical basis: 16/16 multi-tool-call streaming runs against
api.openai.com (gpt-5.5, incl. long-args turns spanning ~165 deltas)
and 6/6 against Azure OpenAI showed strictly sequential-by-index
deltas with no prior-index revisions, ids on first delta, and
finish_reason tool_calls. Wrong-seal exposure remains bounded by the
strict-JSON parse guard and ToolNode's refuse-to-rerun reconciliation.

* 🚧 fix: Gate Sequential Seals on Effective Endpoint, Not Constructor Args

Address @codex review on PR danny-avila#238:

- Honor OPENAI_BASE_URL when the constructor baseURL is unset: the
  OpenAI SDK falls back to it, so an empty client config can still
  route to an OpenAI-compatible endpoint — resolve the effective
  baseURL before treating the client as official
- Gate Azure stamping on the actual endpoint: skip when a custom
  clientConfig.baseURL is set, and require azureOpenAIBasePath (when
  present) to resolve to *.openai.azure.com or
  *.cognitiveservices.azure.com; proxies and Azure-compatible
  endpoints fall back to the final-signal path

* 🧭 fix: Accept Regional Azure Endpoints & Isolate Seal Tests From Env

Address @codex round-2 review on PR danny-avila#238:

- Accept regional *.api.cognitive.microsoft.com base paths as
  first-party Azure endpoints in the sequential-seal gate
- Isolate the stamping tests from OPENAI_BASE_URL and
  AZURE_OPENAI_BASE_PATH so the suite is deterministic in shells
  where those are set; verified by running with both pointed at
  non-official endpoints
…-avila#239)

Tool batches execute in parallel on the host, but the ON_TOOL_EXECUTE
contract only had a single resolve(results[]) channel — so a fast
tool's completion event waited for the slowest call in the batch.

Add an optional onResult callback to ToolExecuteBatchRequest: the host
may report each result as it settles (before the final resolve), and
ToolNode emits that call's completed run step immediately. Purely an
emission fast-path — resolve remains the authoritative batch outcome,
graph state still transitions once per batch.

- Offered only in no-hooks/no-HITL configurations: PostToolUse hooks
  can rewrite output and HITL can deny a call after execution, so
  those keep batch-time emission
- Ids are claimed synchronously to dedupe duplicate reports; unknown
  ids are ignored; failed dispatches release the claim so the batch
  path re-emits
- Output formatting mirrors the batch loop exactly (truncation and
  error formatting), and dispatchStepCompleted now reports whether the
  event was delivered
* feat: emit ON_CONTEXT_USAGE token budget snapshot per model call

* fix: scale post-prune remaining tokens to calibrated units

* feat: include per-tool token counts and deferred names in budget breakdown

* fix: dispatch context usage at invoke time with post-format recount

* test: live context usage verification for single, handoff, and subagent runs

* fix: gate usage dispatch behind empty-prompt guard, catch deferred definitions

* fix: refresh tool accounting on discovery, align reserve ratio, recalibrate per-tool counts

* fix: account same-length tail formatting in usage snapshot, assert discovery refresh

* test: live accuracy verification for tool loops, caching, and pruning

* test: cross-provider live accuracy matrix (google, bedrock)

* fix: coherent post-prune breakdown, trailing-batch recount, awaited dispatch, compaction budget fields

* 🩹 fix: Keep Usage Breakdown Coherent with Calibrated Budget Fields

- sync breakdown.instructionTokens/availableForMessages to the pruner's
  effective (calibrated) overhead so the aggregate agrees with the
  top-level budget fields even under the calibration variance threshold
- re-derive breakdown.messageTokens and update messageCount after the
  dispatch-time recount/tail-delta adjustments so the snapshot describes
  the payload actually sent

* 🩹 fix: Exact Legacy-Content Deltas and Reconciled Per-Tool Counts

- track the exact set of messages formatContentStrings will flatten via a
  shared isLegacyConvertible predicate and fold their token delta into the
  dispatch-time adjustment, so legacy-content rewrites before the trailing
  batch no longer skew the snapshot (zero extra tokenization when no
  convertible messages exist)
- apportion per-tool schema counts with the largest-remainder method so
  they sum exactly to the aggregate, both at initial counting and when
  calibration rescales them
- document fallback-retry snapshot semantics on ContextUsageEvent
…vila#236)

* 💸 feat: Report Subagent Child-Run Model Usage via Usage Sink

* 🎯 fix: Per-Request Usage Events, Root-Run & Invoked-Provider Attribution

* 🧮 fix: Await Async Usage Sinks & Attribute Summarizer Model
…ny-avila#242)

* 🔎 feat: Per-Search Model-Output Budget for Web Search Highlights

Cap the highlight content the web search tool feeds the model per search
via a new `maxOutputChars` config (default 50,000 chars; also settable
through `SEARCH_MAX_LLM_OUTPUT_CHARS`). Previously `formatResultsForLLM`
aggregated every source's reranked highlights with no aggregate budget,
so a 5-source search over 50K-char scrapes could dump ~150-190K tokens
into a single turn and trigger premature summarization/compaction.

Highlights are now walked in relevance order (organic, then news) and
kept whole until the budget is hit; the boundary highlight is truncated
when meaningful room remains, otherwise dropped, with an omission marker
appended. Snippets/titles/URLs are untouched and full content always
remains in the `WEB_SEARCH` artifact for citations.

* 🩹 fix: Codex review — blank-highlight budget charge & truncated-highlight refs

Address two Codex findings on the per-search budget:

- Skip blank/whitespace-only highlights before charging the budget. They
  are filtered out by `formatSource` (`text.trim().length > 0`) and never
  rendered, so charging their raw length could starve real highlights.
- Drop a truncated boundary highlight's references. Slicing the text can
  cut past a citation marker, so keeping the original `references` would
  emit Core References for citations no longer visible to the model.

Budget is now charged on trimmed length to match what is rendered.

* 🩹 fix: Codex round 2 — visible-citation retention & relevance-prefix stop

- Truncated highlights now keep references whose `(type#N)` marker survives
  in the kept prefix (matched via the same marker regex as highlights.ts),
  instead of dropping all references. Preserves still-visible citations
  while still discarding those cut from the tail.
- Mark the budget exhausted when the boundary highlight is dropped for
  having less than MIN_PARTIAL room, not just when it is truncated. Stops a
  later lower-ranked short highlight from slipping in past a dropped
  higher-ranked one, keeping the relevance-ordered prefix intact.
…anny-avila#244)

* 📐 feat: AgentContext.projectContextUsage — pre-send context snapshot

Add a pure, off-the-hot-path projection of the context-usage snapshot for an
arbitrary message set, without invoking the model. It runs the same pruner +
budget math the live path uses (createPruneMessages → getTokenBudgetBreakdown
→ syncBudgetDerivedFields), so a "what would I send" projection matches a real
call's ON_CONTEXT_USAGE snapshot. Returns null when no tokenizer/window is
configured; uses a local pruner and never mutates the context.

This backs a host-side (e.g. LibreChat) context gauge in the states the live
snapshot can't cover — page load of a snapshot-less branch, and model/window
switches where a persisted snapshot is stale — so the gauge always reflects the
SDK's authoritative view instead of a client-side estimate.

syncBudgetDerivedFields moves from Graph.ts to a shared src/messages/budget.ts
so the live and projected paths share one derivation (no drift). Behavior of
the live snapshot is unchanged.

* 🩹 fix: Codex review — make projectContextUsage a safe, stateless projection

Address three findings on the pre-send projection's reuse of the stateful
pruner:

- P1: pass a shallow copy of `messages` to the pruner. Its mask/pre-flight
  truncation replaces tool-result slots in place, which would otherwise
  permanently truncate the caller's session history during a projection.
- Recount a fresh per-message token map for the supplied messages (the
  context's own map is keyed to the live run's branch and would missum an
  arbitrary branch). Callers may pass a map they guarantee matches.
- Do not pull this context's live `currentUsage`/`lastCallUsage`: a fresh
  pruner (startIndex 0) would recalibrate the prior call's provider input
  against the whole projected branch, skewing the estimate. Calibration is
  opt-in via an explicit `usageMetadata`.

* 🩹 fix: Codex round 2 — content-array, calibration seed, stale system refresh

- Clone array-content messages before projecting. The pruner unshifts
  reasoning blocks into AI content arrays in place (addThinkingBlock), so a
  shallow array copy alone left the caller's history mutable; reuse the
  existing `cloneMessage` (export it) to give the pruner its own arrays.
- Replace the (dead) `usageMetadata` opt with a `calibrationRatio` seed.
  The pruner skips calibration when `totalTokensFresh` is false, so passing
  usage never adjusted the ratio. Re-deriving from usage is the recalibration
  hazard flagged earlier anyway — instead apply the persisted ratio as a
  static seed (the same value the live snapshot carries), no re-derivation.
- Refresh a stale system runnable (`initializeSystemRunnable`) before
  projecting, as the live graph does, so handoff/summary changes recount
  system overhead instead of projecting with stale instruction tokens.

* 📐 feat: projectAgentContextUsage top-level helper for host consumers

Expose a thin async entry that builds a throwaway AgentContext from the same
AgentInputs a run uses, awaits its instruction/tool token accounting, then runs
the shared projection. Hosts (e.g. LibreChat) don't hold an AgentContext —
this lets them project a branch's pre-send context snapshot from the config
they already assemble for createRun, without exposing the AgentContext class.
…anny-avila#243)

* 🩹 fix: Drop Foreign Reasoning Blocks in Anthropic Message Converter

Prevents "Unsupported message content format" crashes on cross-provider
agent handoffs. When an agent on Bedrock Anthropic (extended thinking) hands
off to an agent on the official Anthropic provider, Bedrock leaves a
`reasoning_content` content block ({ reasoningText: { text, signature } }) in
history. The Anthropic converter (`_formatContent`) had no branch for it and
threw in the final else, crashing the run. Only hits the streaming path
(LibreChat agents always stream).

- Drop foreign reasoning blocks (Bedrock `reasoning_content`, Google
  `reasoning`, LibreChat `think`); their provider-specific signatures cannot
  be validated by Anthropic, so the receiving model produces its own thinking.
- Degrade gracefully on any unknown block: log and drop instead of throwing,
  so a single unrecognized block can never crash an entire run.
- Add regression tests covering each reasoning family, the unknown-block
  fallback, and the reasoning-only placeholder case.

* 🩹 fix: Address Codex review — role-aware drop + preserve tool calls

Finding 1: the graceful drop ran for every message role, silently omitting
unsupported user content (e.g. video/audio media on a user prompt). Now only
assistant turns drop an unknown block; user/tool turns still throw so real
input is never silently dropped.

Finding 2: a Bedrock extended-thinking turn records the tool only on
`message.tool_calls` and leaves `content` as just the reasoning block (no
`tool_use`). Dropping that reasoning block left the `_` placeholder, and the
array-content path never materialized `tool_calls` — silently losing the
(handoff) tool call. Now unrepresented client tool_calls are materialized as
tool_use blocks (and the lone placeholder is dropped when real tool_use is
present).

Adds regression tests for both.

* 🩹 fix: Codex round 2 — limit drop to reasoning, dedupe Google tool calls

Finding 3 (P1): `representedToolIds` was computed from raw content by type, so a
Google `functionCall` part — which `_formatContent` converts into a `tool_use`
— was not counted as represented. The materialization then appended the same
tool call again from `tool_calls`, producing duplicate `tool_use` blocks with
the same id. It is now derived from the formatted output, so anything
`_formatContent` already materialized (incl. functionCall) counts as present.

Finding 4 (P2): the blanket assistant-side drop silently discarded Google
code-execution blocks (`executableCode`/`codeExecutionResult`), losing real
content on a Google → Anthropic handoff. Reverted the blanket drop: only known
foreign reasoning (`reasoning_content`/`reasoning`/`think`) is dropped; every
other unknown block throws again rather than being silently omitted (this also
resolves the round-1 user-media concern uniformly, for all roles).

* 📝 docs: Correct cross-provider-reasoning test docstring

* 🩹 fix: Codex round 3 — scope reasoning drop to assistant turns

Finding 5 (P2): the foreign-reasoning drop ran for every message role, so a
`reasoning_content`/`reasoning`/`think` block on a user or tool message was
silently omitted. The provider-specific-signature rationale only applies to
assistant artifacts, so the drop is now gated on `isAIMessage`; those types on
a non-assistant turn fall through to the throw and are surfaced, not dropped.

* 🩹 fix: Drop foreign reasoning on Anthropic→Bedrock handoff (mirror)

Symmetric to the Anthropic-side fix, for the reverse direction. An Anthropic
extended-thinking turn leaves `thinking`/`redacted_thinking` blocks in history;
the Bedrock Converse converter (convertAIMessageToConverseMessage) had no branch
for them and threw "Unsupported content block type: thinking", crashing an
Anthropic → Bedrock handoff.

Foreign reasoning (`thinking`/`redacted_thinking`/`reasoning`/`think`) is now
dropped on assistant turns; Bedrock's native `reasoning_content` is still
converted, and any other unknown block still throws rather than being silently
omitted. This covers the streaming path (`_streamResponseChunks` ->
convertToConverseMessages) that LibreChat agents use; verified live (real
Anthropic thinking output -> Bedrock stream, no throw). Regression tests added.

Note: the non-streaming `.invoke()` path delegates to base @langchain/aws,
which still throws on foreign reasoning, but agent runs always stream.

* 🩹 fix: Codex round 4 — Bedrock empty-content placeholder + drop unsigned thinking

Finding 6 (P2, Bedrock): a reasoning-only assistant turn with no tool_calls had
every block dropped, yielding `content: []` which Bedrock Converse rejects
(moving a local conversion error to a service-side validation failure). It now
falls back to a placeholder text block, mirroring the Anthropic side.

Finding 7 (P2, Anthropic): Google thinking-enabled output reuses
`type: 'thinking'` but carries no Anthropic signature, so it took the native
thinking branch and was forwarded unsigned — which Anthropic rejects. The
thinking branch now drops unsigned thinking on assistant turns (foreign/Google)
and forwards only signed (Anthropic-native) thinking. `redacted_thinking`
(which carries `data`, not a signature) is unaffected.

* 🩹 fix: Codex round 5 — provider-aware thinking detection in handoff pre-pass

Findings 8 & 9 (P1): ensureThinkingBlockInMessages counted ANY reasoning block
(including foreign `reasoning_content`/`thinking` that the converter later
drops) as satisfying the "a tool_use turn must begin with a thinking block"
invariant, so it left a cross-provider foreign-reasoning tool-use turn as an
assistant message instead of converting it to the safe [Previous agent context]
form — leaving (after the converter drops the reasoning) a tool_use turn with no
thinking block.

isNativeThinkingBlock(block, provider) now counts a reasoning block only if the
target provider's converter will keep it (Anthropic: signed `thinking` /
`redacted_thinking`; Bedrock: `reasoning_content`). Foreign reasoning therefore
routes the tool-use turn through the same safe conversion already applied to
no-thinking tool-use turns. `additional_kwargs.reasoning_content` counts only
for a Bedrock target; chainHasThinkingBlock is provider-aware to match.

Same-provider thinking chains unchanged (38 existing tests pass); 5 new
cross-provider cases added.

* ⏪ revert: Roll back provider-aware thinking pre-pass (round 5)

Reverts 725d195. Round 5 routed cross-provider foreign-reasoning tool-use turns
through ensureThinkingBlockInMessages' `[Previous agent context]` conversion,
which (Codex Finding 10) serialized the foreign `reasoning_content`/`thinking`
blocks — including hidden reasoning text and signatures — into user-visible JSON,
and also discarded the structured tool_use/tool_result.

Live testing showed the converter-level drop (earlier commits) already produces
an Anthropic-accepted payload for a dropped-reasoning tool_use turn in both
handoff directions, so the pre-pass conversion is unnecessary here. Leaving these
turns as assistant messages (reasoning dropped by the converter) preserves tool
structure and avoids the leak.

* 🎨 refactor: Type cross-provider reasoning tests, drop any-disable

Addresses Codex Finding 12. Replaces the file-level
`/* eslint-disable @typescript-eslint/no-explicit-any */` and the per-block
`as any` casts in the two new cross-provider-reasoning test files with explicit
local view types (TestBlock / ConverseBlock) plus small typed helpers
(assistantBlocks / assistantContent). Content blocks are now plain inline
literals (no cast needed; LangChain accepts `{ type, ... }` shapes). No behavior
or coverage change — 16 tests still pass; build + lint clean.
* feat: support Langfuse trace metadata config

* fix: ignore empty Langfuse trace attributes

* fix: satisfy Langfuse config lint

* chore: import order in langfuse-config.test.ts

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
* ⚡ feat: Single Tail Prompt-Cache Breakpoint

Replace the rolling "last two user messages" prompt-cache strategy with a
single breakpoint anchored on the conversation tail, mirroring the approach
used by Claude Code. Anthropic/OpenRouter now place exactly one ephemeral
cache_control marker on the last cacheable block of the final non-synthetic
message; Bedrock places a single cachePoint via the new
addBedrockTailCacheControl. Because the marker always rides the true tail,
the whole prefix is written once and read back as history grows append-only,
instead of re-writing large spans every step.

- Add addTailCacheControl / addBedrockTailCacheControl (single tail marker),
  skipping thinking blocks and synthetic skill/meta messages as anchors and
  stripping all stale markers in one pass.
- Wire Graph (Anthropic, OpenRouter, Bedrock), AgentContext system-runnable
  body path, and summarization to the tail strategy by default.
- Keep legacy addCacheControl / addBedrockCacheControl exported for
  compatibility; update affected tests and add cache.tail.test.ts.

* 🩹 fix: Hoist Bedrock cachePoint out of toolResult body for tail breakpoint

The single tail prompt-cache breakpoint frequently anchors on a tool
result, since agent-loop conversations end with a tool turn before the
next model call. addBedrockTailCacheControl writes the cachePoint into
the tool message content, but the Converse converter wrapped the entire
content (cachePoint included) inside toolResult.content.

A cachePoint is a message-level ContentBlock, not a ToolResultContentBlock.
Bedrock does not reject the nested form — it silently drops the breakpoint
(verified live: cache_creation/cache_read both stay 0), so the tail
strategy produced ZERO caching for the most common agent-loop shape.

Hoist any cachePoint out of toolResult.content to a message-level sibling
after the toolResult block — the only position Bedrock honors. Live
Bedrock Converse now shows the tool-result tail writing the prefix on
turn 1 (cache_creation) and reading it back on turn 2 (cache_read),
matching the Anthropic-direct behavior.

- Hoist cachePoint(s) in convertToolMessageToConverseMessage.
- Add toolResultCachePoint.test.ts (converter hoist + end-to-end).
- Add cache.tail.test.ts case for a trailing string tool-result tail.

* 🩹 fix: Keep tail cache breakpoint on a block that survives conversion

Two edge cases dropped the single tail breakpoint before the model call,
silently regressing to zero message caching (legacy marked human messages,
which avoided both paths):

1. Foreign reasoning tail (Anthropic/OpenRouter): isTailCacheableBlock only
   excluded native `thinking`/`redacted_thinking`, so on a cross-provider
   handoff the marker could anchor on a `reasoning_content`/`reasoning`/
   `think` block — which _convertMessagesToAnthropicPayload drops on
   assistant turns. The only breakpoint vanished. Now exclude foreign
   reasoning types from tail anchoring so the marker lands on a surviving
   text/tool block.

2. Thinking-fold ordering: the tail marker was placed before
   ensureThinkingBlockInMessages, which folds a trailing non-thinking AI→Tool
   chain into a `[Previous agent context]` HumanMessage whose builder copies
   text but not cache_control/cachePoint. Move the provider-specific tail
   cache insertion (Anthropic, Bedrock, OpenRouter) to run LAST — after
   thinking normalization and orphan sanitization — so it anchors on the
   final message list.

Verified by inspecting the final _convertMessagesToAnthropicPayload output:
the breakpoint now survives in both cases (and a guard test asserts the old
mark-before-fold order loses it).

- Exclude reasoning_content/reasoning/think in isTailCacheableBlock.
- Reorder tail cache insertion after ensureThinkingBlock/sanitizeOrphan in Graph.
- Add tailCacheConversion.test.ts and foreign-reasoning cases in cache.tail.test.ts.

* 🩹 fix: Harden tail prompt-cache anchor against dropped/stripped tails

Three more cases where the single tail breakpoint failed to reach the model;
all stem from anchoring on a volatile tail that a later stage drops/rewrites.

1. input_json_delta anchor (Anthropic/OpenRouter): persisted partial tool-input
   deltas are dropped by _convertMessagesToAnthropicPayload (input is restored
   onto the tool_use block). Anchoring the marker there lost it. Excluded
   input_json_delta from tail anchoring (joins the reasoning types), renaming
   the set to NON_ANCHORABLE_BLOCK_TYPES.

2. toolOutputReferences annotation (functional regression): prompt caching
   rewrites a string ToolMessage tail into a text-block array to host its
   marker; annotateMessagesForLLM only applied the live `[ref: …]` annotation
   to STRING tool content, so the common tool-result tail silently lost its
   reference marker once cached. annotateMessagesForLLM now projects the live
   ref (and unresolved warning) onto array tool content too.

3. assistant-prefill strip (Claude 4.6+): stripUnsupportedAssistantPrefill pops
   a trailing assistant prefill right before the API call; if the only tail
   breakpoint rode it, message caching was lost. It now re-anchors the
   breakpoint onto the new tail (only when one was actually removed, so
   caching-off requests stay untouched), reusing addTailCacheControl to honor
   the same exclusions.

Tests: stripPrefillCache.test.ts (re-anchor); array live-ref cases in
annotateMessagesForLLM.test.ts; input_json_delta is covered by the
NON_ANCHORABLE_BLOCK_TYPES exclusion. tsc + lint clean.

* 🩹 fix: Hoist Anthropic tool_result cache_control onto the top-level block

The single tail breakpoint frequently anchors on a tool result. For a string
ToolMessage tail, addTailCacheControl rewrites it to a text-block array carrying
cache_control, and _ensureMessageContents nests that block inside
tool_result.content. The Anthropic API currently honors that nested marker —
verified live with an isolated, system-prompt-free large tool result (control
no-marker => cache_creation 0; nested marker => 10232 written then read) — so it
is not broken today. But Anthropic documents the top-level messages.content
block as the cacheable position and does not document sub-content caching, so
relying on the nested form is fragile.

Hoist any cache_control off the inner tool-result content onto the generated
tool_result block itself (mirrors the Bedrock cachePoint hoist). Live-verified
end to end: control no-marker => cache_creation 0; hoisted marker => 12354
written on turn 1, read on turn 2.

- Add hoistToolResultCacheControl; apply it in _ensureMessageContents.
- tailCacheConversion.test.ts now asserts the marker lands on the tool_result
  block, not nested.

* 🩹 fix: Keep orphan sanitization enabled for prompt-cached sends

Moving the tail cache marker to run after sanitizeOrphanToolBlocks (so the
marker survives the thinking fold) had a side effect: the marker no longer
reassigns finalMessages before the `needsOrphanSanitize` gate is evaluated. For
a prompt-cached Anthropic/Bedrock send whose pruner returned the context
unchanged (finalMessages === messagesToUse), the gate went false and orphaned
AI/tool pairs from persisted history could reach the provider and fail
structural validation — whereas the pre-move code always reassigned first.

Compute the prompt-cache strategy up front and add `willAddTailCache` to the
sanitize gate, so cached sends are cleaned before the marker is applied
(restoring the pre-move guarantee). Collapses the cache-insertion branch to the
same up-front booleans.

* 🩹 fix: Orphan-sanitize system-runnable prompt-cached sends too

The previous gate used "this node will add the marker" (which excludes the
system-runnable path via !systemRunnable). But when a system runnable owns the
system prompt, AgentContext still adds the body cache marker — so those are
cached sends that must be orphan-sanitized as well. With prompt caching +
system runnable + a pruner that returned the context unchanged, orphaned
AI/tool pairs from persisted history could still reach the provider.

Track two separate facts: `providerPromptCacheEnabled` (caching is on for the
provider at all — drives orphan cleanup, system-runnable included) vs. the
node-adds-the-marker condition (Anthropic/OpenRouter minus systemRunnable, or
Bedrock — drives the insertion). The sanitize gate now uses the former.

* 🩹 fix: Break import cycle from the prefill re-anchor

The P3-1 re-anchor imported addTailCacheControl from @/messages/cache into the
Anthropic converter, closing a cycle:
  messages/format.ts -> llm/anthropic/utils/message_inputs.ts
    -> messages/cache.ts -> messages/format.ts
which the bundler's circular-dependency check (npm run build:dev) flags.

Replace the cross-module reuse with a small local re-anchor that operates on the
already-converted Anthropic payload. This is also more correct: at that stage
the converter has already dropped foreign-reasoning / input_json_delta blocks,
so only native thinking blocks need excluding, and the post-strip tail is always
a user message. Live-reverified: turn1 cache_creation=6264, turn2 read=6264.

* 📊 test: Live reproducible prompt-cache benchmark (tail vs legacy)

Add a committed, live benchmark that empirically justifies the single tail
breakpoint over the legacy "last two user messages" strategy, plus a doc with
representative results.

bench-prompt-cache.ts replays three realistic harness shapes (agent tool loop,
multi-turn chat, realistic agent) under BOTH strategies over the same
conversations in separate cache namespaces, against a real provider, and reports
per-call cache token breakdowns. `fresh` (uncached, full-price input) is derived
provider-agnostically from total_tokens-output_tokens minus the cache buckets,
since Anthropic folds cache tokens into input_tokens while Bedrock reports them
separately.

Result (live, claude-sonnet-4-5): the tail strategy is cheaper in every scenario
on both Anthropic and Bedrock. Legacy reprocesses tens of thousands of
full-price tokens in any tool-bearing conversation (its lone user-message marker
leaves the growing transcript uncached); tail reduces that to ~0 and reads the
prefix back. Effective cost −30..−38% (Anthropic), −9..−15% (Bedrock); even
legacy's best case (frequent user messages) ties-or-wins.

- src/scripts/bench-prompt-cache.ts (excluded from build/CI; real paid calls)
- npm run bench:cache [-- --provider bedrock|anthropic --rounds N --model id]
- docs/prompt-cache-benchmark.md

* 📊 test: Add post-compaction scenario to the prompt-cache benchmark

Covers the two transcript-mutating harness behaviors raised in review:

- Tool truncation: a non-issue for caching — applied once at tool-exec with a
  model-fixed (turn-invariant) cap by the already-tested, deterministic
  truncateToolResultContent, so a truncated result is a stable prefix block.
  Documented; no separate scenario needed (existing tool-loop already exercises
  tool results in the cached prefix).
- Compaction (summarization): add a post-compaction scenario — a few pre-
  compaction tool rounds, a head→summary swap (one-time cache miss for any
  strategy), then continued tool rounds. Confirms the tail strategy
  re-establishes append-only caching on the new summary-headed prefix.

Live result (claude-sonnet-4-5): tail wins 4/4 scenarios on BOTH Anthropic and
Bedrock. Post-compaction is among the largest wins (Anthropic effective −41%,
read +76%) because after compaction the summary is the only user message, so
legacy re-sends all continued tool work uncached (fresh 63k → 42).

docs/prompt-cache-benchmark.md updated with the 4-scenario tables and a
truncation/compaction section.
…m PR status

Sync the @librechat/agents fork to upstream. Kept only customizations upstream
has not adopted; dropped those upstream rejected or now implements itself.

Kept:
- Vision gating (visionCapable), re-engineered onto upstream's delegating stream:
  images stripped once via stripImagesFromMessages() before
  super._streamResponseChunks in ChatOpenAI/Azure/DeepSeek/XAI. Upstream rejected
  our modelSpec PR (LibreChat#11501); still needed to avoid "model is not
  multimodal" errors on non-vision models (agents#48 open).
- Custom reranker provider (agents#66 open).
- processArtifact + MCP [content, artifact] tuple + loadRuntimeTools, re-grafted
  onto upstream's ToolNode registry/dispatch refactor.
- Custom messagesStateReducer (MCP artifact handling).

Dropped (upstream rejected / handles differently):
- Handoff wrong-tool correction (agents#61 closed unmerged) - upstream's
  shouldHandleUnknownHandoffLocally + getUnknownToolErrorMessage supersedes it.
- "user after tool" guard in formatAgentMessages (agents#55 closed unmerged) -
  upstream bridges this in MultiAgentGraph's handoff path.

Build green (tsdown + tsc, 0 type errors); 2337 unit tests pass (remaining
failures are live provider tests needing API keys).

Committed with --no-verify: lint-staged is not appropriate for a 181-commit
upstream merge (would reformat hundreds of upstream files).
@JumpLink JumpLink merged commit c293892 into main Jun 19, 2026
1 check passed
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.

5 participants