@librechat/agents 3.1.76 → 3.1.77

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 (85) hide show
  1. package/dist/cjs/graphs/Graph.cjs +9 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
  4. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
  5. package/dist/cjs/hooks/HookRegistry.cjs +54 -0
  6. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  7. package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
  8. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
  9. package/dist/cjs/hooks/executeHooks.cjs +40 -1
  10. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/llm/openai/index.cjs +317 -1
  14. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  15. package/dist/cjs/main.cjs +29 -0
  16. package/dist/cjs/main.cjs.map +1 -1
  17. package/dist/cjs/run.cjs +400 -42
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +551 -55
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  22. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  23. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  24. package/dist/esm/graphs/Graph.mjs +9 -0
  25. package/dist/esm/graphs/Graph.mjs.map +1 -1
  26. package/dist/esm/hitl/askUserQuestion.mjs +65 -0
  27. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
  28. package/dist/esm/hooks/HookRegistry.mjs +54 -0
  29. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  30. package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
  31. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
  32. package/dist/esm/hooks/executeHooks.mjs +40 -1
  33. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  34. package/dist/esm/hooks/types.mjs +1 -0
  35. package/dist/esm/hooks/types.mjs.map +1 -1
  36. package/dist/esm/llm/openai/index.mjs +318 -2
  37. package/dist/esm/llm/openai/index.mjs.map +1 -1
  38. package/dist/esm/main.mjs +3 -0
  39. package/dist/esm/main.mjs.map +1 -1
  40. package/dist/esm/run.mjs +400 -42
  41. package/dist/esm/run.mjs.map +1 -1
  42. package/dist/esm/tools/ToolNode.mjs +552 -56
  43. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  44. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  45. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  46. package/dist/esm/tools/search/tool.mjs.map +1 -1
  47. package/dist/types/graphs/Graph.d.ts +7 -0
  48. package/dist/types/hitl/askUserQuestion.d.ts +55 -0
  49. package/dist/types/hitl/index.d.ts +6 -0
  50. package/dist/types/hooks/HookRegistry.d.ts +58 -0
  51. package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
  52. package/dist/types/hooks/index.d.ts +4 -1
  53. package/dist/types/hooks/types.d.ts +109 -3
  54. package/dist/types/index.d.ts +9 -0
  55. package/dist/types/llm/openai/index.d.ts +17 -0
  56. package/dist/types/run.d.ts +117 -1
  57. package/dist/types/tools/ToolNode.d.ts +26 -1
  58. package/dist/types/types/hitl.d.ts +272 -0
  59. package/dist/types/types/index.d.ts +1 -0
  60. package/dist/types/types/run.d.ts +33 -0
  61. package/dist/types/types/tools.d.ts +19 -0
  62. package/package.json +1 -1
  63. package/src/graphs/Graph.ts +9 -0
  64. package/src/hitl/askUserQuestion.ts +72 -0
  65. package/src/hitl/index.ts +7 -0
  66. package/src/hooks/HookRegistry.ts +71 -0
  67. package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
  68. package/src/hooks/createToolPolicyHook.ts +184 -0
  69. package/src/hooks/executeHooks.ts +50 -1
  70. package/src/hooks/index.ts +6 -0
  71. package/src/hooks/types.ts +112 -0
  72. package/src/index.ts +19 -0
  73. package/src/llm/openai/deepseek.test.ts +479 -0
  74. package/src/llm/openai/index.ts +484 -1
  75. package/src/run.ts +456 -47
  76. package/src/tools/ToolNode.ts +701 -62
  77. package/src/tools/__tests__/hitl.test.ts +3593 -0
  78. package/src/tools/search/tavily-scraper.ts +4 -4
  79. package/src/tools/search/tavily-search.ts +32 -32
  80. package/src/tools/search/tool.ts +3 -3
  81. package/src/tools/search/types.ts +3 -1
  82. package/src/types/hitl.ts +303 -0
  83. package/src/types/index.ts +1 -0
  84. package/src/types/run.ts +33 -0
  85. package/src/types/tools.ts +19 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * First-class human-in-the-loop (HITL) types for `@librechat/agents`.
