human-in-the-loop 0.1.0 → 2.0.2

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/src/human.ts ADDED
@@ -0,0 +1,626 @@
1
+ /**
2
+ * Human-in-the-loop primitives implementation
3
+ */
4
+
5
+ import type {
6
+ Role,
7
+ Team,
8
+ Human as HumanType,
9
+ Goals,
10
+ KPIs,
11
+ OKRs,
12
+ HumanOptions,
13
+ HumanStore,
14
+ ApprovalRequest,
15
+ ApprovalResponse,
16
+ QuestionRequest,
17
+ TaskRequest,
18
+ DecisionRequest,
19
+ ReviewRequest,
20
+ ReviewResponse,
21
+ Notification,
22
+ ReviewQueue,
23
+ EscalationPolicy,
24
+ ApprovalWorkflow,
25
+ Priority,
26
+ HumanRequest,
27
+ } from './types.js'
28
+ import { InMemoryHumanStore } from './store.js'
29
+
30
+ /**
31
+ * Human-in-the-loop manager
32
+ *
33
+ * Provides primitives for integrating human oversight and intervention
34
+ * in AI workflows.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * const human = Human({
39
+ * defaultTimeout: 3600000, // 1 hour
40
+ * autoEscalate: true,
41
+ * })
42
+ *
43
+ * // Request approval
44
+ * const approval = await human.approve({
45
+ * title: 'Deploy to production',
46
+ * description: 'Approve deployment of v2.0.0',
47
+ * subject: 'Production Deployment',
48
+ * assignee: 'tech-lead@example.com',
49
+ * priority: 'high',
50
+ * })
51
+ *
52
+ * if (approval.approved) {
53
+ * await deploy()
54
+ * }
55
+ * ```
56
+ */
57
+ export class HumanManager {
58
+ private store: HumanStore
59
+ private options: Required<HumanOptions>
60
+ private roles = new Map<string, Role>()
61
+ private teams = new Map<string, Team>()
62
+ private humans = new Map<string, HumanType>()
63
+ private escalationPolicies = new Map<string, EscalationPolicy>()
64
+ private workflows = new Map<string, ApprovalWorkflow>()
65
+
66
+ constructor(options: HumanOptions = {}) {
67
+ this.store = options.store || new InMemoryHumanStore()
68
+ this.options = {
69
+ store: this.store,
70
+ defaultTimeout: options.defaultTimeout || 0, // No timeout by default
71
+ defaultPriority: options.defaultPriority || 'normal',
72
+ escalationPolicies: options.escalationPolicies || [],
73
+ autoEscalate: options.autoEscalate ?? false,
74
+ }
75
+
76
+ // Register escalation policies
77
+ for (const policy of this.options.escalationPolicies) {
78
+ this.escalationPolicies.set(policy.id, policy)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Define a role
84
+ */
85
+ defineRole(role: Role): Role {
86
+ this.roles.set(role.id, role)
87
+ return role
88
+ }
89
+
90
+ /**
91
+ * Get a role by ID
92
+ */
93
+ getRole(id: string): Role | undefined {
94
+ return this.roles.get(id)
95
+ }
96
+
97
+ /**
98
+ * Define a team
99
+ */
100
+ defineTeam(team: Team): Team {
101
+ this.teams.set(team.id, team)
102
+ return team
103
+ }
104
+
105
+ /**
106
+ * Get a team by ID
107
+ */
108
+ getTeam(id: string): Team | undefined {
109
+ return this.teams.get(id)
110
+ }
111
+
112
+ /**
113
+ * Register a human worker
114
+ */
115
+ registerHuman(human: HumanType): HumanType {
116
+ this.humans.set(human.id, human)
117
+ return human
118
+ }
119
+
120
+ /**
121
+ * Get a human by ID
122
+ */
123
+ getHuman(id: string): HumanType | undefined {
124
+ return this.humans.get(id)
125
+ }
126
+
127
+ /**
128
+ * Request approval from a human
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * const result = await human.approve({
133
+ * title: 'Approve expense',
134
+ * description: 'Employee expense claim for $150',
135
+ * subject: 'Expense Claim #1234',
136
+ * input: { amount: 150, category: 'Travel' },
137
+ * assignee: 'manager@example.com',
138
+ * priority: 'normal',
139
+ * })
140
+ * ```
141
+ */
142
+ async approve<TData = unknown>(params: {
143
+ title: string
144
+ description: string
145
+ subject: string
146
+ input: TData
147
+ assignee?: string | string[]
148
+ role?: string
149
+ team?: string
150
+ priority?: Priority
151
+ timeout?: number
152
+ escalatesTo?: string | string[]
153
+ requiresApproval?: boolean
154
+ approvers?: string[]
155
+ metadata?: Record<string, unknown>
156
+ }): Promise<ApprovalResponse> {
157
+ const request = await this.store.create<ApprovalRequest<TData>>({
158
+ type: 'approval',
159
+ status: 'pending',
160
+ title: params.title,
161
+ description: params.description,
162
+ subject: params.subject,
163
+ input: params.input,
164
+ assignee: params.assignee,
165
+ role: params.role,
166
+ team: params.team,
167
+ priority: params.priority || this.options.defaultPriority,
168
+ timeout: params.timeout || this.options.defaultTimeout,
169
+ escalatesTo: params.escalatesTo,
170
+ requiresApproval: params.requiresApproval ?? true,
171
+ approvers: params.approvers,
172
+ currentApproverIndex: 0,
173
+ metadata: params.metadata,
174
+ })
175
+
176
+ // In a real implementation, this would:
177
+ // 1. Send notification to assignee
178
+ // 2. Wait for response (polling, webhook, or event)
179
+ // 3. Handle timeout and escalation
180
+ // 4. Return the response
181
+
182
+ // For now, return the request ID as a placeholder
183
+ return this.waitForResponse<ApprovalRequest<TData>, ApprovalResponse>(request)
184
+ }
185
+
186
+ /**
187
+ * Ask a question to a human
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * const answer = await human.ask({
192
+ * title: 'Product naming',
193
+ * question: 'What should we name the new feature?',
194
+ * context: { feature: 'AI Assistant' },
195
+ * assignee: 'product-manager@example.com',
196
+ * })
197
+ * ```
198
+ */
199
+ async ask(params: {
200
+ title: string
201
+ question: string
202
+ context?: unknown
203
+ assignee?: string | string[]
204
+ role?: string
205
+ team?: string
206
+ priority?: Priority
207
+ timeout?: number
208
+ suggestions?: string[]
209
+ metadata?: Record<string, unknown>
210
+ }): Promise<string> {
211
+ const request = await this.store.create<QuestionRequest>({
212
+ type: 'question',
213
+ status: 'pending',
214
+ title: params.title,
215
+ description: params.question,
216
+ question: params.question,
217
+ input: { question: params.question, context: params.context },
218
+ context: params.context,
219
+ suggestions: params.suggestions,
220
+ assignee: params.assignee,
221
+ role: params.role,
222
+ team: params.team,
223
+ priority: params.priority || this.options.defaultPriority,
224
+ timeout: params.timeout || this.options.defaultTimeout,
225
+ metadata: params.metadata,
226
+ })
227
+
228
+ return this.waitForResponse<QuestionRequest, string>(request)
229
+ }
230
+
231
+ /**
232
+ * Request a human to perform a task
233
+ *
234
+ * @example
235
+ * ```ts
236
+ * const result = await human.do({
237
+ * title: 'Review code',
238
+ * instructions: 'Review the PR and provide feedback',
239
+ * input: { prUrl: 'https://github.com/...' },
240
+ * assignee: 'senior-dev@example.com',
241
+ * })
242
+ * ```
243
+ */
244
+ async do<TInput = unknown, TOutput = unknown>(params: {
245
+ title: string
246
+ instructions: string
247
+ input: TInput
248
+ assignee?: string | string[]
249
+ role?: string
250
+ team?: string
251
+ priority?: Priority
252
+ timeout?: number
253
+ tools?: any[]
254
+ estimatedEffort?: string
255
+ metadata?: Record<string, unknown>
256
+ }): Promise<TOutput> {
257
+ const request = await this.store.create<TaskRequest<TInput, TOutput>>({
258
+ type: 'task',
259
+ status: 'pending',
260
+ title: params.title,
261
+ description: params.instructions,
262
+ instructions: params.instructions,
263
+ input: params.input,
264
+ assignee: params.assignee,
265
+ role: params.role,
266
+ team: params.team,
267
+ priority: params.priority || this.options.defaultPriority,
268
+ timeout: params.timeout || this.options.defaultTimeout,
269
+ tools: params.tools,
270
+ estimatedEffort: params.estimatedEffort,
271
+ metadata: params.metadata,
272
+ })
273
+
274
+ return this.waitForResponse<TaskRequest<TInput, TOutput>, TOutput>(request)
275
+ }
276
+
277
+ /**
278
+ * Request a human to make a decision
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * const choice = await human.decide({
283
+ * title: 'Pick deployment strategy',
284
+ * options: ['blue-green', 'canary', 'rolling'],
285
+ * context: { risk: 'high', users: 100000 },
286
+ * assignee: 'devops-lead@example.com',
287
+ * })
288
+ * ```
289
+ */
290
+ async decide<TOptions extends string = string>(params: {
291
+ title: string
292
+ description?: string
293
+ options: TOptions[]
294
+ context?: unknown
295
+ assignee?: string | string[]
296
+ role?: string
297
+ team?: string
298
+ priority?: Priority
299
+ timeout?: number
300
+ criteria?: string[]
301
+ metadata?: Record<string, unknown>
302
+ }): Promise<TOptions> {
303
+ const request = await this.store.create<DecisionRequest<TOptions>>({
304
+ type: 'decision',
305
+ status: 'pending',
306
+ title: params.title,
307
+ description: params.description || `Choose from: ${params.options.join(', ')}`,
308
+ input: { options: params.options, context: params.context },
309
+ options: params.options,
310
+ context: params.context,
311
+ criteria: params.criteria,
312
+ assignee: params.assignee,
313
+ role: params.role,
314
+ team: params.team,
315
+ priority: params.priority || this.options.defaultPriority,
316
+ timeout: params.timeout || this.options.defaultTimeout,
317
+ metadata: params.metadata,
318
+ })
319
+
320
+ return this.waitForResponse<DecisionRequest<TOptions>, TOptions>(request)
321
+ }
322
+
323
+ /**
324
+ * Request a human to review content
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * const review = await human.review({
329
+ * title: 'Review blog post',
330
+ * content: { title: 'My Post', body: '...' },
331
+ * reviewType: 'content',
332
+ * criteria: ['Grammar', 'Tone', 'Accuracy'],
333
+ * assignee: 'editor@example.com',
334
+ * })
335
+ * ```
336
+ */
337
+ async review<TContent = unknown>(params: {
338
+ title: string
339
+ description?: string
340
+ content: TContent
341
+ reviewType?: 'code' | 'content' | 'design' | 'data' | 'other'
342
+ criteria?: string[]
343
+ assignee?: string | string[]
344
+ role?: string
345
+ team?: string
346
+ priority?: Priority
347
+ timeout?: number
348
+ metadata?: Record<string, unknown>
349
+ }): Promise<ReviewResponse> {
350
+ const request = await this.store.create<ReviewRequest<TContent>>({
351
+ type: 'review',
352
+ status: 'pending',
353
+ title: params.title,
354
+ description: params.description || `Review requested: ${params.reviewType || 'other'}`,
355
+ input: params.content,
356
+ content: params.content,
357
+ reviewType: params.reviewType,
358
+ criteria: params.criteria,
359
+ assignee: params.assignee,
360
+ role: params.role,
361
+ team: params.team,
362
+ priority: params.priority || this.options.defaultPriority,
363
+ timeout: params.timeout || this.options.defaultTimeout,
364
+ metadata: params.metadata,
365
+ })
366
+
367
+ return this.waitForResponse<ReviewRequest<TContent>, ReviewResponse>(request)
368
+ }
369
+
370
+ /**
371
+ * Send a notification to a human
372
+ *
373
+ * @example
374
+ * ```ts
375
+ * await human.notify({
376
+ * type: 'info',
377
+ * title: 'Deployment complete',
378
+ * message: 'Version 2.0.0 deployed successfully',
379
+ * recipient: 'team@example.com',
380
+ * channels: ['slack', 'email'],
381
+ * })
382
+ * ```
383
+ */
384
+ async notify(params: {
385
+ type: 'info' | 'warning' | 'error' | 'success'
386
+ title: string
387
+ message: string
388
+ recipient: string | string[]
389
+ channels?: ('slack' | 'email' | 'sms' | 'web')[]
390
+ priority?: Priority
391
+ data?: unknown
392
+ }): Promise<Notification> {
393
+ const notification: Notification = {
394
+ id: `notif_${Date.now()}_${Math.random().toString(36).slice(2)}`,
395
+ type: params.type,
396
+ title: params.title,
397
+ message: params.message,
398
+ recipient: params.recipient,
399
+ channels: params.channels,
400
+ priority: params.priority,
401
+ data: params.data,
402
+ createdAt: new Date(),
403
+ }
404
+
405
+ // In a real implementation, this would:
406
+ // 1. Send the notification via specified channels
407
+ // 2. Track delivery status
408
+ // 3. Handle failures and retries
409
+
410
+ return notification
411
+ }
412
+
413
+ /**
414
+ * Get a review queue
415
+ *
416
+ * @example
417
+ * ```ts
418
+ * const queue = await human.getQueue({
419
+ * name: 'Pending Approvals',
420
+ * filters: {
421
+ * status: ['pending'],
422
+ * priority: ['high', 'critical'],
423
+ * },
424
+ * })
425
+ * ```
426
+ */
427
+ async getQueue(params: {
428
+ name: string
429
+ description?: string
430
+ filters?: ReviewQueue['filters']
431
+ sortBy?: 'createdAt' | 'priority' | 'updatedAt'
432
+ sortDirection?: 'asc' | 'desc'
433
+ limit?: number
434
+ }): Promise<ReviewQueue> {
435
+ const items = await this.store.list(params.filters, params.limit)
436
+
437
+ return {
438
+ id: `queue_${Date.now()}`,
439
+ name: params.name,
440
+ description: params.description,
441
+ items,
442
+ filters: params.filters,
443
+ sortBy: params.sortBy,
444
+ sortDirection: params.sortDirection,
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Get a request by ID
450
+ */
451
+ async getRequest<T extends HumanRequest = HumanRequest>(id: string): Promise<T | null> {
452
+ return this.store.get<T>(id)
453
+ }
454
+
455
+ /**
456
+ * Complete a request with a response
457
+ */
458
+ async completeRequest<T extends HumanRequest = HumanRequest>(
459
+ id: string,
460
+ response: T['response']
461
+ ): Promise<T> {
462
+ return this.store.complete<T>(id, response)
463
+ }
464
+
465
+ /**
466
+ * Reject a request
467
+ */
468
+ async rejectRequest(id: string, reason: string): Promise<HumanRequest> {
469
+ return this.store.reject(id, reason)
470
+ }
471
+
472
+ /**
473
+ * Escalate a request
474
+ */
475
+ async escalateRequest(id: string, to: string): Promise<HumanRequest> {
476
+ return this.store.escalate(id, to)
477
+ }
478
+
479
+ /**
480
+ * Cancel a request
481
+ */
482
+ async cancelRequest(id: string): Promise<HumanRequest> {
483
+ return this.store.cancel(id)
484
+ }
485
+
486
+ /**
487
+ * Define or update goals
488
+ */
489
+ defineGoals(goals: Goals): Goals {
490
+ // In a real implementation, this would persist goals
491
+ return goals
492
+ }
493
+
494
+ /**
495
+ * Track KPIs
496
+ */
497
+ trackKPIs(kpis: KPIs): KPIs {
498
+ // In a real implementation, this would persist KPIs
499
+ return kpis
500
+ }
501
+
502
+ /**
503
+ * Define or update OKRs
504
+ */
505
+ defineOKRs(okrs: OKRs): OKRs {
506
+ // In a real implementation, this would persist OKRs
507
+ return okrs
508
+ }
509
+
510
+ /**
511
+ * Create an approval workflow
512
+ */
513
+ createWorkflow(workflow: ApprovalWorkflow): ApprovalWorkflow {
514
+ this.workflows.set(workflow.id, workflow)
515
+ return workflow
516
+ }
517
+
518
+ /**
519
+ * Get a workflow by ID
520
+ */
521
+ getWorkflow(id: string): ApprovalWorkflow | undefined {
522
+ return this.workflows.get(id)
523
+ }
524
+
525
+ /**
526
+ * Wait for a human response
527
+ *
528
+ * In a real implementation, this would:
529
+ * 1. Poll the store for updates
530
+ * 2. Listen for webhooks/events
531
+ * 3. Handle timeouts and escalations
532
+ * 4. Return the response when available
533
+ *
534
+ * For now, this throws an error to indicate manual completion is needed
535
+ */
536
+ private async waitForResponse<TRequest extends HumanRequest, TResponse>(
537
+ request: TRequest
538
+ ): Promise<TResponse> {
539
+ // Check if there's a timeout
540
+ if (request.timeout && request.timeout > 0) {
541
+ // Set up timeout handler
542
+ const timeoutPromise = new Promise<never>((_, reject) => {
543
+ setTimeout(() => {
544
+ reject(new Error(`Request ${request.id} timed out after ${request.timeout}ms`))
545
+ }, request.timeout)
546
+ })
547
+
548
+ // Poll for completion
549
+ const pollPromise = this.pollForCompletion<TRequest, TResponse>(request.id)
550
+
551
+ // Race between timeout and completion
552
+ try {
553
+ return await Promise.race([pollPromise, timeoutPromise])
554
+ } catch (error) {
555
+ // On timeout, escalate if configured
556
+ if (this.options.autoEscalate && request.escalatesTo) {
557
+ const escalateTo = Array.isArray(request.escalatesTo)
558
+ ? request.escalatesTo[0]
559
+ : request.escalatesTo
560
+ if (escalateTo) {
561
+ await this.store.escalate(request.id, escalateTo)
562
+ } else {
563
+ await this.store.update(request.id, { status: 'timeout' })
564
+ }
565
+ } else {
566
+ await this.store.update(request.id, { status: 'timeout' })
567
+ }
568
+ throw error
569
+ }
570
+ }
571
+
572
+ // No timeout, just poll indefinitely
573
+ return this.pollForCompletion<TRequest, TResponse>(request.id)
574
+ }
575
+
576
+ /**
577
+ * Poll for request completion
578
+ */
579
+ private async pollForCompletion<TRequest extends HumanRequest, TResponse>(
580
+ requestId: string
581
+ ): Promise<TResponse> {
582
+ // In a real implementation, use webhooks, WebSockets, or event emitters
583
+ // This is a simplified polling implementation
584
+ const pollInterval = 1000 // 1 second
585
+
586
+ while (true) {
587
+ const request = await this.store.get<TRequest>(requestId)
588
+
589
+ if (!request) {
590
+ throw new Error(`Request ${requestId} not found`)
591
+ }
592
+
593
+ if (request.status === 'completed' && request.response) {
594
+ return request.response as TResponse
595
+ }
596
+
597
+ if (request.status === 'rejected') {
598
+ throw new Error(`Request ${requestId} was rejected: ${request.rejectionReason}`)
599
+ }
600
+
601
+ if (request.status === 'cancelled') {
602
+ throw new Error(`Request ${requestId} was cancelled`)
603
+ }
604
+
605
+ // Wait before polling again
606
+ await new Promise((resolve) => setTimeout(resolve, pollInterval))
607
+ }
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Create a Human-in-the-loop manager instance
613
+ *
614
+ * @example
615
+ * ```ts
616
+ * import { Human } from 'human-in-the-loop'
617
+ *
618
+ * const human = Human({
619
+ * defaultTimeout: 3600000, // 1 hour
620
+ * autoEscalate: true,
621
+ * })
622
+ * ```
623
+ */
624
+ export function Human(options?: HumanOptions): HumanManager {
625
+ return new HumanManager(options)
626
+ }