chore: merge upstream/main (v3.2.x); prune customizations per upstream PR status#1
Merged
Conversation
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)
… 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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.