@librechat/agents 3.6.1 → 3.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -161,6 +161,7 @@ import { createCloudflareCodingToolBundle } from '@/tools/cloudflare';
161
161
  import { buildSubagentToolParams } from '@/tools/SubagentTool';
162
162
  import { initializeLangfuseTracing } from '@/instrumentation';
163
163
  import { shouldTriggerSummarization } from '@/summarization';
164
+ import { isRunStepResumeState } from '@/tools/runStepResume';
164
165
  import { resolveLocalToolsForBinding } from '@/tools/local';
165
166
  import { createSummarizeNode } from '@/summarization/node';
166
167
  import { messagesStateReducer } from '@/messages/reducer';
@@ -168,7 +169,6 @@ import { createSchemaOnlyTools } from '@/tools/schema';
168
169
  import { AgentContext } from '@/agents/AgentContext';
169
170
  import { createFakeStreamingLLM } from '@/llm/fake';
170
171
  import { handleToolCalls } from '@/tools/handlers';
171
- import { isRunStepResumeState } from '@/tools/runStepResume';
172
172
  import { isThinkingEnabled } from '@/llm/request';
173
173
  import { resolveMaxSeals } from '@/llm/preempt';
174
174
  import { initializeModel } from '@/llm/init';
@@ -1800,9 +1800,10 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1800
1800
  version: 1,
1801
1801
  revision: this.runStepStateRevision,
1802
1802
  nextIndex: this.nextContentIndex,
1803
- toolCallSteps: [...this.toolCallStepIds].map(
1804
- ([toolCallId, stepId]) => ({ toolCallId, stepId })
1805
- ),
1803
+ toolCallSteps: [...this.toolCallStepIds].map(([toolCallId, stepId]) => ({
1804
+ toolCallId,
1805
+ stepId,
1806
+ })),
1806
1807
  steps,
1807
1808
  };
1808
1809
  }
@@ -1836,10 +1837,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1836
1837
  this.pendingToolCallsByStep.set(runStep.id, pending);
1837
1838
  }
1838
1839
  if (entry.latestCompletionAt != null) {
1839
- this.latestCompletionByStep.set(
1840
- runStep.id,
1841
- entry.latestCompletionAt
1842
- );
1840
+ this.latestCompletionByStep.set(runStep.id, entry.latestCompletionAt);
1843
1841
  }