3
+ * Surfaces the interrupt payload that `ToolNode` raises when a `PreToolUse`
4
+ * hook returns `decision: 'ask'` and HITL is enabled on the run, plus the
5
+ * resume-decision shape the host returns to continue or reject the tool.
6
+ *
7
+ * Mirrors the LangChain HITL middleware shape (action_requests /
8
+ * review_configs) so hosts and clients can share rendering/UI semantics
9
+ * across the langchain ecosystem.
10
+ */
11
+ /** Per-tool approval request emitted inside an interrupt payload. */
12
+ export interface ToolApprovalRequest {
13
+ /** Stable id of the tool call (matches LangGraph `ToolCall.id`). */
14
+ tool_call_id: string;
15
+ /** Tool name being invoked. */
16
+ name: string;
17
+ /**
18
+ * Arguments the tool is about to be invoked with — already resolved by
19
+ * any `{{tool<i>turn<n>}}` references and any `updatedInput` returned
20
+ * by the firing PreToolUse hook.
21
+ */
22
+ arguments: Record<string, unknown>;
23
+ /**
24
+ * Optional reason the hook supplied for asking (e.g., "destructive
25
+ * filesystem write"). Hosts can render this verbatim.
26
+ */
27
+ description?: string;
28
+ }
29
+ /** Allowed host-side decisions for a `tool_approval` interrupt. */
30
+ export type ToolApprovalDecisionType = 'approve' | 'reject' | 'edit' | 'respond';
31
+ /** Per-action review configuration paired with each action_request. */
32
+ export interface ToolApprovalReviewConfig {
33
+ /** Tool name (matches the `name` field on the corresponding action_request). */
34
+ action_name: string;
35
+ /**
36
+ * Stable id of the tool call this review_config applies to (matches
37
+ * the `tool_call_id` of the corresponding action_request). Lets a UI
38
+ * map review_configs → action_requests directly when a batch
39
+ * contains the same tool called more than once — by-position
40
+ * mapping breaks down with duplicates.
41
+ */
42
+ tool_call_id: string;
43
+ /** Decisions the host UI is allowed to surface for this action. */
44
+ allowed_decisions: ToolApprovalDecisionType[];
45
+ }
46
+ /**
47
+ * Resume value the host returns through `Run.resume(decisions)` after a
48
+ * `tool_approval` interrupt. One entry per action_request, in the same
49
+ * order. Hosts may also return a record keyed by `tool_call_id`; the SDK
50
+ * handles either shape.
51
+ *
52
+ * Variants:
53
+ * - `approve`: run the tool with its original (or hook-rewritten) args.
54
+ * - `reject`: skip the tool, emit a blocked error `ToolMessage` with
55
+ * `reason` surfaced to the model.
56
+ * - `edit`: replace the tool's args with `updatedInput` (re-resolves
57
+ * any `{{tool<i>turn<n>}}` placeholders) and run the tool.
58
+ * - `respond`: skip the tool entirely and emit `responseText` as a
59
+ * successful `ToolMessage`. Mirrors LangChain HITL middleware's
60
+ * `respond` semantic — the human supplies the result the model sees,
61
+ * bypassing tool execution. Useful when the user wants to short-circuit
62
+ * a tool call with a hand-written answer (e.g., "don't actually run
63
+ * the search, just tell the model 'no relevant results'").
64
+ *
65
+ * Note on hook semantics: `respond` does NOT fire the per-tool
66
+ * `PostToolUse` hook (no real tool execution happened, so the
67
+ * "post-tool" semantic doesn't apply). It DOES appear in the
68
+ * `PostToolBatch` entry array with `status: 'success'` and the
69
+ * user-supplied text as `toolOutput`, so batch-level audit /
70
+ * convention hooks see the full set of outcomes.
71
+ */
72
+ export type ToolApprovalDecision = {
73
+ type: 'approve';
74
+ } | {
75
+ type: 'reject';
76
+ reason?: string;
77
+ } | {
78
+ type: 'edit';
79
+ updatedInput: Record<string, unknown>;
80
+ } | {
81
+ type: 'respond';
82
+ responseText: string;
83
+ };
84
+ /** Map form of resume decisions, keyed by tool call id. */
85
+ export type ToolApprovalDecisionMap = Record<string, ToolApprovalDecision>;
86
+ /**
87
+ * Categories of human-in-the-loop interrupts the SDK can raise. Hosts
88
+ * narrow on `HumanInterruptPayload.type` to determine which payload
89
+ * shape they're handling and which resume value to send back through
90
+ * `Run.resume()`.
91
+ *
92
+ * Exported as a discrete type so downstream consumers (notably
93
+ * LibreChat's wire types in `librechat-data-provider`) can mirror
94
+ * the discriminator alongside their own host-side `PendingAction`
95
+ * record without re-declaring the union themselves. Internal SDK
96
+ * code narrows directly on the literal strings via the type guards
97
+ * below; this type alias is primarily an integration-layer contract.
98
+ */
99
+ export type HumanInterruptType = 'tool_approval' | 'ask_user_question';
100
+ /**
101
+ * Structured payload the SDK passes to `interrupt()` when one or more
102
+ * pending tool calls require host approval. All `ask`-decision tool calls
103
+ * from a single ToolNode batch are bundled into one interrupt so the host
104
+ * can render and resolve them together.
105
+ *
106
+ * Resume value: `ToolApprovalDecision[]` (in `action_requests` order) or
107
+ * `ToolApprovalDecisionMap` (keyed by `tool_call_id`).
108
+ */
109
+ export interface ToolApprovalInterruptPayload {
110
+ type: 'tool_approval';
111
+ action_requests: ToolApprovalRequest[];
112
+ review_configs: ToolApprovalReviewConfig[];
113
+ }
114
+ /**
115
+ * Pre-defined option the user can pick when answering an
116
+ * `ask_user_question` interrupt. The selected option's `value` becomes
117
+ * the resume value's `answer` field.
118
+ */
119
+ export interface AskUserQuestionOption {
120
+ /** Human-readable label rendered in the host UI. */
121
+ label: string;
122
+ /** Value returned via `AskUserQuestionResolution.answer` if picked. */
123
+ value: string;
124
+ }
125
+ /** Question request emitted inside an `ask_user_question` interrupt. */
126
+ export interface AskUserQuestionRequest {
127
+ /** The question to ask the human. */
128
+ question: string;
129
+ /** Optional context / description rendered alongside the question. */
130
+ description?: string;
131
+ /**
132
+ * Optional pre-defined response options. When present, hosts can render
133
+ * a picker; the user may still type a free-form answer when the host
134
+ * UI allows it. Omit to require a free-form answer.
135
+ */
136
+ options?: AskUserQuestionOption[];
137
+ }
138
+ /**
139
+ * Structured payload the SDK passes to `interrupt()` when an agent (or
140
+ * a custom node) needs to ask the user a clarifying question. Mirrors
141
+ * Claude Code's `AskUserQuestion` semantic. Resume value:
142
+ * `AskUserQuestionResolution`.
143
+ */
144
+ export interface AskUserQuestionInterruptPayload {
145
+ type: 'ask_user_question';
146
+ question: AskUserQuestionRequest;
147
+ }
148
+ /**
149
+ * Discriminated union of every interrupt payload the SDK raises. New
150
+ * variants can be added without breaking existing handlers as long as
151
+ * those handlers check `payload.type` before reading variant-specific
152
+ * fields. Use the `isToolApprovalInterrupt` / `isAskUserQuestionInterrupt`
153
+ * type guards for ergonomic narrowing.
154
+ */
155
+ export type HumanInterruptPayload = ToolApprovalInterruptPayload | AskUserQuestionInterruptPayload;
156
+ /** Resume value the host returns for an `ask_user_question` interrupt. */
157
+ export interface AskUserQuestionResolution {
158
+ /**
159
+ * The human's answer. Free-form text, or — when `options` were
160
+ * provided — one of the option `value`s. Hosts may also send any
161
+ * structured object their custom UI defines; see the host docs for
162
+ * what your downstream consumer expects.
163
+ */
164
+ answer: string;
165
+ }
166
+ /**
167
+ * Type guard narrowing an arbitrary value to a `ToolApprovalInterruptPayload`.
168
+ * Accepts `unknown` (not just `HumanInterruptPayload`) because hosts can
169
+ * raise custom interrupt payloads from custom nodes — `getInterrupt()`
170
+ * surfaces them as-is, and downstream code must validate the shape at
171
+ * runtime before reading variant-specific fields.
172
+ */
173
+ export declare function isToolApprovalInterrupt(payload: unknown): payload is ToolApprovalInterruptPayload;
174
+ /**
175
+ * Type guard narrowing an arbitrary value to an
176
+ * `AskUserQuestionInterruptPayload`. Same `unknown`-tolerant contract
177
+ * as `isToolApprovalInterrupt`.
178
+ */
179
+ export declare function isAskUserQuestionInterrupt(payload: unknown): payload is AskUserQuestionInterruptPayload;
180
+ /**
181
+ * Run-level configuration controlling HITL semantics. **HITL is OFF by
182
+ * default** for now — the SDK ships the interrupt machinery, but the
183
+ * default stays opt-in until host UIs (notably LibreChat) ship the
184
+ * approval-rendering affordances needed to surface interrupts to end
185
+ * users. Without that UI, an interrupt with no resolver looks like a
186
+ * hung tool-call card. Hosts opt in explicitly with
187
+ * `{ enabled: true }`. The intent is to flip this default to ON in a
188
+ * future minor once the consumer ecosystem is ready to render
189
+ * interrupts end-to-end.
190
+ *
191
+ * When enabled (`{ enabled: true }`):
192
+ *
193
+ * - `PreToolUse` hooks returning `decision: 'ask'` raise a real
194
+ * LangGraph `interrupt()` instead of being treated as a synchronous
195
+ * deny.
196
+ * - `Run.create` installs a `MemorySaver` checkpointer fallback on the
197
+ * run's compile options if the host did not provide one, since
198
+ * LangGraph requires a checkpointer to suspend and resume.
199
+ *
200
+ * When disabled (the default — omitted, or `{ enabled: false }`):
201
+ * `ask` decisions are fail-closed (blocked with an error
202
+ * `ToolMessage`) and no checkpointer is implicitly attached. This
203
+ * matches the pre-HITL behavior so existing hosts upgrading the SDK
204
+ * see no change until they're ready to wire the resume UI.
205
+ *
206
+ * ## Scope: event-driven tools only
207
+ *
208
+ * The interrupt path is wired into `ToolNode.dispatchToolEvents`, which
209
+ * runs when the agent uses event-driven tool dispatch (the path
210
+ * LibreChat and most production hosts take). Tools that execute via
211
+ * the direct path — i.e. tools listed in `directToolNames` (the
212
+ * graph-managed handoff and subagent tools) or tools on agents
213
+ * configured WITHOUT `eventDrivenMode` — bypass the hook system
214
+ * entirely. `PreToolUse` hooks do not fire for those tools and HITL
215
+ * approval does not gate them.
216
+ *
217
+ * Practical implications:
218
+ * - LibreChat-style hosts using event-driven dispatch get the full
219
+ * HITL surface across every tool the model calls.
220
+ * - Hosts using `AgentInputs.tools` directly without event-driven
221
+ * mode get policy enforcement for nothing — the hooks register
222
+ * but never fire. Either switch to event-driven mode or accept
223
+ * that direct tools are not approval-gated. This is documented
224
+ * also on `ToolNodeOptions.hookRegistry`.
225
+ * - Mixed direct + event batches (e.g. a handoff tool sharing an
226
+ * LLM turn with a regular tool) currently re-execute the direct
227
+ * half on resume, since LangGraph rolls back the entire ToolNode
228
+ * on `interrupt()` throw. Hosts whose direct tools have side
229
+ * effects (subagents that invoke models, handoffs that trigger
230
+ * downstream work) should avoid mixing those tools into the same
231
+ * batch as approval-gated event tools.
232
+ *
233
+ * ## Note on idempotency
234
+ *
235
+ * When an interrupt fires, LangGraph re-runs the interrupted node
236
+ * from the start on resume, which fires `PreToolUse` hooks again.
237
+ * Hooks that produce side effects (logging, external calls) will see
238
+ * two invocations per paused turn.
239
+ */
240
+ export interface HumanInTheLoopConfig {
241
+ /**
242
+ * Master switch. Defaults to `false` — omit the field (or pass
243
+ * `false`) to keep HITL off, or set `true` to opt in once the host
244
+ * UI is ready to render and resolve `tool_approval` interrupts.
245
+ */
246
+ enabled?: boolean;
247
+ }
248
+ /**
249
+ * Snapshot of an in-flight interrupt surfaced from `Run.processStream`
250
+ * via `run.getInterrupt()`. Hosts persist this alongside their job
251
+ * record so they can later call `Run.resume(decisions)` against a Run
252
+ * compiled with the same `thread_id` / checkpointer.
253
+ *
254
+ * The `payload` type defaults to `HumanInterruptPayload` (the SDK's
255
+ * built-in `tool_approval` / `ask_user_question` discriminated union)
256
+ * for ergonomic narrowing in the common case. Hosts that raise custom
257
+ * interrupt payloads from custom graph nodes can pass the type
258
+ * parameter (`run.getInterrupt<MyCustom>()` or
259
+ * `RunInterruptResult<MyCustom>`) — the SDK does not validate the
260
+ * runtime shape, it just transports whatever the node passed to
261
+ * `interrupt()`. Use the `isToolApprovalInterrupt` /
262
+ * `isAskUserQuestionInterrupt` guards (which accept `unknown`) when
263
+ * the source of the interrupt isn't statically known.
264
+ */
265
+ export interface RunInterruptResult<TPayload = HumanInterruptPayload> {
266
+ /** Stable id of the LangGraph interrupt (from `Interrupt.id`). */
267
+ interruptId: string;
268
+ /** `thread_id` the run was bound to — required to resume. */
269
+ threadId?: string;
270
+ /** Structured payload describing what needs human input. */
271
+ payload: TPayload;
272
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './graph';
2
+ export * from './hitl';
2
3
  export * from './llm';
3
4
  export * from './messages';
4
5
  export * from './run';
@@ -8,6 +8,7 @@ import type * as e from '@/common/enum';
8
8
  import type * as g from '@/types/graph';
9
9
  import type * as l from '@/types/llm';
10
10
  import type { ToolSessionMap, ToolOutputReferencesConfig } from '@/types/tools';
11
+ import type { HumanInTheLoopConfig } from '@/types/hitl';
11
12
  import type { HookRegistry } from '@/hooks';
12
13
  export type ZodObjectAny = z.ZodObject<any, any, any, any>;
13
14
  export type BaseGraphConfig = {
@@ -144,6 +145,38 @@ export type RunConfig = {
144
145
  * placeholders. Disabled by default so existing runs are unaffected.
145
146
  */
146
147
  toolOutputReferences?: ToolOutputReferencesConfig;
148
+ /**
149
+ * First-class human-in-the-loop (HITL) flow for this run.
150
+ *
151
+ * **HITL is OFF by default.** Omitting this field — or passing
152
+ * `{ enabled: false }` — keeps the pre-HITL fail-closed semantics
153
+ * where `ask` decisions collapse into a synchronous deny. Hosts opt
154
+ * in explicitly with `{ enabled: true }` once their UI can render
155
+ * and resolve `tool_approval` interrupts (otherwise the run just
156
+ * pauses with no resolver, which surfaces to end users as a hung
157
+ * tool-call card).
158
+ *
159
+ * Plan of record: the default flips back to ON in a future minor
160
+ * once the consumer ecosystem (notably LibreChat) ships HITL UI
161
+ * end-to-end. See `HumanInTheLoopConfig` JSDoc.
162
+ *
163
+ * When enabled (`{ enabled: true }`):
164
+ * - `PreToolUse` hooks returning `decision: 'ask'` raise a real
165
+ * LangGraph `interrupt()` instead of being treated as a synchronous
166
+ * deny. The graph pauses and the run exits cleanly.
167
+ * - If `graphConfig.compileOptions.checkpointer` is missing, the SDK
168
+ * installs an in-memory `MemorySaver` as a fallback so scripts and
169
+ * tests can resume without external infrastructure. Production
170
+ * hosts should always provide a durable checkpointer.
171
+ * - Hosts inspect the pending interrupt via `run.getInterrupt()` and
172
+ * continue with `Run.resume(decisions)` against a Run rebuilt with
173
+ * the same `thread_id` and checkpointer.
174
+ *
175
+ * When disabled (the default): `ask` decisions remain fail-closed
176
+ * (blocked with an error `ToolMessage`) and no checkpointer is
177
+ * implicitly attached.
178
+ */
179
+ humanInTheLoop?: HumanInTheLoopConfig;
147
180
  };
148
181
  export type ProvidedCallbacks = (BaseCallbackHandler | CallbackHandlerMethods)[] | undefined;
149
182
  export type TokenCounter = (message: BaseMessage) => number;
@@ -4,6 +4,7 @@ import type { ToolCall } from '@langchain/core/messages/tool';
4
4
  import type { HookRegistry } from '@/hooks';
5
5
  import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
6
6
  import type { MessageContentComplex, ToolErrorData } from './stream';
7
+ import type { HumanInTheLoopConfig } from './hitl';
7
8
  /** Replacement type for `import type { ToolCall } from '@langchain/core/messages/tool'` in order to have stringified args typed */
8
9
  export type CustomToolCall = {
9
10
  name: string;
@@ -46,6 +47,24 @@ export type ToolNodeOptions = {
46
47
  * routed through `directToolNames` bypass hook dispatch entirely.
47
48
  */
48
49
  hookRegistry?: HookRegistry;
50
+ /**
51
+ * Run-scoped HITL config. **HITL is OFF by default** — omitting this
52
+ * field (or passing `{ enabled: false }`) keeps the pre-HITL
53
+ * fail-closed behavior where a `PreToolUse` `ask` decision collapses
54
+ * into a blocked `ToolMessage`. Hosts opt in with
55
+ * `{ enabled: true }` once their UI can render and resolve a
56
+ * `tool_approval` interrupt; that engages the interrupt path where
57
+ * `ask` raises a real LangGraph `interrupt()` carrying a
58
+ * `HumanInterruptPayload` and the host resumes with
59
+ * `Run.resume(decisions)`.
60
+ *
61
+ * Mirrors `RunConfig.humanInTheLoop` (which is the canonical place
62
+ * to set this); the Graph threads it down to every ToolNode it
63
+ * compiles. Same caveat: the interrupt path is only wired into the
64
+ * event-driven dispatch (`dispatchToolEvents`), not into
65
+ * `directToolNames` execution — direct tools bypass HITL entirely.
66
+ */
67
+ humanInTheLoop?: HumanInTheLoopConfig;
49
68
  /** Max context tokens for the agent — used to compute tool result truncation limits. */
50
69
  maxContextTokens?: number;
51
70
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.1.76",
3
+ "version": "3.1.77",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -129,6 +129,13 @@ export abstract class Graph<
129
129
  invokedToolIds?: Set<string>;
130
130
  handlerRegistry: HandlerRegistry | undefined;
131
131
  hookRegistry: HookRegistry | undefined;
132
+ /**
133
+ * Run-scoped HITL configuration. When `humanInTheLoop?.enabled` is
134
+ * `true`, `ToolNode` raises a real `interrupt()` for `PreToolUse`
135
+ * `ask` decisions instead of treating them as a synchronous deny.
136
+ * Threaded from `RunConfig.humanInTheLoop`.
137
+ */
138
+ humanInTheLoop: t.HumanInTheLoopConfig | undefined;
132
139
  /**
133
140
  * Run-scoped config for the tool output reference registry. Threaded
134
141
  * from `RunConfig.toolOutputReferences` down into every ToolNode this
@@ -167,6 +174,7 @@ export abstract class Graph<
167
174
  this.invokedToolIds = undefined;
168
175
  this.handlerRegistry = undefined;
169
176
  this.hookRegistry = undefined;
177
+ this.humanInTheLoop = undefined;
170
178
  this.toolOutputReferences = undefined;
171
179
  /**
172
180
  * ToolNodes compiled from this graph captured the registry
@@ -579,6 +587,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
579
587
  toolCallStepIds: this.toolCallStepIds,
580
588
  toolRegistry: agentContext?.toolRegistry,
581
589
  hookRegistry: this.hookRegistry,
590
+ humanInTheLoop: this.humanInTheLoop,
582
591
  directToolNames: directToolNames.size > 0 ? directToolNames : undefined,
583
592
  maxContextTokens: agentContext?.maxContextTokens,
584
593
  maxToolResultChars: agentContext?.maxToolResultChars,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Typed convenience wrapper around LangGraph's `interrupt()` for the
3
+ * `ask_user_question` interrupt category. Lets a custom graph node
4
+ * (or a tool implementation) suspend execution to collect a free-form
5
+ * answer from the human, without the host having to assemble the
6
+ * interrupt payload by hand. The companion to `Run.resume(answer)` on
7
+ * the host side.
8
+ *
9
+ * AsyncLocalStorage anchoring: this helper does NOT call
10
+ * `runWithConfig` itself — it expects to be invoked from inside a
11
+ * LangGraph node where the framework has already established the
12
+ * runnable config. ToolNode is the one place in this codebase that
13
+ * needs the manual `runWithConfig` shim, because its
14
+ * `RunnableCallable.trace = false` skips the upstream tracing path
15
+ * that normally sets up the AsyncLocalStorage frame; ordinary user
16
+ * nodes (RunnableLambda, addNode callbacks) do not have that
17
+ * constraint.
18
+ */
19
+
20
+ import { interrupt } from '@langchain/langgraph';
21
+ import type {
22
+ AskUserQuestionRequest,
23
+ AskUserQuestionResolution,
24
+ AskUserQuestionInterruptPayload,
25
+ } from '@/types/hitl';
26
+
27
+ /**
28
+ * Suspend the current graph node to ask the human a question. Returns
29
+ * the host-supplied resolution after `Run.resume(resolution)` is
30
+ * called against a Run rebuilt with the same `thread_id` and
31
+ * checkpointer.
32
+ *
33
+ * On the FIRST call (no resume value available), `interrupt()` throws
34
+ * a `GraphInterrupt` that LangGraph catches; this function does not
35
+ * return — execution unwinds, the SDK persists the checkpoint, and
36
+ * the run completes with `run.getInterrupt()` returning a
37
+ * `RunInterruptResult` whose `payload` is an
38
+ * `AskUserQuestionInterruptPayload`.
39
+ *
40
+ * On RESUME, LangGraph re-runs the node from the start and this call
41
+ * returns the host's `AskUserQuestionResolution` directly.
42
+ *
43
+ * Hosts that prefer the raw `interrupt()` (e.g., to attach extra
44
+ * metadata) can construct an `AskUserQuestionInterruptPayload` and
45
+ * call `interrupt()` themselves — this helper is purely convenience.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const builder = new StateGraph(MessagesAnnotation)
50
+ * .addNode('clarifier', () => {
51
+ * const { answer } = askUserQuestion({
52
+ * question: 'Which environment should I deploy to?',
53
+ * options: [
54
+ * { label: 'Staging', value: 'staging' },
55
+ * { label: 'Production', value: 'production' },
56
+ * ],
57
+ * });
58
+ * return { messages: [new HumanMessage(`Use ${answer}`)] };
59
+ * });
60
+ * ```
61
+ */
62
+ export function askUserQuestion(
63
+ question: AskUserQuestionRequest
64
+ ): AskUserQuestionResolution {
65
+ const payload: AskUserQuestionInterruptPayload = {
66
+ type: 'ask_user_question',
67
+ question,
68
+ };
69
+ return interrupt<AskUserQuestionInterruptPayload, AskUserQuestionResolution>(
70
+ payload
71
+ );
72
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Human-in-the-loop helpers. Type definitions live in `@/types/hitl`
3
+ * and re-export from the top-level types barrel; runtime helpers (like
4
+ * `askUserQuestion()`) live here.
5
+ */
6
+
7
+ export { askUserQuestion } from './askUserQuestion';
@@ -13,6 +13,20 @@ import type { HookEvent, HookMatcher } from './types';
13
13
  */
14
14
  type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
15
15
 
16
+ /**
17
+ * Snapshot of a halt request raised by a hook returning
18
+ * `preventContinuation: true`. The SDK's run loop polls for this between
19
+ * stream events and exits cleanly when set, skipping the `Stop` hook
20
+ * (the run is being halted, not naturally completing). One per registry
21
+ * instance — the first hook to halt wins; subsequent halts are ignored
22
+ * so the original reason isn't clobbered.
23
+ */
24
+ export interface HookHaltSignal {
25
+ reason: string;
26
+ /** Event of the hook that triggered the halt (for diagnostics). */
27
+ source: HookEvent;
28
+ }
29
+
16
30
  /**
17
31
  * Run-scoped storage for hook matchers with an additional layer for
18
32
  * session-scoped matchers that should be cleaned up between sessions.
@@ -34,6 +48,18 @@ type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
34
48
  export class HookRegistry {
35
49
  private readonly global: MatcherBucket = {};
36
50
  private readonly sessions: Map<string, MatcherBucket> = new Map();
51
+ /**
52
+ * Per-session halt signals. Scoped by `sessionId` (= the run id the
53
+ * hook fired under) so a host that shares one registry across
54
+ * concurrent runs cannot leak `preventContinuation` from one run
55
+ * into another. Without scoping, a halt raised by run A's hook
56
+ * would trip run B's stream-loop poll on the next iteration —
57
+ * silently terminating an unrelated run.
58
+ *
59
+ * Map storage mirrors the reasoning above for session matchers:
60
+ * O(1) insertion in hot paths, no spread-on-write.
61
+ */
62
+ private readonly haltSignals: Map<string, HookHaltSignal> = new Map();
37
63
 
38
64
  /**
39
65
  * Register a matcher for the lifetime of this registry (= one Run).
@@ -125,6 +151,51 @@ export class HookRegistry {
125
151
  this.sessions.delete(sessionId);
126
152
  }
127
153
 
154
+ /**
155
+ * Raise a halt signal scoped to `sessionId` (= the run id the hook
156
+ * fired under). The SDK's run loop polls for this between stream
157
+ * events with the run's own id. First-write-wins per session: a
158
+ * halt already raised by an earlier hook in the same run is
159
+ * preserved so the original `reason` / `source` aren't overwritten.
160
+ *
161
+ * Per-session scoping is critical when hosts share one registry
162
+ * across concurrent runs (e.g. a global policy registered once and
163
+ * reused). Without it, a `preventContinuation` from run A would
164
+ * trip run B's stream-loop poll on the next iteration and silently
165
+ * terminate an unrelated run.
166
+ *
167
+ * Called by the SDK after `executeHooks` returns an aggregate with
168
+ * `preventContinuation: true`. Hosts can also call it directly from
169
+ * inside a hook callback if they want to halt without going through
170
+ * the aggregated return value, but `preventContinuation` is the
171
+ * canonical path.
172
+ */
173
+ haltRun(sessionId: string, reason: string, source: HookEvent): void {
174
+ if (this.haltSignals.has(sessionId)) {
175
+ return;
176
+ }
177
+ this.haltSignals.set(sessionId, { reason, source });
178
+ }
179
+
180
+ /**
181
+ * Returns the halt signal raised by hooks running under `sessionId`,
182
+ * or `undefined` if no hook in that run has halted. Polled by
183
+ * `Run.processStream` between stream events using the run's own id.
184
+ */
185
+ getHaltSignal(sessionId: string): HookHaltSignal | undefined {
186
+ return this.haltSignals.get(sessionId);
187
+ }
188
+
189
+ /**
190
+ * Clears the halt signal for `sessionId`. Called by
191
+ * `Run.processStream` in its `finally` block so a subsequent
192
+ * invocation of the same Run (e.g. resume) starts with a fresh
193
+ * halt state. No-op when no signal exists for that session.
194
+ */
195
+ clearHaltSignal(sessionId: string): void {
196
+ this.haltSignals.delete(sessionId);
197
+ }
198
+
128
199
  /** True if at least one matcher exists for `event` (global + session). */
129
200
  hasHookFor(event: HookEvent, sessionId?: string): boolean {
130
201
  if (readList(this.global, event).length > 0) {