@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.
- package/dist/cjs/graphs/Graph.cjs +9 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
- package/dist/cjs/hooks/HookRegistry.cjs +54 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
- package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
- package/dist/cjs/hooks/executeHooks.cjs +40 -1
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +317 -1
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +29 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/run.cjs +400 -42
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +551 -55
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +9 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +65 -0
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
- package/dist/esm/hooks/HookRegistry.mjs +54 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
- package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
- package/dist/esm/hooks/executeHooks.mjs +40 -1
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +318 -2
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/run.mjs +400 -42
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +552 -56
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +7 -0
- package/dist/types/hitl/askUserQuestion.d.ts +55 -0
- package/dist/types/hitl/index.d.ts +6 -0
- package/dist/types/hooks/HookRegistry.d.ts +58 -0
- package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
- package/dist/types/hooks/index.d.ts +4 -1
- package/dist/types/hooks/types.d.ts +109 -3
- package/dist/types/index.d.ts +9 -0
- package/dist/types/llm/openai/index.d.ts +17 -0
- package/dist/types/run.d.ts +117 -1
- package/dist/types/tools/ToolNode.d.ts +26 -1
- package/dist/types/types/hitl.d.ts +272 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/run.d.ts +33 -0
- package/dist/types/types/tools.d.ts +19 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +9 -0
- package/src/hitl/askUserQuestion.ts +72 -0
- package/src/hitl/index.ts +7 -0
- package/src/hooks/HookRegistry.ts +71 -0
- package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
- package/src/hooks/createToolPolicyHook.ts +184 -0
- package/src/hooks/executeHooks.ts +50 -1
- package/src/hooks/index.ts +6 -0
- package/src/hooks/types.ts +112 -0
- package/src/index.ts +19 -0
- package/src/llm/openai/deepseek.test.ts +479 -0
- package/src/llm/openai/index.ts +484 -1
- package/src/run.ts +456 -47
- package/src/tools/ToolNode.ts +701 -62
- package/src/tools/__tests__/hitl.test.ts +3593 -0
- package/src/tools/search/tavily-scraper.ts +4 -4
- package/src/tools/search/tavily-search.ts +32 -32
- package/src/tools/search/tool.ts +3 -3
- package/src/tools/search/types.ts +3 -1
- package/src/types/hitl.ts +303 -0
- package/src/types/index.ts +1 -0
- package/src/types/run.ts +33 -0
- 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
|
+
}
|
|
@@ -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
package/src/graphs/Graph.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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) {
|