illuma-agents 1.0.20 → 1.0.22

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/run.ts CHANGED
@@ -4,6 +4,8 @@ import { CallbackHandler } from '@langfuse/langchain';
4
4
  import { PromptTemplate } from '@langchain/core/prompts';
5
5
  import { RunnableLambda } from '@langchain/core/runnables';
6
6
  import { AzureChatOpenAI, ChatOpenAI } from '@langchain/openai';
7
+ import { MemorySaver } from '@langchain/langgraph-checkpoint';
8
+ import { Command, INTERRUPT, isInterrupted } from '@langchain/langgraph';
7
9
  import type {
8
10
  MessageContentComplex,
9
11
  BaseMessage,
@@ -22,6 +24,7 @@ import { StandardGraph } from '@/graphs/Graph';
22
24
  import { HandlerRegistry } from '@/events';
23
25
  import { isOpenAILike } from '@/utils/llm';
24
26
  import { isPresent } from '@/utils/misc';
27
+ import type { BrowserInterrupt, BrowserActionResult } from '@/tools/BrowserInterruptTools';
25
28
 
26
29
  export const defaultOmitOptions = new Set([
27
30
  'stream',
@@ -36,11 +39,35 @@ export const defaultOmitOptions = new Set([
36
39
  'additionalModelRequestFields',
37
40
  ]);
38
41
 
42
+ /** Global checkpointer store for browser mode (keyed by runId) */
43
+ const browserCheckpointers = new Map<string, MemorySaver>();
44
+
45
+ /**
46
+ * Get or create a checkpointer for browser mode
47
+ */
48
+ export function getBrowserCheckpointer(runId: string): MemorySaver {
49
+ let checkpointer = browserCheckpointers.get(runId);
50
+ if (!checkpointer) {
51
+ checkpointer = new MemorySaver();
52
+ browserCheckpointers.set(runId, checkpointer);
53
+ }
54
+ return checkpointer;
55
+ }
56
+
57
+ /**
58
+ * Clean up a browser checkpointer when done
59
+ */
60
+ export function cleanupBrowserCheckpointer(runId: string): void {
61
+ browserCheckpointers.delete(runId);
62
+ }
63
+
39
64
  export class Run<_T extends t.BaseGraphState> {
40
65
  id: string;
41
66
  private tokenCounter?: t.TokenCounter;
42
67
  private handlerRegistry?: HandlerRegistry;
43
68
  private indexTokenCountMap?: Record<string, number>;
69
+ /** Whether this run is in browser extension mode */
70
+ browserMode: boolean = false;
44
71
  graphRunnable?: t.CompiledStateWorkflow;
45
72
  Graph: StandardGraph | MultiAgentGraph | undefined;
46
73
  returnContent: boolean = false;
@@ -54,6 +81,7 @@ export class Run<_T extends t.BaseGraphState> {
54
81
  this.id = runId;
55
82
  this.tokenCounter = config.tokenCounter;
56
83
  this.indexTokenCountMap = config.indexTokenCountMap;
84
+ this.browserMode = config.browserMode ?? false;
57
85
 
58
86
  const handlerRegistry = new HandlerRegistry();
59
87
 
@@ -73,16 +101,24 @@ export class Run<_T extends t.BaseGraphState> {
73
101
 
74
102
  /** Handle different graph types */
75
103
  if (config.graphConfig.type === 'multi-agent') {
104
+ // For multi-agent, browser mode checkpointer support not yet implemented
76
105
  this.graphRunnable = this.createMultiAgentGraph(config.graphConfig);
77
106
  if (this.Graph) {
78
107
  this.Graph.handlerRegistry = handlerRegistry;
79
108
  }
80
109
  } else {
81
110
  /** Default to legacy graph for 'standard' or undefined type */
82
- this.graphRunnable = this.createLegacyGraph(config.graphConfig);
111
+ // In browser mode, inject checkpointer into compileOptions BEFORE creating the graph
112
+ const graphConfig = { ...config.graphConfig };
113
+ if (this.browserMode) {
114
+ const checkpointer = getBrowserCheckpointer(runId);
115
+ graphConfig.compileOptions = {
116
+ ...graphConfig.compileOptions,
117
+ checkpointer,
118
+ };
119
+ }
120
+ this.graphRunnable = this.createLegacyGraph(graphConfig);
83
121
  if (this.Graph) {
84
- this.Graph.compileOptions =
85
- config.graphConfig.compileOptions ?? this.Graph.compileOptions;
86
122
  this.Graph.handlerRegistry = handlerRegistry;
87
123
  }
88
124
  }
@@ -453,4 +489,141 @@ export class Run<_T extends t.BaseGraphState> {
453
489
  );
454
490
  }
455
491
  }
492
+
493
+ /**
494
+ * Process stream with browser interrupt support.
495
+ * Uses regular stream() instead of streamEvents() to properly detect interrupts.
496
+ * Returns interrupt data when graph is paused waiting for browser action.
497
+ */
498
+ async *processBrowserStream(
499
+ inputs: t.IState,
500
+ config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string },
501
+ streamOptions?: t.EventStreamOptions
502
+ ): AsyncGenerator<
503
+ | { type: 'event'; data: unknown }
504
+ | { type: 'interrupt'; data: BrowserInterrupt }
505
+ | { type: 'done'; data?: MessageContentComplex[] }
506
+ > {
507
+ if (this.graphRunnable == null) {
508
+ throw new Error(
509
+ 'Run not initialized. Make sure to use Run.create() to instantiate the Run.'
510
+ );
511
+ }
512
+ if (!this.Graph) {
513
+ throw new Error(
514
+ 'Graph not initialized. Make sure to use Run.create() to instantiate the Run.'
515
+ );
516
+ }
517
+
518
+ this.Graph.resetValues(streamOptions?.keepContent);
519
+
520
+ if (!this.id) {
521
+ throw new Error('Run ID not provided');
522
+ }
523
+
524
+ config.run_id = this.id;
525
+ // Set up thread_id for checkpointing (required for interrupt/resume)
526
+ config.configurable = Object.assign(config.configurable ?? {}, {
527
+ run_id: this.id,
528
+ thread_id: this.id, // Use run ID as thread ID for browser sessions
529
+ });
530
+
531
+ // Use the values stream mode to detect interrupts
532
+ const stream = await this.graphRunnable.stream(inputs, {
533
+ ...config,
534
+ streamMode: ['values', 'updates'],
535
+ });
536
+
537
+ for await (const chunk of stream) {
538
+ // Check if this chunk contains an interrupt
539
+ if (isInterrupted(chunk)) {
540
+ const interrupts = chunk[INTERRUPT] as Array<{ value: BrowserInterrupt }>;
541
+ if (interrupts.length > 0) {
542
+ // Emit the interrupt data to the client
543
+ for (const interrupt of interrupts) {
544
+ if (interrupt.value?.type === 'browser_interrupt') {
545
+ yield { type: 'interrupt', data: interrupt.value };
546
+ }
547
+ }
548
+ // Stop yielding - graph is paused
549
+ return;
550
+ }
551
+ }
552
+
553
+ // Emit regular events
554
+ yield { type: 'event', data: chunk };
555
+ }
556
+
557
+ // Stream completed without interrupt
558
+ if (this.returnContent) {
559
+ yield { type: 'done', data: this.Graph.getContentParts() };
560
+ } else {
561
+ yield { type: 'done' };
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Resume a browser stream after interrupt.
567
+ * Call this with the result from the browser extension.
568
+ */
569
+ async *resumeBrowserStream(
570
+ result: BrowserActionResult,
571
+ config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string }
572
+ ): AsyncGenerator<
573
+ | { type: 'event'; data: unknown }
574
+ | { type: 'interrupt'; data: BrowserInterrupt }
575
+ | { type: 'done'; data?: MessageContentComplex[] }
576
+ > {
577
+ if (this.graphRunnable == null) {
578
+ throw new Error(
579
+ 'Run not initialized. Make sure to use Run.create() to instantiate the Run.'
580
+ );
581
+ }
582
+ if (!this.Graph) {
583
+ throw new Error(
584
+ 'Graph not initialized. Make sure to use Run.create() to instantiate the Run.'
585
+ );
586
+ }
587
+
588
+ if (!this.id) {
589
+ throw new Error('Run ID not provided');
590
+ }
591
+
592
+ config.run_id = this.id;
593
+ config.configurable = Object.assign(config.configurable ?? {}, {
594
+ run_id: this.id,
595
+ thread_id: this.id,
596
+ });
597
+
598
+ // Use Command to resume with the browser result
599
+ const resumeCommand = new Command({ resume: result });
600
+
601
+ const stream = await this.graphRunnable.stream(resumeCommand, {
602
+ ...config,
603
+ streamMode: ['values', 'updates'],
604
+ });
605
+
606
+ for await (const chunk of stream) {
607
+ // Check if this chunk contains another interrupt
608
+ if (isInterrupted(chunk)) {
609
+ const interrupts = chunk[INTERRUPT] as Array<{ value: BrowserInterrupt }>;
610
+ if (interrupts.length > 0) {
611
+ for (const interrupt of interrupts) {
612
+ if (interrupt.value?.type === 'browser_interrupt') {
613
+ yield { type: 'interrupt', data: interrupt.value };
614
+ }
615
+ }
616
+ return;
617
+ }
618
+ }
619
+
620
+ yield { type: 'event', data: chunk };
621
+ }
622
+
623
+ if (this.returnContent) {
624
+ yield { type: 'done', data: this.Graph.getContentParts() };
625
+ } else {
626
+ yield { type: 'done' };
627
+ }
628
+ }
456
629
  }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Tests for Browser Interrupt Tools
3
+ *
4
+ * These tests verify:
5
+ * 1. Tool creation and schema validation
6
+ * 2. Interrupt type exports and guards
7
+ * 3. Tool naming consistency
8
+ */
9
+
10
+ import { z } from 'zod';
11
+ import {
12
+ createBrowserInterruptTools,
13
+ createBrowserNavigateInterruptTool,
14
+ createBrowserClickInterruptTool,
15
+ createBrowserTypeInterruptTool,
16
+ createBrowserGetPageStateInterruptTool,
17
+ createBrowserScrollInterruptTool,
18
+ createBrowserExtractInterruptTool,
19
+ createBrowserHoverInterruptTool,
20
+ createBrowserWaitInterruptTool,
21
+ createBrowserGoBackInterruptTool,
22
+ createBrowserScreenshotInterruptTool,
23
+ isBrowserInterrupt,
24
+ isBrowserInterruptToolCall,
25
+ BROWSER_INTERRUPT_TOOL_NAMES,
26
+ EBrowserInterruptTools,
27
+ type BrowserInterrupt,
28
+ type BrowserActionResult,
29
+ } from '../tools/BrowserInterruptTools';
30
+
31
+ describe('BrowserInterruptTools', () => {
32
+ describe('createBrowserInterruptTools', () => {
33
+ it('should create all 10 browser tools', () => {
34
+ const tools = createBrowserInterruptTools();
35
+ expect(tools).toHaveLength(10);
36
+ });
37
+
38
+ it('should have unique tool names', () => {
39
+ const tools = createBrowserInterruptTools();
40
+ const names = tools.map(t => t.name);
41
+ const uniqueNames = new Set(names);
42
+ expect(uniqueNames.size).toBe(names.length);
43
+ });
44
+
45
+ it('should match BROWSER_INTERRUPT_TOOL_NAMES', () => {
46
+ const tools = createBrowserInterruptTools();
47
+ const names = tools.map(t => t.name);
48
+ expect(names.sort()).toEqual([...BROWSER_INTERRUPT_TOOL_NAMES].sort());
49
+ });
50
+ });
51
+
52
+ describe('individual tool creation', () => {
53
+ it('should create navigate tool with correct schema', () => {
54
+ const tool = createBrowserNavigateInterruptTool();
55
+ expect(tool.name).toBe('browser_navigate');
56
+ expect(tool.schema).toBeDefined();
57
+
58
+ // Verify schema accepts valid input
59
+ const schema = tool.schema as z.ZodObject<any>;
60
+ const result = schema.safeParse({ url: 'https://example.com' });
61
+ expect(result.success).toBe(true);
62
+ });
63
+
64
+ it('should create click tool with correct schema', () => {
65
+ const tool = createBrowserClickInterruptTool();
66
+ expect(tool.name).toBe('browser_click');
67
+
68
+ const schema = tool.schema as z.ZodObject<any>;
69
+ // Click by index
70
+ expect(schema.safeParse({ index: 5 }).success).toBe(true);
71
+ // Click by coordinates
72
+ expect(schema.safeParse({ coordinates: { x: 100, y: 200 } }).success).toBe(true);
73
+ });
74
+
75
+ it('should create type tool with correct schema', () => {
76
+ const tool = createBrowserTypeInterruptTool();
77
+ expect(tool.name).toBe('browser_type');
78
+
79
+ const schema = tool.schema as z.ZodObject<any>;
80
+ expect(schema.safeParse({ index: 3, text: 'hello world' }).success).toBe(true);
81
+ expect(schema.safeParse({ index: 3, text: 'search', pressEnter: true }).success).toBe(true);
82
+ });
83
+
84
+ it('should create get_page_state tool with correct schema', () => {
85
+ const tool = createBrowserGetPageStateInterruptTool();
86
+ expect(tool.name).toBe('browser_get_page_state');
87
+
88
+ const schema = tool.schema as z.ZodObject<any>;
89
+ expect(schema.safeParse({}).success).toBe(true);
90
+ expect(schema.safeParse({ reason: 'checking elements' }).success).toBe(true);
91
+ });
92
+
93
+ it('should create scroll tool with correct schema', () => {
94
+ const tool = createBrowserScrollInterruptTool();
95
+ expect(tool.name).toBe('browser_scroll');
96
+
97
+ const schema = tool.schema as z.ZodObject<any>;
98
+ expect(schema.safeParse({ direction: 'down' }).success).toBe(true);
99
+ expect(schema.safeParse({ direction: 'up', amount: 500 }).success).toBe(true);
100
+ expect(schema.safeParse({ direction: 'invalid' }).success).toBe(false);
101
+ });
102
+
103
+ it('should create extract tool with correct schema', () => {
104
+ const tool = createBrowserExtractInterruptTool();
105
+ expect(tool.name).toBe('browser_extract');
106
+
107
+ const schema = tool.schema as z.ZodObject<any>;
108
+ expect(schema.safeParse({}).success).toBe(true);
109
+ expect(schema.safeParse({ query: 'product prices' }).success).toBe(true);
110
+ });
111
+
112
+ it('should create hover tool with correct schema', () => {
113
+ const tool = createBrowserHoverInterruptTool();
114
+ expect(tool.name).toBe('browser_hover');
115
+
116
+ const schema = tool.schema as z.ZodObject<any>;
117
+ expect(schema.safeParse({ index: 5 }).success).toBe(true);
118
+ expect(schema.safeParse({}).success).toBe(false); // index is required
119
+ });
120
+
121
+ it('should create wait tool with correct schema', () => {
122
+ const tool = createBrowserWaitInterruptTool();
123
+ expect(tool.name).toBe('browser_wait');
124
+
125
+ const schema = tool.schema as z.ZodObject<any>;
126
+ expect(schema.safeParse({}).success).toBe(true);
127
+ expect(schema.safeParse({ duration: 2000 }).success).toBe(true);
128
+ });
129
+
130
+ it('should create back tool with correct schema', () => {
131
+ const tool = createBrowserGoBackInterruptTool();
132
+ expect(tool.name).toBe('browser_back');
133
+
134
+ const schema = tool.schema as z.ZodObject<any>;
135
+ expect(schema.safeParse({}).success).toBe(true);
136
+ });
137
+
138
+ it('should create screenshot tool with correct schema', () => {
139
+ const tool = createBrowserScreenshotInterruptTool();
140
+ expect(tool.name).toBe('browser_screenshot');
141
+
142
+ const schema = tool.schema as z.ZodObject<any>;
143
+ expect(schema.safeParse({}).success).toBe(true);
144
+ expect(schema.safeParse({ fullPage: true }).success).toBe(true);
145
+ });
146
+ });
147
+
148
+ describe('isBrowserInterrupt', () => {
149
+ it('should return true for valid browser interrupt', () => {
150
+ const interrupt: BrowserInterrupt = {
151
+ type: 'browser_interrupt',
152
+ action: { type: 'navigate', url: 'https://example.com' },
153
+ interruptId: 'test_123',
154
+ };
155
+ expect(isBrowserInterrupt(interrupt)).toBe(true);
156
+ });
157
+
158
+ it('should return false for null/undefined', () => {
159
+ expect(isBrowserInterrupt(null)).toBe(false);
160
+ expect(isBrowserInterrupt(undefined)).toBe(false);
161
+ });
162
+
163
+ it('should return false for non-object', () => {
164
+ expect(isBrowserInterrupt('string')).toBe(false);
165
+ expect(isBrowserInterrupt(123)).toBe(false);
166
+ });
167
+
168
+ it('should return false for wrong type field', () => {
169
+ expect(isBrowserInterrupt({ type: 'other_type' })).toBe(false);
170
+ });
171
+ });
172
+
173
+ describe('isBrowserInterruptToolCall', () => {
174
+ it('should return true for valid tool names', () => {
175
+ expect(isBrowserInterruptToolCall('browser_navigate')).toBe(true);
176
+ expect(isBrowserInterruptToolCall('browser_click')).toBe(true);
177
+ expect(isBrowserInterruptToolCall('browser_type')).toBe(true);
178
+ expect(isBrowserInterruptToolCall('browser_get_page_state')).toBe(true);
179
+ });
180
+
181
+ it('should return false for invalid tool names', () => {
182
+ expect(isBrowserInterruptToolCall('invalid_tool')).toBe(false);
183
+ expect(isBrowserInterruptToolCall('browser_invalid')).toBe(false);
184
+ expect(isBrowserInterruptToolCall('')).toBe(false);
185
+ });
186
+ });
187
+
188
+ describe('EBrowserInterruptTools enum', () => {
189
+ it('should have all expected tool names', () => {
190
+ expect(EBrowserInterruptTools.CLICK).toBe('browser_click');
191
+ expect(EBrowserInterruptTools.TYPE).toBe('browser_type');
192
+ expect(EBrowserInterruptTools.NAVIGATE).toBe('browser_navigate');
193
+ expect(EBrowserInterruptTools.SCROLL).toBe('browser_scroll');
194
+ expect(EBrowserInterruptTools.EXTRACT).toBe('browser_extract');
195
+ expect(EBrowserInterruptTools.HOVER).toBe('browser_hover');
196
+ expect(EBrowserInterruptTools.WAIT).toBe('browser_wait');
197
+ expect(EBrowserInterruptTools.BACK).toBe('browser_back');
198
+ expect(EBrowserInterruptTools.SCREENSHOT).toBe('browser_screenshot');
199
+ expect(EBrowserInterruptTools.GET_PAGE_STATE).toBe('browser_get_page_state');
200
+ });
201
+ });
202
+
203
+ describe('BrowserActionResult type', () => {
204
+ it('should accept success result', () => {
205
+ const result: BrowserActionResult = {
206
+ success: true,
207
+ };
208
+ expect(result.success).toBe(true);
209
+ });
210
+
211
+ it('should accept result with page state', () => {
212
+ const result: BrowserActionResult = {
213
+ success: true,
214
+ pageState: {
215
+ url: 'https://example.com',
216
+ title: 'Example',
217
+ elementList: '[0] <button>Click me</button>',
218
+ elementCount: 1,
219
+ scrollPosition: 0,
220
+ scrollHeight: 1000,
221
+ viewportHeight: 800,
222
+ },
223
+ };
224
+ expect(result.pageState?.elementCount).toBe(1);
225
+ });
226
+
227
+ it('should accept error result', () => {
228
+ const result: BrowserActionResult = {
229
+ success: false,
230
+ error: 'Element not found',
231
+ };
232
+ expect(result.error).toBe('Element not found');
233
+ });
234
+ });
235
+ });