@wundr.io/langgraph-orchestrator 1.0.2-dev.20260530174250.ef0ec927

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.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Human Node - Human-in-the-loop interaction node
3
+ * @module @wundr.io/langgraph-orchestrator
4
+ */
5
+
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { z } from 'zod';
8
+
9
+ import type {
10
+ AgentState,
11
+ NodeDefinition,
12
+ NodeContext,
13
+ NodeResult,
14
+ Message,
15
+ } from '../types';
16
+
17
+ /**
18
+ * Configuration for human node
19
+ */
20
+ export interface HumanNodeConfig {
21
+ /** Prompt to display to the human */
22
+ readonly prompt?: string | ((state: AgentState) => string);
23
+ /** Input handler to collect human response */
24
+ readonly inputHandler: HumanInputHandler;
25
+ /** Validation for human input */
26
+ readonly validation?: z.ZodSchema;
27
+ /** Timeout for human response in milliseconds */
28
+ readonly timeout?: number;
29
+ /** What to do on timeout */
30
+ readonly onTimeout?: 'error' | 'skip' | 'default';
31
+ /** Default value to use on timeout */
32
+ readonly defaultValue?: unknown;
33
+ /** Pre-defined choices for the human */
34
+ readonly choices?: HumanChoice[];
35
+ /** Whether to require confirmation */
36
+ readonly requireConfirmation?: boolean;
37
+ /** Custom response processor */
38
+ readonly processResponse?: (
39
+ response: HumanResponse,
40
+ state: AgentState
41
+ ) => Partial<AgentState['data']>;
42
+ }
43
+
44
+ /**
45
+ * Handler for collecting human input
46
+ */
47
+ export interface HumanInputHandler {
48
+ /** Request input from human */
49
+ request(context: HumanInputContext): Promise<HumanResponse>;
50
+ /** Cancel a pending request */
51
+ cancel?(requestId: string): Promise<void>;
52
+ }
53
+
54
+ /**
55
+ * Context provided to human input handler
56
+ */
57
+ export interface HumanInputContext {
58
+ /** Unique request ID */
59
+ readonly requestId: string;
60
+ /** Prompt to display */
61
+ readonly prompt: string;
62
+ /** Available choices */
63
+ readonly choices?: HumanChoice[];
64
+ /** Current workflow state (sanitized) */
65
+ readonly state: Partial<AgentState>;
66
+ /** Timeout in milliseconds */
67
+ readonly timeout?: number;
68
+ /** Additional metadata */
69
+ readonly metadata?: Record<string, unknown>;
70
+ }
71
+
72
+ /**
73
+ * Human choice option
74
+ */
75
+ export interface HumanChoice {
76
+ /** Choice value */
77
+ readonly value: string;
78
+ /** Display label */
79
+ readonly label: string;
80
+ /** Description */
81
+ readonly description?: string;
82
+ /** Whether this is the default choice */
83
+ readonly default?: boolean;
84
+ }
85
+
86
+ /**
87
+ * Response from human input
88
+ */
89
+ export interface HumanResponse {
90
+ /** The response value */
91
+ readonly value: unknown;
92
+ /** Response type */
93
+ readonly type: 'input' | 'choice' | 'confirmation' | 'cancel' | 'timeout';
94
+ /** Timestamp of response */
95
+ readonly timestamp: Date;
96
+ /** Additional metadata */
97
+ readonly metadata?: Record<string, unknown>;
98
+ }
99
+
100
+ /**
101
+ * Schema for human node configuration validation
102
+ */
103
+ export const HumanNodeConfigSchema = z.object({
104
+ prompt: z.union([z.string(), z.function()]).optional(),
105
+ timeout: z.number().min(0).optional(),
106
+ onTimeout: z.enum(['error', 'skip', 'default']).optional(),
107
+ choices: z
108
+ .array(
109
+ z.object({
110
+ value: z.string(),
111
+ label: z.string(),
112
+ description: z.string().optional(),
113
+ default: z.boolean().optional(),
114
+ }),
115
+ )
116
+ .optional(),
117
+ requireConfirmation: z.boolean().optional(),
118
+ });
119
+
120
+ /**
121
+ * Create a human-in-the-loop node
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * const humanNode = createHumanNode({
126
+ * id: 'approval',
127
+ * name: 'Human Approval',
128
+ * config: {
129
+ * prompt: 'Please review and approve the generated content.',
130
+ * inputHandler: myInputHandler,
131
+ * choices: [
132
+ * { value: 'approve', label: 'Approve' },
133
+ * { value: 'reject', label: 'Reject' },
134
+ * { value: 'modify', label: 'Request Modifications' }
135
+ * ],
136
+ * timeout: 300000 // 5 minutes
137
+ * }
138
+ * });
139
+ *
140
+ * graph.addNode('approval', humanNode);
141
+ * ```
142
+ *
143
+ * @param options - Node creation options
144
+ * @returns NodeDefinition for use in StateGraph
145
+ */
146
+ export function createHumanNode<
147
+ TState extends AgentState = AgentState,
148
+ >(options: {
149
+ id: string;
150
+ name: string;
151
+ config: HumanNodeConfig;
152
+ nodeConfig?: NodeDefinition<TState>['config'];
153
+ }): NodeDefinition<TState> {
154
+ const { id, name, config, nodeConfig = {} } = options;
155
+
156
+ return {
157
+ id,
158
+ name,
159
+ type: 'human',
160
+ config: nodeConfig,
161
+ execute: async (
162
+ state: TState,
163
+ context: NodeContext,
164
+ ): Promise<NodeResult<TState>> => {
165
+ const requestId = uuidv4();
166
+
167
+ // Build prompt
168
+ const prompt =
169
+ typeof config.prompt === 'function'
170
+ ? config.prompt(state)
171
+ : (config.prompt ?? 'Please provide input:');
172
+
173
+ context.services.logger.info('Requesting human input', {
174
+ requestId,
175
+ hasChoices: Boolean(config.choices?.length),
176
+ });
177
+
178
+ // Build input context
179
+ const inputContext: HumanInputContext = {
180
+ requestId,
181
+ prompt,
182
+ choices: config.choices,
183
+ state: sanitizeStateForHuman(state),
184
+ timeout: config.timeout,
185
+ metadata: {
186
+ nodeId: id,
187
+ nodeName: name,
188
+ executionId: context.executionId,
189
+ },
190
+ };
191
+
192
+ let response: HumanResponse;
193
+
194
+ try {
195
+ // Request input with timeout if configured
196
+ if (config.timeout) {
197
+ response = await Promise.race([
198
+ config.inputHandler.request(inputContext),
199
+ createTimeoutPromise(config.timeout, requestId),
200
+ ]);
201
+ } else {
202
+ response = await config.inputHandler.request(inputContext);
203
+ }
204
+ } catch (error) {
205
+ // Handle timeout
206
+ if ((error as Error).message === 'TIMEOUT') {
207
+ response = {
208
+ value: config.defaultValue,
209
+ type: 'timeout',
210
+ timestamp: new Date(),
211
+ };
212
+
213
+ if (config.onTimeout === 'error') {
214
+ throw new Error(`Human input timed out after ${config.timeout}ms`);
215
+ }
216
+
217
+ if (config.onTimeout === 'skip') {
218
+ context.services.logger.warn('Human input timed out, skipping');
219
+ return { state };
220
+ }
221
+ } else {
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ context.services.logger.debug('Received human response', {
227
+ requestId,
228
+ type: response.type,
229
+ });
230
+
231
+ // Handle cancellation
232
+ if (response.type === 'cancel') {
233
+ context.services.logger.info('Human cancelled input');
234
+ return {
235
+ state,
236
+ terminate: true,
237
+ };
238
+ }
239
+
240
+ // Validate response if schema provided
241
+ if (config.validation && response.type !== 'timeout') {
242
+ try {
243
+ config.validation.parse(response.value);
244
+ } catch (validationError) {
245
+ throw new Error(
246
+ `Human input validation failed: ${validationError instanceof Error ? validationError.message : String(validationError)}`,
247
+ );
248
+ }
249
+ }
250
+
251
+ // Build human message
252
+ const humanMessage: Message = {
253
+ id: uuidv4(),
254
+ role: 'user',
255
+ content: formatResponseContent(response),
256
+ timestamp: response.timestamp,
257
+ metadata: {
258
+ requestId,
259
+ responseType: response.type,
260
+ ...response.metadata,
261
+ },
262
+ };
263
+
264
+ // Build updated state
265
+ let newData = { ...state.data };
266
+ newData['lastHumanResponse'] = response;
267
+
268
+ // Apply custom response processor
269
+ if (config.processResponse) {
270
+ const processed = config.processResponse(response, state);
271
+ newData = { ...newData, ...processed };
272
+ }
273
+
274
+ const newState: TState = {
275
+ ...state,
276
+ messages: [...state.messages, humanMessage],
277
+ data: newData,
278
+ } as TState;
279
+
280
+ return {
281
+ state: newState,
282
+ metadata: {
283
+ duration: 0,
284
+ },
285
+ };
286
+ },
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Sanitize state for human viewing
292
+ */
293
+ function sanitizeStateForHuman(state: AgentState): Partial<AgentState> {
294
+ return {
295
+ currentStep: state.currentStep,
296
+ data: state.data,
297
+ messages: state.messages.slice(-5), // Only show recent messages
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Create a timeout promise
303
+ */
304
+ function createTimeoutPromise(
305
+ timeout: number,
306
+ _requestId: string,
307
+ ): Promise<HumanResponse> {
308
+ return new Promise((_, reject) => {
309
+ setTimeout(() => reject(new Error('TIMEOUT')), timeout);
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Format response content as string
315
+ */
316
+ function formatResponseContent(response: HumanResponse): string {
317
+ if (response.type === 'timeout') {
318
+ return '[Timed out - using default]';
319
+ }
320
+
321
+ if (response.type === 'cancel') {
322
+ return '[Cancelled]';
323
+ }
324
+
325
+ if (typeof response.value === 'string') {
326
+ return response.value;
327
+ }
328
+
329
+ return JSON.stringify(response.value);
330
+ }
331
+
332
+ /**
333
+ * Create a console-based human input handler
334
+ *
335
+ * @example
336
+ * ```typescript
337
+ * const handler = createConsoleInputHandler();
338
+ *
339
+ * const humanNode = createHumanNode({
340
+ * id: 'input',
341
+ * name: 'User Input',
342
+ * config: {
343
+ * inputHandler: handler,
344
+ * prompt: 'Enter your response:'
345
+ * }
346
+ * });
347
+ * ```
348
+ *
349
+ * @returns HumanInputHandler for console input
350
+ */
351
+ export function createConsoleInputHandler(): HumanInputHandler {
352
+ // Note: This is a placeholder - actual console input would need readline
353
+ // In production, use a proper event-based input handler
354
+ return {
355
+ async request(_context: HumanInputContext): Promise<HumanResponse> {
356
+ // In a real implementation, this would read from stdin
357
+ // For now, return a placeholder response
358
+ return {
359
+ value: 'placeholder-response',
360
+ type: 'input',
361
+ timestamp: new Date(),
362
+ };
363
+ },
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Create a callback-based human input handler
369
+ *
370
+ * @example
371
+ * ```typescript
372
+ * const pendingRequests = new Map();
373
+ *
374
+ * const handler = createCallbackInputHandler({
375
+ * onRequest: (context) => {
376
+ * pendingRequests.set(context.requestId, context);
377
+ * // Notify UI or external system
378
+ * },
379
+ * onResolve: (requestId) => pendingRequests.delete(requestId)
380
+ * });
381
+ *
382
+ * // External system resolves:
383
+ * handler.resolve(requestId, { value: 'user input', type: 'input', timestamp: new Date() });
384
+ * ```
385
+ *
386
+ * @param options - Handler options
387
+ * @returns HumanInputHandler with resolve capability
388
+ */
389
+ export function createCallbackInputHandler(options: {
390
+ onRequest?: (context: HumanInputContext) => void;
391
+ onResolve?: (requestId: string) => void;
392
+ onCancel?: (requestId: string) => void;
393
+ }): HumanInputHandler & {
394
+ resolve: (requestId: string, response: HumanResponse) => void;
395
+ reject: (requestId: string, error: Error) => void;
396
+ } {
397
+ const pending = new Map<
398
+ string,
399
+ {
400
+ resolve: (response: HumanResponse) => void;
401
+ reject: (error: Error) => void;
402
+ }
403
+ >();
404
+
405
+ return {
406
+ async request(context: HumanInputContext): Promise<HumanResponse> {
407
+ return new Promise((resolve, reject) => {
408
+ pending.set(context.requestId, { resolve, reject });
409
+ options.onRequest?.(context);
410
+ });
411
+ },
412
+
413
+ async cancel(requestId: string): Promise<void> {
414
+ const handlers = pending.get(requestId);
415
+ if (handlers) {
416
+ handlers.resolve({
417
+ value: null,
418
+ type: 'cancel',
419
+ timestamp: new Date(),
420
+ });
421
+ pending.delete(requestId);
422
+ options.onCancel?.(requestId);
423
+ }
424
+ },
425
+
426
+ resolve(requestId: string, response: HumanResponse): void {
427
+ const handlers = pending.get(requestId);
428
+ if (handlers) {
429
+ handlers.resolve(response);
430
+ pending.delete(requestId);
431
+ options.onResolve?.(requestId);
432
+ }
433
+ },
434
+
435
+ reject(requestId: string, error: Error): void {
436
+ const handlers = pending.get(requestId);
437
+ if (handlers) {
438
+ handlers.reject(error);
439
+ pending.delete(requestId);
440
+ }
441
+ },
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Create a confirmation node for human approval
447
+ *
448
+ * @example
449
+ * ```typescript
450
+ * const confirmNode = createConfirmationNode({
451
+ * id: 'confirm-action',
452
+ * name: 'Confirm Action',
453
+ * inputHandler: myHandler,
454
+ * message: (state) => `Are you sure you want to ${state.data.action}?`,
455
+ * onConfirm: 'execute-action',
456
+ * onReject: 'cancel-action'
457
+ * });
458
+ * ```
459
+ *
460
+ * @param options - Confirmation node options
461
+ * @returns NodeDefinition for use in StateGraph
462
+ */
463
+ export function createConfirmationNode<
464
+ TState extends AgentState = AgentState,
465
+ >(options: {
466
+ id: string;
467
+ name: string;
468
+ inputHandler: HumanInputHandler;
469
+ message: string | ((state: TState) => string);
470
+ onConfirm: string;
471
+ onReject: string;
472
+ timeout?: number;
473
+ nodeConfig?: NodeDefinition<TState>['config'];
474
+ }): NodeDefinition<TState> {
475
+ return createHumanNode<TState>({
476
+ id: options.id,
477
+ name: options.name,
478
+ config: {
479
+ prompt: options.message as string | ((state: AgentState) => string),
480
+ inputHandler: options.inputHandler,
481
+ choices: [
482
+ { value: 'confirm', label: 'Confirm', default: false },
483
+ { value: 'reject', label: 'Reject', default: false },
484
+ ],
485
+ timeout: options.timeout,
486
+ onTimeout: 'error',
487
+ processResponse: response => ({
488
+ confirmationResult: response.value,
489
+ }),
490
+ },
491
+ nodeConfig: options.nodeConfig,
492
+ });
493
+ }
494
+
495
+ /**
496
+ * Create a feedback collection node
497
+ *
498
+ * @example
499
+ * ```typescript
500
+ * const feedbackNode = createFeedbackNode({
501
+ * id: 'collect-feedback',
502
+ * name: 'Collect Feedback',
503
+ * inputHandler: myHandler,
504
+ * questions: [
505
+ * { id: 'rating', prompt: 'Rate this response (1-5):', type: 'number' },
506
+ * { id: 'comments', prompt: 'Any additional comments?', type: 'text' }
507
+ * ]
508
+ * });
509
+ * ```
510
+ *
511
+ * @param options - Feedback node options
512
+ * @returns NodeDefinition for use in StateGraph
513
+ */
514
+ export function createFeedbackNode<
515
+ TState extends AgentState = AgentState,
516
+ >(options: {
517
+ id: string;
518
+ name: string;
519
+ inputHandler: HumanInputHandler;
520
+ questions: Array<{
521
+ id: string;
522
+ prompt: string;
523
+ type: 'text' | 'number' | 'choice';
524
+ choices?: string[];
525
+ required?: boolean;
526
+ }>;
527
+ timeout?: number;
528
+ nodeConfig?: NodeDefinition<TState>['config'];
529
+ }): NodeDefinition<TState> {
530
+ return {
531
+ id: options.id,
532
+ name: options.name,
533
+ type: 'human',
534
+ config: options.nodeConfig ?? {},
535
+ execute: async (
536
+ state: TState,
537
+ _context: NodeContext,
538
+ ): Promise<NodeResult<TState>> => {
539
+ const feedback: Record<string, unknown> = {};
540
+
541
+ for (const question of options.questions) {
542
+ const requestId = uuidv4();
543
+
544
+ const inputContext: HumanInputContext = {
545
+ requestId,
546
+ prompt: question.prompt,
547
+ choices: question.choices?.map(c => ({ value: c, label: c })),
548
+ state: sanitizeStateForHuman(state),
549
+ timeout: options.timeout,
550
+ };
551
+
552
+ const response = await options.inputHandler.request(inputContext);
553
+
554
+ if (response.type === 'cancel') {
555
+ return { state, terminate: true };
556
+ }
557
+
558
+ feedback[question.id] = response.value;
559
+ }
560
+
561
+ const newState: TState = {
562
+ ...state,
563
+ data: {
564
+ ...state.data,
565
+ feedback,
566
+ },
567
+ } as TState;
568
+
569
+ return { state: newState };
570
+ },
571
+ };
572
+ }