@librechat/agents 3.4.5 → 3.4.6

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.
Files changed (81) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +268 -14
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/langfuseTraceShaping.cjs +9 -6
  6. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  7. package/dist/cjs/run.cjs +147 -14
  8. package/dist/cjs/run.cjs.map +1 -1
  9. package/dist/cjs/session/AgentSession.cjs +93 -31
  10. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  11. package/dist/cjs/session/handlers.cjs +10 -0
  12. package/dist/cjs/session/handlers.cjs.map +1 -1
  13. package/dist/cjs/stream.cjs +17 -3
  14. package/dist/cjs/stream.cjs.map +1 -1
  15. package/dist/cjs/summarization/node.cjs +1 -0
  16. package/dist/cjs/summarization/node.cjs.map +1 -1
  17. package/dist/cjs/tools/ToolNode.cjs +4 -2
  18. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  19. package/dist/cjs/tools/handlers.cjs +1 -0
  20. package/dist/cjs/tools/handlers.cjs.map +1 -1
  21. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +28 -0
  22. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  23. package/dist/cjs/utils/handlers.cjs +3 -0
  24. package/dist/cjs/utils/handlers.cjs.map +1 -1
  25. package/dist/esm/common/enum.mjs +2 -0
  26. package/dist/esm/common/enum.mjs.map +1 -1
  27. package/dist/esm/graphs/Graph.mjs +268 -14
  28. package/dist/esm/graphs/Graph.mjs.map +1 -1
  29. package/dist/esm/langfuseTraceShaping.mjs +9 -6
  30. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  31. package/dist/esm/run.mjs +147 -14
  32. package/dist/esm/run.mjs.map +1 -1
  33. package/dist/esm/session/AgentSession.mjs +93 -31
  34. package/dist/esm/session/AgentSession.mjs.map +1 -1
  35. package/dist/esm/session/handlers.mjs +10 -0
  36. package/dist/esm/session/handlers.mjs.map +1 -1
  37. package/dist/esm/stream.mjs +17 -3
  38. package/dist/esm/stream.mjs.map +1 -1
  39. package/dist/esm/summarization/node.mjs +1 -0
  40. package/dist/esm/summarization/node.mjs.map +1 -1
  41. package/dist/esm/tools/ToolNode.mjs +4 -2
  42. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  43. package/dist/esm/tools/handlers.mjs +1 -0
  44. package/dist/esm/tools/handlers.mjs.map +1 -1
  45. package/dist/esm/tools/subagent/SubagentExecutor.mjs +28 -0
  46. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  47. package/dist/esm/utils/handlers.mjs +3 -0
  48. package/dist/esm/utils/handlers.mjs.map +1 -1
  49. package/dist/types/common/enum.d.ts +2 -0
  50. package/dist/types/graphs/Graph.d.ts +87 -0
  51. package/dist/types/langfuseTraceShaping.d.ts +4 -3
  52. package/dist/types/run.d.ts +9 -1
  53. package/dist/types/session/types.d.ts +1 -1
  54. package/dist/types/summarization/node.d.ts +6 -0
  55. package/dist/types/types/graph.d.ts +3 -3
  56. package/dist/types/types/stream.d.ts +57 -0
  57. package/dist/types/utils/handlers.d.ts +1 -0
  58. package/package.json +1 -1
  59. package/src/aggregator.test.ts +127 -0
  60. package/src/common/enum.ts +2 -0
  61. package/src/graphs/Graph.ts +365 -7
  62. package/src/graphs/__tests__/Graph.closeRunStep.test.ts +385 -0
  63. package/src/langfuseTraceShaping.ts +14 -7
  64. package/src/run.ts +219 -41
  65. package/src/session/AgentSession.ts +105 -13
  66. package/src/session/handlers.ts +14 -2
  67. package/src/session/types.ts +1 -0
  68. package/src/specs/activity-label-observability.live.test.ts +262 -0
  69. package/src/specs/activity-label-observability.test.ts +167 -0
  70. package/src/specs/langfuse-trace-shaping.test.ts +32 -0
  71. package/src/specs/run-step-timestamps.test.ts +414 -0
  72. package/src/stream.ts +29 -6
  73. package/src/summarization/node.ts +11 -0
  74. package/src/tools/ToolNode.ts +2 -0
  75. package/src/tools/__tests__/SubagentExecutor.test.ts +52 -10
  76. package/src/tools/__tests__/handlers.test.ts +2 -0
  77. package/src/tools/handlers.ts +1 -0
  78. package/src/tools/subagent/SubagentExecutor.ts +46 -4
  79. package/src/types/graph.ts +9 -6
  80. package/src/types/stream.ts +65 -12
  81. package/src/utils/handlers.ts +13 -0