1844
1842
  if (entry.openMessageStep) {
1845
1843
  this.openMessageStepByAgent.set(runStep.agentId ?? '', runStep.id);
@@ -1964,10 +1962,11 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1964
1962
  /**
1965
1963
  * Closes a run step: stamps its terminal status + timestamp on the stored
1966
1964
  * `RunStep` and emits `ON_RUN_STEP_CLOSED`. First close wins — later calls
1967
- * are no-ops except a `restamp` close, which lets a `completed`
1968
- * TOOL_CALLS step refresh `completed_at` when a late-registered parallel
1969
- * tool call finishes after the step already closed (the eager-execution
1970
- * race). `cancelled`/`failed` are immutable once stamped.
1965
+ * are no-ops with no exceptions; every terminal status is immutable once
1966
+ * stamped. (A `restamp` option once let a completed TOOL_CALLS step
1967
+ * refresh `completed_at` for the eager-execution race, but that race is
1968
+ * unreachable each step registers its calls before any completion can
1969
+ * reference it — and the mechanism was removed.)
1971
1970
  */
1972
1971
  async closeRunStep(
1973
1972
  stepId: string,
@@ -2025,12 +2024,36 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2025
2024
  GraphEvents.ON_RUN_STEP_CLOSED
2026
2025
  );
2027
2026
  if (handler) {
2028
- await handler.handle(
2029
- GraphEvents.ON_RUN_STEP_CLOSED,
2030
- closedEvent,
2031
- options?.metadata,
2032
- this
2033
- );
2027
+ /**
2028
+ * Isolated, unlike the other dual-dispatch sites, because this one
2029
+ * reports state that is already committed: the step was stamped
2030
+ * terminal and untracked above, and nothing a failed delivery can do
2031
+ * will undo that. Propagating instead costs two things.
2032
+ *
2033
+ * First, it fails an entire run over an observational event —
2034
+ * `closeOpenMessageStep` awaits this inside the stream loop on every
2035
+ * CHAT_MODEL_END, where a rejection sets `streamThrew` and fires the
2036
+ * StopFailure hooks for a response that was fully delivered.
2037
+ *
2038
+ * Second, and worse, it skips the secondary custom-event dispatch
2039
+ * below. That channel exists precisely as the fallback for when the
2040
+ * primary path does not deliver, so letting the primary's failure
2041
+ * suppress it removes the redundancy exactly when it is needed.
2042
+ *
2043
+ * `closeUnfinishedRunSteps` and `dispatchRunStep` already wrap their
2044
+ * own calls for the same reason; this closes the gap inside, which
2045
+ * those wrappers cannot reach.
2046
+ */
2047
+ try {
2048
+ await handler.handle(
2049
+ GraphEvents.ON_RUN_STEP_CLOSED,
2050
+ closedEvent,
2051
+ options?.metadata,
2052
+ this
2053
+ );
2054
+ } catch (_e) {
2055
+ /** Host delivery failure must not fail the run or block the echo */
2056
+ }
2034
2057
  this.handlerDispatchedStepIds.add(stepId);
2035
2058
  }
2036
2059
  const unmarkHandlerDispatchedEvent = handler
@@ -4990,10 +5013,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
4990
5013
  .addNode(agentNode, (state, config) =>
4991
5014
  invokeWithRunStepState(state, config, () => callModel(state, config))
4992
5015
  )
4993
- .addNode(
4994
- toolNode,
4995
- callTools
4996
- )
5016
+ .addNode(toolNode, callTools)
4997
5017
  .addNode(
4998
5018
  summarizeNode,
4999
5019
  createSummarizeNode({
@@ -43,6 +43,7 @@ import type { SeenScalarMetadata } from './streamMetadata';
43
43
  import type { HeaderValue, HeadersLike } from './types';
44
44
  import type { PromptCacheTtl } from '@/messages/cache';
45
45
  import {
46
+ OPENAI_RESPONSES_ACTIVE_REASONING_ID_KEY,
46
47
  OPENAI_RESPONSES_REPLAY_POSITIONS_KEY,
47
48
  projectOpenAIResponsesToolMessageContent,
48
49
  projectToolStreamContentForProvider,
@@ -606,6 +607,85 @@ function isResponsesReplayOutputItem(item: unknown): boolean {
606
607
  );
607
608
  }
608
609
 
610
+ type ResponsesReasoningSlot = {
611
+ encrypted_content?: string;
612
+ id?: string;
613
+ status?: string;
614
+ };
615
+
616
+ function getResponsesReasoningSlot(
617
+ reasoning: unknown
618
+ ): ResponsesReasoningSlot | undefined {
619
+ return typeof reasoning === 'object' && reasoning != null
620
+ ? (reasoning as ResponsesReasoningSlot)
621
+ : undefined;
622
+ }
623
+
624
+ function isSealedReasoningSlot(
625
+ slot: ResponsesReasoningSlot | undefined
626
+ ): slot is ResponsesReasoningSlot & { encrypted_content: string } {
627
+ return (
628
+ typeof slot?.encrypted_content === 'string' &&
629
+ slot.encrypted_content.length > 0
630
+ );
631
+ }
632
+
633
+ function resolveActiveReasoningItemId(
634
+ incoming: ResponsesReasoningSlot | undefined,
635
+ carried: unknown
636
+ ): string | undefined {
637
+ if (typeof incoming?.id === 'string' && incoming.id.length > 0) {
638
+ return incoming.id;
639
+ }
640
+ return typeof carried === 'string' ? carried : undefined;
641
+ }
642
+
643
+ /**
644
+ * A single `additional_kwargs.reasoning` slot has to stand in for a turn that
645
+ * can emit many reasoning items, and the chunk merge folds it field by field:
646
+ * `encrypted_content` and `status` are strings, so they concatenate, while
647
+ * `id` takes whichever item arrived last. An interrupted turn replays from
648
+ * that slot, handing the provider one item id welded to every item's
649
+ * ciphertext — rejected as "Encrypted content could not be decrypted or
650
+ * parsed". Keep the slot describing whichever item most recently sealed, so
651
+ * the id, its ciphertext, and its status always come from the same item.
652
+ *
653
+ * `activeItemId` tracks the item currently streaming, which is the one a
654
+ * terminal `encrypted_content` belongs to: the slot's own id has already been
655
+ * pinned back to the last sealed item by an earlier merge.
656
+ */
657
+ function resealReasoningItemBoundary(
658
+ combined: AIMessageChunk,
659
+ accumulated: ResponsesReasoningSlot | undefined,
660
+ incoming: ResponsesReasoningSlot | undefined,
661
+ activeItemId: string | undefined
662
+ ): void {
663
+ const merged = getResponsesReasoningSlot(
664
+ combined.additional_kwargs.reasoning
665
+ );
666
+ if (merged == null || incoming == null) {
667
+ return;
668
+ }
669
+ if (isSealedReasoningSlot(incoming)) {
670
+ combined.additional_kwargs.reasoning = {
671
+ ...merged,
672
+ id: activeItemId ?? merged.id,
673
+ encrypted_content: incoming.encrypted_content,
674
+ status: incoming.status,
675
+ };
676
+ return;
677
+ }
678
+ if (!isSealedReasoningSlot(accumulated)) {
679
+ return;
680
+ }
681
+ combined.additional_kwargs.reasoning = {
682
+ ...merged,
683
+ id: accumulated.id,
684
+ encrypted_content: accumulated.encrypted_content,
685
+ status: accumulated.status,
686
+ };
687
+ }
688
+
609
689
  /**
610
690
  * LangChain's Responses converter places the authoritative terminal output in
611
691
  * response_metadata.output. Its chunk merge has no way to delete provisional
@@ -619,10 +699,26 @@ class ResponsesReplayAIMessageChunk extends AIMessageChunk {
619
699
  }
620
700
 
621
701
  override concat(chunk: AIMessageChunk): this {
702
+ const accumulated = getResponsesReasoningSlot(
703
+ this.additional_kwargs.reasoning
704
+ );
705
+ const incoming = getResponsesReasoningSlot(
706
+ chunk.additional_kwargs.reasoning
707
+ );
708
+ const activeItemId = resolveActiveReasoningItemId(
709
+ incoming,
710
+ this.additional_kwargs[OPENAI_RESPONSES_ACTIVE_REASONING_ID_KEY]
711
+ );
622
712
  const combined = super.concat(chunk);
713
+ resealReasoningItemBoundary(combined, accumulated, incoming, activeItemId);
714
+ if (activeItemId != null) {
715
+ combined.additional_kwargs[OPENAI_RESPONSES_ACTIVE_REASONING_ID_KEY] =
716
+ activeItemId;
717
+ }
623
718
  if (!Array.isArray(chunk.response_metadata.output)) {
624
719
  return combined;
625
720
  }
721
+ delete combined.additional_kwargs[OPENAI_RESPONSES_ACTIVE_REASONING_ID_KEY];
626
722
  delete combined.additional_kwargs[OPENAI_RESPONSES_REPLAY_POSITIONS_KEY];
627
723
  const toolOutputs = combined.additional_kwargs.tool_outputs;
628
724
  if (!Array.isArray(toolOutputs)) {
@@ -671,6 +671,10 @@ type ResponsesReplayProjection = 'fallback' | 'native';
671
671
  export const OPENAI_RESPONSES_REPLAY_POSITIONS_KEY =
672
672
  '__openai_responses_replay_positions__';
673
673
 
674
+ /** Reasoning item currently streaming, so a terminal ciphertext seals against its own id. */
675
+ export const OPENAI_RESPONSES_ACTIVE_REASONING_ID_KEY =
676
+ '__openai_responses_active_reasoning_id__';
677
+
674
678
  export type ResponsesReplayPosition = {
675
679
  contentIndex?: number;
676
680
  itemId: string;
@@ -1813,6 +1817,7 @@ function projectPreemptedOpenAIResponsesMessage(
1813
1817
  }
1814
1818
  delete additionalKwargs.tool_outputs;
1815
1819
  delete additionalKwargs[OPENAI_RESPONSES_REPLAY_POSITIONS_KEY];
1820
+ delete additionalKwargs[OPENAI_RESPONSES_ACTIVE_REASONING_ID_KEY];
1816
1821
  delete additionalKwargs.__openai_function_call_ids__;
1817
1822
  delete additionalKwargs.__openai_custom_tool_call_ids__;
1818
1823
 
package/src/run.ts CHANGED
@@ -826,12 +826,23 @@ export class Run<_T extends t.BaseGraphState> {
826
826
  * The producer stamped `completed_at` before dispatch. Carrying it
827
827
  * through keeps the recorded duration the tool's, not the host
828
828
  * handler's — this runs after an arbitrarily slow handler resolves.
829
+ *
830
+ * Isolated because this is a `finally`: a throw here would
831
+ * REPLACE an in-flight error from the handler above, so the
832
+ * host's original failure would be reported as whatever went
833
+ * wrong during closure instead. Closing is best-effort at this
834
+ * point either way — the end-of-run sweep still stamps any step
835
+ * this misses.
829
836
  */
830
- await this.Graph.recordStepCompletion(completion.stepId, {
831
- toolCallId: completion.toolCallId,
832
- metadata,
833
- at: completion.completedAt,
834
- });
837
+ try {
838
+ await this.Graph.recordStepCompletion(completion.stepId, {
839
+ toolCallId: completion.toolCallId,
840
+ metadata,
841
+ at: completion.completedAt,
842
+ });
843
+ } catch (_e) {
844
+ /** Never mask the handler's error with a closure failure */
845
+ }
835
846
  }
836
847
  }
837
848
  }
@@ -87,7 +87,6 @@ import type {
87
87
  import type { GraphFactory } from '@/graphs/graphFactory';
88
88
  import type { StandardGraph } from '@/graphs/Graph';
89
89
  import type { HandlerRegistry } from '@/events';
90
- import { stripRunStepResumeState } from '@/tools/runStepResume';
91
90
  import {
92
91
  getSubagentApprovalExecutionScope,
93
92
  SubagentDefinitionBindingError,
@@ -125,6 +124,7 @@ import {
125
124
  createChildGraphPlan,
126
125
  isGraphSubagentConfig,
127
126
  } from './childGraphConfig';
127
+ import { stripRunStepResumeState } from '@/tools/runStepResume';
128
128
  import { seedAgentInitialSessions } from '@/utils/toolSessions';
129
129
  import { stableStringify } from '@/tools/eagerEventExecution';
130
130
  import { composeAbortSignals } from '@/utils/misc';
@@ -2450,6 +2450,18 @@ export class SubagentExecutor {
2450
2450
  } catch (error) {
2451
2451
  /** Stamped at failure, not after the error-envelope work below. */
2452
2452
  const childTerminalAt = Date.now();
2453
+ /**
2454
+ * Captured at catch entry, BEFORE the self-abort below flips it. The
2455
+ * closure sweep distinguishes "stopped on purpose" from "died of this
2456
+ * error" by whether the child was already aborted when the error
2457
+ * arrived — reading the signal after `childBreaker.abort(error)` would
2458
+ * relabel the child's own stream-limit failure as an intentional stop
2459
+ * (`cancelled`), while the parent stamps `failed` for the same
2460
+ * incident. A trip that arrived from a parallel sibling has already
2461
+ * aborted the composed signal by this point, so it still reads as
2462
+ * `cancelled` here.
2463
+ */
2464
+ const abortedBeforeError = childSignal.aborted;
2453
2465
  if (isGraphInterrupt(error)) {
2454
2466
  const activeChildRun = execution.activeRun;
2455
2467
  if (activeChildRun != null) {
@@ -2488,11 +2500,13 @@ export class SubagentExecutor {
2488
2500
  /**
2489
2501
  * `cancelled` vs `failed` mirrors `Run.resolveSweepStatus`: an aborted
2490
2502
  * child was stopped on purpose (caller abort, or a breaker trip from a
2491
- * parallel sibling), anything else died of an unexpected error.
2503
+ * parallel sibling), anything else died of an unexpected error. Uses
2504
+ * the pre-error snapshot, not the live signal — the self-abort above
2505
+ * has already tripped it for the child's own limit error.
2492
2506
  */
2493
2507
  await this.closeChildRunSteps(
2494
2508
  childGraph,
2495
- childSignal.aborted ? 'cancelled' : 'failed',
2509
+ abortedBeforeError ? 'cancelled' : 'failed',
2496
2510
  childTerminalAt
2497
2511
  );
2498
2512
  if (forwarding) {