package/src/run.ts CHANGED
@@ -49,6 +49,12 @@ import {
49
49
  resolveLangfuseConfig,
50
50
  resolveToolOutputTracingConfig,
51
51
  } from '@/langfuseConfig';
52
+ import {
53
+ appendCallbacks,
54
+ filterCallbacks,
55
+ findCallback,
56
+ type CallbackEntry,
57
+ } from '@/utils/callbacks';
52
58
  import {
53
59
  resolveLangfuseRuntimeScope,
54
60
  withLangfuseRuntimeScope,
@@ -59,12 +65,6 @@ import {
59
65
  TitleMethod,
60
66
  DEFAULT_RECURSION_LIMIT,
61
67
  } from '@/common';
62
- import {
63
- appendCallbacks,
64
- filterCallbacks,
65
- findCallback,
66
- type CallbackEntry,
67
- } from '@/utils/callbacks';
68
68
  import {
69
69
  createCompletionTitleRunnable,
70
70
  createTitleRunnable,
@@ -93,11 +93,15 @@ export const defaultOmitOptions = new Set([
93
93
  'additionalModelRequestFields',
94
94
  ]);
95
95
 
96
+ const ACTIVITY_LABEL_TRACE_NAME = 'LibreChat Activity Label';
97
+ const ACTIVITY_PHASE_TRACE_NAME = 'LibreChat Activity Phase';
98
+
96
99
  const CUSTOM_GRAPH_EVENTS = new Set<string>([
97
100
  GraphEvents.ON_AGENT_UPDATE,
98
101
  GraphEvents.ON_RUN_STEP,
99
102
  GraphEvents.ON_RUN_STEP_DELTA,
100
103
  GraphEvents.ON_RUN_STEP_COMPLETED,
104
+ GraphEvents.ON_RUN_STEP_CLOSED,
101
105
  GraphEvents.ON_MESSAGE_DELTA,
102
106
  GraphEvents.ON_REASONING_DELTA,
103
107
  GraphEvents.ON_TOOL_EXECUTE,
@@ -113,6 +117,7 @@ const CUSTOM_GRAPH_EVENTS = new Set<string>([
113
117
  const DIRECT_DISPATCHED_STEP_EVENTS = new Set<string>([
114
118
  GraphEvents.ON_RUN_STEP,
115
119
  GraphEvents.ON_RUN_STEP_DELTA,
120
+ GraphEvents.ON_RUN_STEP_CLOSED,
116
121
  GraphEvents.ON_MESSAGE_DELTA,
117
122
  GraphEvents.ON_REASONING_DELTA,
118
123
  ]);
@@ -125,6 +130,38 @@ function getStepScopedEventId(data: unknown): string | undefined {
125
130
  return typeof candidate.id === 'string' ? candidate.id : undefined;
126
131
  }
127
132
 
133
+ /**
134
+ * Narrows an ON_RUN_STEP_COMPLETED payload (`{ result: ToolCompleteEvent }`)
135
+ * to the ids step closure needs. Returns undefined for malformed payloads and
136
+ * the resume race's empty step id.
137
+ */
138
+ function getToolCompletion(
139
+ data: unknown
140
+ ): { stepId: string; toolCallId?: string; completedAt?: number } | undefined {
141
+ if (data == null || typeof data !== 'object') {
142
+ return undefined;
143
+ }
144
+ const { result } = data as { result?: unknown };
145
+ if (result == null || typeof result !== 'object') {
146
+ return undefined;
147
+ }
148
+ const candidate = result as {
149
+ id?: unknown;
150
+ tool_call?: { id?: unknown };
151
+ completed_at?: unknown;
152
+ };
153
+ if (typeof candidate.id !== 'string' || candidate.id === '') {
154
+ return undefined;
155
+ }
156
+ const toolCallId = candidate.tool_call?.id;
157
+ const completedAt = candidate.completed_at;
158
+ return {
159
+ stepId: candidate.id,
160
+ ...(typeof toolCallId === 'string' ? { toolCallId } : {}),
161
+ ...(typeof completedAt === 'number' ? { completedAt } : {}),
162
+ };
163
+ }
164
+
128
165
  function isLangGraphResumeMapForInterrupt(
129
166
  value: unknown,
130
167
  interruptId: string
@@ -703,20 +740,50 @@ export class Run<_T extends t.BaseGraphState> {
703
740
  return;
704
741
  }
705
742
  const handler = this.handlerRegistry?.getHandler(eventName);
706
- if (handler && this.Graph) {
707
- return await handler.handle(
708
- eventName,
709
- data as
710
- | t.StreamEventData
711
- | t.ModelEndData
712
- | t.RunStep
713
- | t.RunStepDeltaEvent
714
- | t.MessageDeltaEvent
715
- | t.ReasoningDeltaEvent
716
- | { result: t.ToolEndEvent },
717
- metadata,
718
- this.Graph
719
- );
743
+ /**
744
+ * Tool completions arriving over the custom-event channel are the only
745
+ * signal ToolNode (which holds no graph reference) emits — observe them
746
+ * here to drive step closure. Runs in `finally`, independent of handler
747
+ * registration, so an absent or throwing host handler cannot lose the
748
+ * close; duplicate callback echoes are absorbed by the terminal-status
749
+ * guard in `closeRunStep`.
750
+ */
751
+ try {
752
+ if (handler && this.Graph) {
753
+ return await handler.handle(
754
+ eventName,
755
+ data as
756
+ | t.StreamEventData
757
+ | t.ModelEndData
758
+ | t.RunStep
759
+ | t.RunStepDeltaEvent
760
+ | t.RunStepClosedEvent
761
+ | t.MessageDeltaEvent
762
+ | t.ReasoningDeltaEvent
763
+ | { result: t.ToolEndEvent },
764
+ metadata,
765
+ this.Graph
766
+ );
767
+ }
768
+ } finally {
769
+ if (
770
+ eventName === GraphEvents.ON_RUN_STEP_COMPLETED &&
771
+ this.Graph != null
772
+ ) {
773
+ const completion = getToolCompletion(data);
774
+ if (completion != null) {
775
+ /**
776
+ * The producer stamped `completed_at` before dispatch. Carrying it
777
+ * through keeps the recorded duration the tool's, not the host
778
+ * handler's — this runs after an arbitrarily slow handler resolves.
779
+ */
780
+ await this.Graph.recordStepCompletion(completion.stepId, {
781
+ toolCallId: completion.toolCallId,
782
+ metadata,
783
+ at: completion.completedAt,
784
+ });
785
+ }
786
+ }
720
787
  }
721
788
  };
722
789
  }
@@ -731,6 +798,26 @@ export class Run<_T extends t.BaseGraphState> {
731
798
  );
732
799
  }
733
800
 
801
+ /**
802
+ * Terminal status for steps still open at end-of-run: `cancelled` for
803
+ * intentional stops (caller abort, hook halt), `failed` for unexpected
804
+ * stream errors, `completed` for a natural finish. Reads `_haltedReason`
805
+ * behind a method boundary on purpose — it is assigned inside the
806
+ * `consumeStream` closure, which control-flow narrowing cannot see.
807
+ */
808
+ private resolveSweepStatus(
809
+ streamThrew: boolean,
810
+ streamAborted: boolean
811
+ ): Exclude<t.RunStepStatus, 'in_progress'> {
812
+ if (streamThrew) {
813
+ return streamAborted ? 'cancelled' : 'failed';
814
+ }
815
+ if (this._haltedReason != null) {
816
+ return 'cancelled';
817
+ }
818
+ return 'completed';
819
+ }
820
+
734
821
  private getStreamLangfuseConfig(
735
822
  graph: StandardGraph | MultiAgentGraph
736
823
  ): t.LangfuseConfig | undefined {
@@ -1027,6 +1114,14 @@ export class Run<_T extends t.BaseGraphState> {
1027
1114
  * preserving session hooks would leak them into the next run.
1028
1115
  */
1029
1116
  let streamThrew = false;
1117
+ let streamAborted = false;
1118
+ /**
1119
+ * When the stream itself ended — captured before the post-stream work in
1120
+ * the `finally` (Stop/StopFailure hooks, Langfuse disposal, which can
1121
+ * force-flush) so a slow hook cannot inflate the terminal stamps that the
1122
+ * sweep writes onto steps that were still open.
1123
+ */
1124
+ let terminalAt: number | undefined;
1030
1125
 
1031
1126
  const consumeStream = async (): Promise<void> => {
1032
1127
  /**
@@ -1095,11 +1190,28 @@ export class Run<_T extends t.BaseGraphState> {
1095
1190
  }
1096
1191
  }
1097
1192
 
1193
+ /**
1194
+ * Stamped before the handler runs: the close below happens after an
1195
+ * arbitrarily slow host handler resolves, and the step's duration
1196
+ * should end when the model did, not when the host finished with it.
1197
+ */
1198
+ const modelEndAt =
1199
+ eventName === GraphEvents.CHAT_MODEL_END ? Date.now() : undefined;
1098
1200
  const handler = this.handlerRegistry?.getHandler(eventName);
1099
1201
  if (handler) {
1100
1202
  await handler.handle(eventName, data, metadata, this.Graph);
1101
1203
  }
1102
1204
 
1205
+ /**
1206
+ * A finished model call ends its lane's open message step. Placed
1207
+ * here — not in `ModelEndHandler` — because hosts replace the
1208
+ * CHAT_MODEL_END handler with their own instance, which would
1209
+ * silently drop the close.
1210
+ */
1211
+ if (eventName === GraphEvents.CHAT_MODEL_END && this.Graph != null) {
1212
+ await this.Graph.closeOpenMessageStep(metadata, modelEndAt);
1213
+ }
1214
+
1103
1215
  /**
1104
1216
  * Mid-flight halt: any hook (PreToolUse, PostToolUse,
1105
1217
  * PostToolBatch, SubagentStart/Stop, PreCompact, PostCompact)
@@ -1128,6 +1240,8 @@ export class Run<_T extends t.BaseGraphState> {
1128
1240
  }
1129
1241
  }
1130
1242
 
1243
+ terminalAt = Date.now();
1244
+
1131
1245
  if (this._interrupt != null) {
1132
1246
  await this.resolveInterruptResumeConfig(config);
1133
1247
  }
@@ -1212,7 +1326,17 @@ export class Run<_T extends t.BaseGraphState> {
1212
1326
  )
1213
1327
  );
1214
1328
  } catch (err) {
1329
+ terminalAt = Date.now();
1215
1330
  streamThrew = true;
1331
+ /**
1332
+ * Corroborate cancellation against an actually-aborted signal. A
1333
+ * provider SDK or host handler can reject with an `AbortError` while
1334
+ * nothing was cancelled — that is an unexpected failure (it also fires
1335
+ * `StopFailure`), and naming it `cancelled` would misreport abort
1336
+ * forensics.
1337
+ */
1338
+ streamAborted =
1339
+ config.signal?.aborted === true || this.Graph.signal?.aborted === true;
1216
1340
  if (this.hookRegistry?.hasHookFor('StopFailure', this.id) === true) {
1217
1341
  const runMessages = this.Graph.getRunMessages() ?? [];
1218
1342
  await executeHooks({
@@ -1260,6 +1384,29 @@ export class Run<_T extends t.BaseGraphState> {
1260
1384
  this.hookRegistry?.clearHaltSignal(this.id);
1261
1385
  await disposeLangfuseHandler(langfuseHandler);
1262
1386
 
1387
+ /**
1388
+ * Terminal sweep: close every step that never reached a terminal
1389
+ * status — `completed` on a natural end, `cancelled` on caller abort
1390
+ * or hook halt, `failed` on an unexpected stream error. Skipped on a
1391
+ * HITL pause, where the open steps continue after `resume()`.
1392
+ *
1393
+ * Runs BEFORE the callback teardown below, so a caller observing
1394
+ * lifecycle events only through `RunnableConfig.callbacks` still
1395
+ * receives these closures rather than being left with unmatched
1396
+ * starts, and before `getContentParts()` so terminal stamps flow into
1397
+ * content and session serialization.
1398
+ */
1399
+ if (!this.isAwaitingResume(streamThrew)) {
1400
+ try {
1401
+ await this.Graph.closeUnfinishedRunSteps(
1402
+ this.resolveSweepStatus(streamThrew, streamAborted),
1403
+ terminalAt
1404
+ );
1405
+ } catch {
1406
+ /* the sweep must never mask the stream outcome */
1407
+ }
1408
+ }
1409
+
1263
1410
  /**
1264
1411
  * Break the reference chain that keeps heavy data alive via
1265
1412
  * LangGraph's internal `__pregel_scratchpad.currentTaskInput` →
@@ -1866,22 +2013,15 @@ export class Run<_T extends t.BaseGraphState> {
1866
2013
  ? undefined
1867
2014
  : (requestedContext ??
1868
2015
  this.Graph.agentContexts.get(this.Graph.defaultAgentId));
1869
- const traceMetadata = createLangfuseTraceMetadata({
1870
- messageId: 'activity-label-' + this.id,
1871
- agentName: labelContext?.name,
1872
- });
1873
- const labelRunName = getLangfuseTraceName(
1874
- traceMetadata,
1875
- 'LibreChat Activity Label'
1876
- );
1877
-
1878
2016
  /** Shallow-cloned: activity labels run once per tool batch, and writing
1879
2017
  * the Langfuse handler back onto a host-reused `chainOptions` would
1880
2018
  * accumulate duplicate callbacks across batches. */
1881
2019
  const labelChainOptions = {
1882
2020
  ...(chainOptions ?? {}),
1883
2021
  } as Partial<RunnableConfig> & {
1884
- configurable?: Record<string, unknown>;
2022
+ configurable?: Record<string, unknown> & {
2023
+ requestBody?: { parentMessageId?: unknown };
2024
+ };
1885
2025
  };
1886
2026
  const labelUserId =
1887
2027
  typeof labelChainOptions.configurable?.user_id === 'string'
@@ -1891,6 +2031,42 @@ export class Run<_T extends t.BaseGraphState> {
1891
2031
  typeof labelChainOptions.configurable?.thread_id === 'string'
1892
2032
  ? (labelChainOptions.configurable.thread_id as string)
1893
2033
  : undefined;
2034
+ const labelIndex = labelSeq - 1;
2035
+ const labelParentMessageId =
2036
+ labelChainOptions.configurable?.requestBody?.parentMessageId;
2037
+ /** An omitted `agentId` is attributable only when exactly one context
2038
+ * exists. Multi-agent callers remain unattributed instead of being
2039
+ * incorrectly assigned to the graph's default agent. */
2040
+ const labelAgentId =
2041
+ agentId ??
2042
+ (this.Graph?.agentContexts.size === 1
2043
+ ? this.Graph.defaultAgentId
2044
+ : undefined);
2045
+ const labelAgentName =
2046
+ labelAgentId == null ? undefined : labelContext?.name;
2047
+ const labelMetadata: Record<string, unknown> = {
2048
+ sourceRunId: this.id,
2049
+ responseId: this.id,
2050
+ activityIndex: labelIndex,
2051
+ ...(typeof labelParentMessageId === 'string'
2052
+ ? { parentMessageId: labelParentMessageId }
2053
+ : {}),
2054
+ ...(labelAgentId == null ? {} : { agentId: labelAgentId }),
2055
+ ...(labelAgentName == null ? {} : { agentName: labelAgentName }),
2056
+ };
2057
+ const traceMetadata = {
2058
+ ...createLangfuseTraceMetadata({
2059
+ messageId: 'activity-label-' + this.id,
2060
+ parentMessageId: labelParentMessageId,
2061
+ agentId: labelAgentId,
2062
+ agentName: labelAgentName,
2063
+ }),
2064
+ sourceRunId: this.id,
2065
+ responseId: this.id,
2066
+ activityIndex: String(labelIndex),
2067
+ };
2068
+ const labelRunName = labelChainOptions.runName ?? ACTIVITY_LABEL_TRACE_NAME;
2069
+ const labelTags = ['librechat', 'activity-label'];
1894
2070
  const labelLangfuseConfig = resolveLangfuseConfig(
1895
2071
  this.langfuse,
1896
2072
  labelContext?.langfuse
@@ -1934,14 +2110,14 @@ export class Run<_T extends t.BaseGraphState> {
1934
2110
  userId: labelUserId,
1935
2111
  sessionId: labelSessionId,
1936
2112
  traceMetadata,
1937
- tags: ['librechat', 'activity-label'],
2113
+ tags: labelTags,
1938
2114
  traceIdSeed:
1939
2115
  labelLangfuseConfig?.deterministicTraceId === true
1940
2116
  ? labelTraceSeed
1941
2117
  : undefined,
1942
2118
  runId: labelScopeRunId,
1943
2119
  toolOutputTracing: labelRuntimeScope.toolOutputTracing,
1944
- traceName: labelChainOptions.runName ?? labelRunName,
2120
+ traceName: labelRunName,
1945
2121
  });
1946
2122
  }
1947
2123
  if (labelLangfuseHandler != null) {
@@ -2027,7 +2203,12 @@ export class Run<_T extends t.BaseGraphState> {
2027
2203
  const invokeConfig = Object.assign({}, labelChainOptions, {
2028
2204
  run_id: labelRunId,
2029
2205
  runId: labelRunId,
2030
- runName: labelChainOptions.runName ?? labelRunName,
2206
+ runName: labelRunName,
2207
+ tags: [...new Set([...(labelChainOptions.tags ?? []), ...labelTags])],
2208
+ metadata: {
2209
+ ...(labelChainOptions.metadata ?? {}),
2210
+ ...labelMetadata,
2211
+ },
2031
2212
  }) as Partial<RunnableConfig>;
2032
2213
 
2033
2214
  const invokeLabel = (
@@ -2038,9 +2219,9 @@ export class Run<_T extends t.BaseGraphState> {
2038
2219
  langfuse: labelLangfuseConfig,
2039
2220
  userId: labelUserId,
2040
2221
  sessionId: labelSessionId,
2041
- traceName: runtimeConfig.runName ?? labelRunName,
2222
+ traceName: labelRunName,
2042
2223
  traceMetadata,
2043
- tags: ['librechat', 'activity-label'],
2224
+ tags: labelTags,
2044
2225
  },
2045
2226
  () =>
2046
2227
  model.invoke(
@@ -2124,7 +2305,7 @@ export class Run<_T extends t.BaseGraphState> {
2124
2305
 
2125
2306
  /**
2126
2307
  * Generates one parent summary for two or more logical activities. The
2127
- * summary model is traced as a dedicated activity-phase agent root in the
2308
+ * summary model is traced as a dedicated activity-phase chain root in the
2128
2309
  * conversation session, with the model callback recorded as its generation
2129
2310
  * child. No session id means no phase trace, avoiding orphan observations.
2130
2311
  */
@@ -2289,11 +2470,8 @@ export class Run<_T extends t.BaseGraphState> {
2289
2470
  : { contributingAgentIds: contributingAgentIds.join(',') }),
2290
2471
  ...(closingTextPhase == null ? {} : { closingTextPhase }),
2291
2472
  };
2292
- const phaseRunName = getLangfuseTraceName(
2293
- traceMetadata,
2294
- 'LibreChat Activity Phase'
2295
- );
2296
- const phaseTraceName = phaseChainOptions.runName ?? phaseRunName;
2473
+ const phaseTraceName =
2474
+ phaseChainOptions.runName ?? ACTIVITY_PHASE_TRACE_NAME;
2297
2475
  const phaseTags = [
2298
2476
  'librechat',
2299
2477
  'activity-phase',
@@ -616,13 +616,70 @@ function createManualCompactGraph(params: {
616
616
  }): {
617
617
  graph: Parameters<typeof createSummarizeNode>[0]['graph'];
618
618
  completedSummary?: t.SummaryContentBlock;
619
+ closeOpenSteps: (
620
+ status: Exclude<t.RunStepStatus, 'in_progress'>
621
+ ) => Promise<void>;
619
622
  } {
620
623
  const contentData: t.RunStep[] = [];
621
624
  const contentIndexMap = new Map<string, number>();
625
+ /** Stamps the terminal state and publishes the single terminal event. */
626
+ const closeStep = async (
627
+ runStep: t.RunStep,
628
+ status: Exclude<t.RunStepStatus, 'in_progress'>,
629
+ at: number
630
+ ): Promise<void> => {
631
+ if (runStep.status !== 'in_progress') {
632
+ return;
633
+ }
634
+ runStep.status = status;
635
+ if (status === 'completed') {
636
+ runStep.completed_at = at;
637
+ } else if (status === 'cancelled') {
638
+ runStep.cancelled_at = at;
639
+ } else {
640
+ runStep.failed_at = at;
641
+ }
642
+ const closedEvent: t.RunStepClosedEvent = {
643
+ id: runStep.id,
644
+ index: runStep.index,
645
+ type: runStep.type,
646
+ status,
647
+ closed_at: at,
648
+ };
649
+ if (runStep.created_at != null) {
650
+ closedEvent.created_at = runStep.created_at;
651
+ }
652
+ if (runStep.runId != null) {
653
+ closedEvent.runId = runStep.runId;
654
+ }
655
+ await params.customHandlers?.[GraphEvents.ON_RUN_STEP_CLOSED]?.handle(
656
+ GraphEvents.ON_RUN_STEP_CLOSED,
657
+ closedEvent
658
+ );
659
+ };
622
660
  const result: {
623
661
  graph: Parameters<typeof createSummarizeNode>[0]['graph'];
624
662
  completedSummary?: t.SummaryContentBlock;
663
+ closeOpenSteps: (
664
+ status: Exclude<t.RunStepStatus, 'in_progress'>
665
+ ) => Promise<void>;
625
666
  } = {
667
+ /**
668
+ * Terminal sweep for compaction, which runs outside `Run.processStream`
669
+ * and so has no stream-level equivalent: a summarization model that
670
+ * rejects, aborts, or trips a stream limit after the step was published
671
+ * would otherwise strand observers with a permanently open step.
672
+ */
673
+ closeOpenSteps: async (status): Promise<void> => {
674
+ const closedAt = Date.now();
675
+ for (const runStep of contentData) {
676
+ try {
677
+ await closeStep(runStep, status, closedAt);
678
+ } catch (_e) {
679
+ /** Delivery failure for one step must not halt the sweep */
680
+ }
681
+ }
682
+ },
626
683
  graph: {
627
684
  contentData,
628
685
  contentIndexMap,
@@ -631,6 +688,8 @@ function createManualCompactGraph(params: {
631
688
  hookRegistry: params.hooks,
632
689
  streamLimits: resolveStreamLimits(params.streamLimits),
633
690
  dispatchRunStep: async (runStep): Promise<void> => {
691
+ runStep.created_at ??= Date.now();
692
+ runStep.status ??= 'in_progress';
634
693
  contentData.push(runStep);
635
694
  contentIndexMap.set(runStep.id, runStep.index);
636
695
  await params.customHandlers?.[GraphEvents.ON_RUN_STEP]?.handle(
@@ -638,12 +697,25 @@ function createManualCompactGraph(params: {
638
697
  runStep
639
698
  );
640
699
  },
700
+ closeRunStep: async (stepId, status): Promise<void> => {
701
+ const stepIndex = contentIndexMap.get(stepId);
702
+ const runStep =
703
+ stepIndex === undefined ? undefined : contentData[stepIndex];
704
+ if (runStep == null) {
705
+ return;
706
+ }
707
+ await closeStep(runStep, status, Date.now());
708
+ },
641
709
  dispatchRunStepCompleted: async (stepId, completed): Promise<void> => {
642
- const runStep = contentData.find((step) => step.id === stepId);
710
+ const completedAt = Date.now();
711
+ const stepIndex = contentIndexMap.get(stepId);
712
+ const runStep =
713
+ stepIndex === undefined ? undefined : contentData[stepIndex];
643
714
  const resultWithStep = {
644
715
  ...completed,
645
716
  id: stepId,
646
717
  index: runStep?.index ?? 0,
718
+ completed_at: completedAt,
647
719
  };
648
720
  if (completed.type === 'summary') {
649
721
  result.completedSummary = completed.summary;
@@ -653,6 +725,10 @@ function createManualCompactGraph(params: {
653
725
  ]?.handle(GraphEvents.ON_RUN_STEP_COMPLETED, {
654
726
  result: resultWithStep,
655
727
  } as unknown as Parameters<t.EventHandler['handle']>[1]);
728
+ if (runStep == null) {
729
+ return;
730
+ }
731
+ await closeStep(runStep, 'completed', completedAt);
656
732
  },
657
733
  },
658
734
  };
@@ -1302,19 +1378,35 @@ export class AgentSession {
1302
1378
  graph.graph.contentData.length,
1303
1379
  ],
1304
1380
  });
1305
- const summarizedState = await summarizeNode(
1306
- {
1307
- messages: sessionState.messages,
1308
- summarizationRequest: {
1309
- remainingContextTokens: agentContext.maxContextTokens ?? 0,
1310
- agentId: agentContext.agentId,
1381
+ let summarizedState;
1382
+ try {
1383
+ summarizedState = await summarizeNode(
1384
+ {
1385
+ messages: sessionState.messages,
1386
+ summarizationRequest: {
1387
+ remainingContextTokens: agentContext.maxContextTokens ?? 0,
1388
+ agentId: agentContext.agentId,
1389
+ },
1311
1390
  },
1312
- },
1313
- {
1314
- configurable: { thread_id: this.threadId },
1315
- metadata: { run_id: compactRunId },
1316
- }
1317
- );
1391
+ {
1392
+ configurable: { thread_id: this.threadId },
1393
+ metadata: { run_id: compactRunId },
1394
+ }
1395
+ );
1396
+ } catch (error) {
1397
+ /**
1398
+ * `failed`, not `cancelled`: manual compaction is invoked without a
1399
+ * caller abort signal, so there is no cancellation source to
1400
+ * corroborate against. An error that merely carries the `AbortError`
1401
+ * name is an unexpected failure here, and it propagates to the caller
1402
+ * either way.
1403
+ */
1404
+ await graph.closeOpenSteps('failed').catch(() => {
1405
+ /** the sweep must never mask the summarization failure */
1406
+ });
1407
+ throw error;
1408
+ }
1409
+ await graph.closeOpenSteps('completed');
1318
1410
  const completedSummaryText = getSummaryText(graph.completedSummary);
1319
1411
  const contextSummaryText = agentContext.getSummaryText();
1320
1412
  let summaryText = completedSummaryText;
@@ -5,13 +5,13 @@ import type {
5
5
  AgentSessionUsage,
6
6
  } from './types';
7
7
  import type * as t from '@/types';
8
- import { ModelEndHandler, ToolEndHandler } from '@/events';
9
- import { toJsonValue } from './messageSerialization';
10
8
  import {
11
9
  createContentAggregator,
12
10
  dispatchesChatModelStream,
13
11
  SDK_STREAM_DISPATCH,
14
12
  } from '@/stream';
13
+ import { ModelEndHandler, ToolEndHandler } from '@/events';
14
+ import { toJsonValue } from './messageSerialization';
15
15
  import { createTimestamp } from './ids';
16
16
  import { GraphEvents } from '@/common';
17
17
 
@@ -225,6 +225,18 @@ export function createRunHandlers(params: {
225
225
  });
226
226
  },
227
227
  },
228
+ [GraphEvents.ON_RUN_STEP_CLOSED]: {
229
+ handle: async (event, data, metadata, graph): Promise<void> => {
230
+ emitEvent(createEvent('step.finished', data as t.RunStepClosedEvent));
231
+ await callUserHandler({
232
+ userHandlers: params.userHandlers,
233
+ event,
234
+ data,
235
+ metadata,
236
+ graph,
237
+ });
238
+ },
239
+ },
228
240
  [GraphEvents.ON_MESSAGE_DELTA]: {
229
241
  handle: async (event, data, metadata, graph): Promise<void> => {
230
242
  const delta = data as t.MessageDeltaEvent;
@@ -225,6 +225,7 @@ export interface AgentSessionStreamEvent {
225
225
  | 'tool.started'
226
226
  | 'tool.delta'
227
227
  | 'tool.completed'
228
+ | 'step.finished'
228
229
  | 'usage.updated'
229
230
  | 'run.completed'
230
231
  | 'run.failed'