@mobileai/react-native 0.1.0 → 0.3.0

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 (35) hide show
  1. package/README.md +78 -7
  2. package/lib/module/components/AIAgent.js +40 -4
  3. package/lib/module/components/AIAgent.js.map +1 -1
  4. package/lib/module/components/AgentChatBar.js +177 -29
  5. package/lib/module/components/AgentChatBar.js.map +1 -1
  6. package/lib/module/core/AgentRuntime.js +268 -126
  7. package/lib/module/core/AgentRuntime.js.map +1 -1
  8. package/lib/module/core/FiberTreeWalker.js +74 -20
  9. package/lib/module/core/FiberTreeWalker.js.map +1 -1
  10. package/lib/module/core/systemPrompt.js +164 -0
  11. package/lib/module/core/systemPrompt.js.map +1 -0
  12. package/lib/module/providers/GeminiProvider.js +189 -73
  13. package/lib/module/providers/GeminiProvider.js.map +1 -1
  14. package/lib/typescript/src/components/AIAgent.d.ts +9 -1
  15. package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
  16. package/lib/typescript/src/components/AgentChatBar.d.ts +4 -3
  17. package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -1
  18. package/lib/typescript/src/core/AgentRuntime.d.ts +16 -0
  19. package/lib/typescript/src/core/AgentRuntime.d.ts.map +1 -1
  20. package/lib/typescript/src/core/FiberTreeWalker.d.ts +5 -0
  21. package/lib/typescript/src/core/FiberTreeWalker.d.ts.map +1 -1
  22. package/lib/typescript/src/core/systemPrompt.d.ts +9 -0
  23. package/lib/typescript/src/core/systemPrompt.d.ts.map +1 -0
  24. package/lib/typescript/src/core/types.d.ts +51 -13
  25. package/lib/typescript/src/core/types.d.ts.map +1 -1
  26. package/lib/typescript/src/providers/GeminiProvider.d.ts +33 -13
  27. package/lib/typescript/src/providers/GeminiProvider.d.ts.map +1 -1
  28. package/package.json +16 -14
  29. package/src/components/AIAgent.tsx +41 -1
  30. package/src/components/AgentChatBar.tsx +150 -28
  31. package/src/core/AgentRuntime.ts +287 -131
  32. package/src/core/FiberTreeWalker.ts +74 -19
  33. package/src/core/systemPrompt.ts +162 -0
  34. package/src/core/types.ts +58 -10
  35. package/src/providers/GeminiProvider.ts +174 -101
@@ -13,6 +13,7 @@ import { logger } from '../utils/logger';
13
13
  import { walkFiberTree } from './FiberTreeWalker';
14
14
  import type { WalkConfig } from './FiberTreeWalker';
15
15
  import { dehydrateScreen } from './ScreenDehydrator';
16
+ import { buildSystemPrompt } from './systemPrompt';
16
17
  import type {
17
18
  AIProvider,
18
19
  AgentConfig,
@@ -24,51 +25,6 @@ import type {
24
25
 
25
26
  const DEFAULT_MAX_STEPS = 10;
26
27
 
27
- // ─── System Prompt ─────────────────────────────────────────────
28
-
29
- function buildSystemPrompt(language: string): string {
30
- const isArabic = language === 'ar';
31
-
32
- return `You are an AI agent that controls a React Native mobile app. You operate in an iterative loop to accomplish user requests.
33
-
34
- ${isArabic ? 'Respond to the user in Arabic.' : 'Respond to the user in English.'}
35
-
36
- <input>
37
- At every step you receive:
38
- 1. <screen_state>: Current screen name, available screens, and interactive elements indexed for actions.
39
- 2. <agent_history>: Your previous steps and their results.
40
- 3. <user_request>: The user's original request.
41
- </input>
42
-
43
- <screen_state>
44
- Interactive elements are listed as [index]<type attrs>label</>
45
- - index: numeric identifier for interaction
46
- - type: element type (pressable, text-input, switch)
47
- - label: visible text content of the element
48
-
49
- Only elements with [index] are interactive. Use the index to tap or type into them.
50
- </screen_state>
51
-
52
- <tools>
53
- Available tools:
54
- - tap(index): Tap an interactive element by its index. This triggers its onPress handler.
55
- - type(index, text): Type text into a text-input element by its index.
56
- - navigate(screen, params): Navigate to a specific screen. params is optional JSON object.
57
- - done(text, success): Complete the task. text is your response to the user.
58
- - ask_user(question): Ask the user for clarification if needed.
59
- </tools>
60
-
61
- <rules>
62
- - Only interact with elements that have an [index].
63
- - After tapping an element, the screen may change. Wait for the next step to see updated elements.
64
- - If the current screen doesn't have what you need, use navigate() to go to another screen.
65
- - If you're stuck or need more info, use ask_user().
66
- - When the task is complete, ALWAYS call done() with a summary.
67
- - Be efficient — complete tasks in as few steps as possible.
68
- - If a tap navigates to another screen, the next step will show the new screen's elements.
69
- </rules>`;
70
- }
71
-
72
28
  // ─── Agent Runtime ─────────────────────────────────────────────
73
29
 
74
30
  export class AgentRuntime {
@@ -112,10 +68,10 @@ export class AgentRuntime {
112
68
  // ─── Tool Registration ─────────────────────────────────────
113
69
 
114
70
  private registerBuiltInTools(): void {
115
- // tap — tap an interactive element by index
71
+ // tap — universal interaction (mirrors RNTL's dispatchEvent pattern)
116
72
  this.tools.set('tap', {
117
73
  name: 'tap',
118
- description: 'Tap an interactive element by its index to trigger its onPress handler.',
74
+ description: 'Tap an interactive element by its index. Works universally on buttons, switches, and custom components.',
119
75
  parameters: {
120
76
  index: { type: 'number', description: 'The index of the element to tap', required: true },
121
77
  },
@@ -125,17 +81,48 @@ export class AgentRuntime {
125
81
  if (!element) {
126
82
  return `❌ Element with index ${args.index} not found. Available indexes: ${elements.map(e => e.index).join(', ')}`;
127
83
  }
128
- if (!element.props.onPress) {
129
- return `❌ Element [${args.index}] "${element.label}" does not have an onPress handler.`;
84
+
85
+ // Strategy 1: Switch call onValueChange (like RNTL's fireEvent('valueChange'))
86
+ if (element.type === 'switch' && element.props.onValueChange) {
87
+ try {
88
+ element.props.onValueChange(!element.props.value);
89
+ await new Promise(resolve => setTimeout(resolve, 500));
90
+ return `✅ Toggled [${args.index}] "${element.label}" to ${!element.props.value}`;
91
+ } catch (error: any) {
92
+ return `❌ Error toggling [${args.index}]: ${error.message}`;
93
+ }
130
94
  }
131
- try {
132
- element.props.onPress();
133
- // Wait for UI to update after tap
134
- await new Promise(resolve => setTimeout(resolve, 500));
135
- return `✅ Tapped [${args.index}] "${element.label}"`;
136
- } catch (error: any) {
137
- return `❌ Error tapping [${args.index}]: ${error.message}`;
95
+
96
+ // Strategy 2: Direct onPress (covers Pressable, Button, custom components)
97
+ if (element.props.onPress) {
98
+ try {
99
+ element.props.onPress();
100
+ await new Promise(resolve => setTimeout(resolve, 500));
101
+ return `✅ Tapped [${args.index}] "${element.label}"`;
102
+ } catch (error: any) {
103
+ return `❌ Error tapping [${args.index}]: ${error.message}`;
104
+ }
138
105
  }
106
+
107
+ // Strategy 3: Bubble up Fiber tree (like RNTL's findEventHandler → element.parent)
108
+ let fiber = element.fiberNode?.return;
109
+ let bubbleDepth = 0;
110
+ while (fiber && bubbleDepth < 5) {
111
+ const parentProps = fiber.memoizedProps || {};
112
+ if (parentProps.onPress && typeof parentProps.onPress === 'function') {
113
+ try {
114
+ parentProps.onPress();
115
+ await new Promise(resolve => setTimeout(resolve, 500));
116
+ return `✅ Tapped parent of [${args.index}] "${element.label}"`;
117
+ } catch (error: any) {
118
+ return `❌ Error tapping parent of [${args.index}]: ${error.message}`;
119
+ }
120
+ }
121
+ fiber = fiber.return;
122
+ bubbleDepth++;
123
+ }
124
+
125
+ return `❌ Element [${args.index}] "${element.label}" has no tap handler (no onPress or onValueChange found).`;
139
126
  },
140
127
  });
141
128
 
@@ -165,25 +152,35 @@ export class AgentRuntime {
165
152
  },
166
153
  });
167
154
 
168
- // navigate — navigate to a screen
155
+ // navigate — navigate to a screen (supports React Navigation + Expo Router)
169
156
  this.tools.set('navigate', {
170
157
  name: 'navigate',
171
158
  description: 'Navigate to a specific screen in the app.',
172
159
  parameters: {
173
- screen: { type: 'string', description: 'Screen name to navigate to', required: true },
160
+ screen: { type: 'string', description: 'Screen name or path to navigate to', required: true },
174
161
  params: { type: 'string', description: 'Optional JSON params object', required: false },
175
162
  },
176
163
  execute: async (args) => {
164
+ // Expo Router path: use router.push()
165
+ if (this.config.router) {
166
+ try {
167
+ const path = args.screen.startsWith('/') ? args.screen : `/${args.screen}`;
168
+ this.config.router.push(path);
169
+ await new Promise(resolve => setTimeout(resolve, 500));
170
+ return `✅ Navigated to "${path}"`;
171
+ } catch (error: any) {
172
+ return `❌ Navigation error: ${error.message}`;
173
+ }
174
+ }
175
+
176
+ // React Navigation path: use navRef.navigate()
177
177
  if (!this.navRef) {
178
178
  return '❌ Navigation ref not available.';
179
179
  }
180
- // Per React Navigation docs: must check isReady() before navigate
181
- // https://reactnavigation.org/docs/navigating-without-navigation-prop#handling-initialization
182
180
  if (!this.navRef.isReady()) {
183
- // Wait a bit and retry — navigator may still be mounting
184
181
  await new Promise(resolve => setTimeout(resolve, 1000));
185
182
  if (!this.navRef.isReady()) {
186
- return '❌ Navigation is not ready yet. The navigator may not have finished mounting.';
183
+ return '❌ Navigation is not ready yet.';
187
184
  }
188
185
  }
189
186
  try {
@@ -210,14 +207,21 @@ export class AgentRuntime {
210
207
  },
211
208
  });
212
209
 
213
- // ask_user — ask for clarification
210
+ // ask_user — ask for clarification (mirrors page-agent: blocks until user responds)
214
211
  this.tools.set('ask_user', {
215
212
  name: 'ask_user',
216
- description: 'Ask the user for clarification or more information.',
213
+ description: 'Ask the user a question and wait for their answer. Use this if you need more information or clarification.',
217
214
  parameters: {
218
215
  question: { type: 'string', description: 'Question to ask the user', required: true },
219
216
  },
220
217
  execute: async (args) => {
218
+ if (this.config.onAskUser) {
219
+ // Page-agent pattern: block until user responds, then continue the loop
220
+ this.config.onStatusUpdate?.('Waiting for your answer...');
221
+ const answer = await this.config.onAskUser(args.question);
222
+ return `User answered: ${answer}`;
223
+ }
224
+ // Legacy fallback: break the loop (context will be lost)
221
225
  return `❓ ${args.question}`;
222
226
  },
223
227
  });
@@ -236,30 +240,85 @@ export class AgentRuntime {
236
240
 
237
241
  // ─── Navigation Helpers ────────────────────────────────────
238
242
 
243
+ /**
244
+ * Recursively collect ALL screen names from the navigation state tree.
245
+ * This handles tabs, drawers, and nested stacks.
246
+ */
239
247
  private getRouteNames(): string[] {
240
248
  try {
241
249
  if (!this.navRef?.isReady?.()) return [];
242
250
  const state = this.navRef?.getRootState?.() || this.navRef?.getState?.();
243
- if (state?.routeNames) return state.routeNames;
244
- if (state?.routes) return state.routes.map((r: any) => r.name);
245
- return [];
251
+ if (!state) return [];
252
+ return this.collectRouteNames(state);
246
253
  } catch {
247
254
  return [];
248
255
  }
249
256
  }
250
257
 
258
+ private collectRouteNames(state: any): string[] {
259
+ const names: string[] = [];
260
+ if (state?.routes) {
261
+ for (const route of state.routes) {
262
+ names.push(route.name);
263
+ // Recurse into nested navigator states
264
+ if (route.state) {
265
+ names.push(...this.collectRouteNames(route.state));
266
+ }
267
+ }
268
+ }
269
+ return [...new Set(names)];
270
+ }
271
+
272
+ /**
273
+ * Recursively find the deepest active screen name.
274
+ * For tabs: follows active tab → active screen inside that tab.
275
+ */
251
276
  private getCurrentScreenName(): string {
277
+ // Expo Router: use pathname
278
+ if (this.config.pathname) {
279
+ const segments = this.config.pathname.split('/').filter(Boolean);
280
+ return segments[segments.length - 1] || 'Unknown';
281
+ }
282
+
252
283
  try {
253
284
  if (!this.navRef?.isReady?.()) return 'Unknown';
254
285
  const state = this.navRef?.getRootState?.() || this.navRef?.getState?.();
255
286
  if (!state) return 'Unknown';
256
- const route = state.routes[state.index];
257
- return route?.name || 'Unknown';
287
+ return this.getDeepestScreenName(state);
258
288
  } catch {
259
289
  return 'Unknown';
260
290
  }
261
291
  }
262
292
 
293
+ private getDeepestScreenName(state: any): string {
294
+ if (!state?.routes || state.index == null) return 'Unknown';
295
+ const route = state.routes[state.index];
296
+ if (!route) return 'Unknown';
297
+ // If this route has a nested state, recurse deeper
298
+ if (route.state) {
299
+ return this.getDeepestScreenName(route.state);
300
+ }
301
+ return route.name || 'Unknown';
302
+ }
303
+
304
+ /** Maps a tool call to a user-friendly status label for the loading overlay. */
305
+ private getToolStatusLabel(toolName: string, args: Record<string, any>): string {
306
+ switch (toolName) {
307
+ case 'tap':
308
+ return `Tapping element ${args.index ?? ''}...`;
309
+ case 'type':
310
+ return `Typing into field...`;
311
+ case 'navigate':
312
+ return `Navigating to ${args.screen || 'screen'}...`;
313
+ case 'done':
314
+ return 'Wrapping up...';
315
+ case 'ask_user':
316
+ return 'Asking you a question...';
317
+ default:
318
+ return `Running ${toolName}...`;
319
+ }
320
+ }
321
+
263
322
  // ─── Build Tools Array for Provider ────────────────────────
264
323
 
265
324
  private buildToolsForProvider(): ToolDefinition[] {
@@ -324,7 +383,87 @@ export class AgentRuntime {
324
383
  return result ? `<instructions>\n${result}</instructions>\n\n` : '';
325
384
  }
326
385
 
327
- // ─── Main Execution Loop (mirrors PageAgentCore.execute) ───────
386
+ // ─── Observation System (mirrors PageAgentCore.#handleObservations) ──
387
+
388
+ private observations: string[] = [];
389
+ private lastScreenName: string = '';
390
+
391
+ private handleObservations(step: number, maxSteps: number, screenName: string): void {
392
+ // Screen change detection
393
+ if (this.lastScreenName && screenName !== this.lastScreenName) {
394
+ this.observations.push(`Screen navigated to → ${screenName}`);
395
+ }
396
+ this.lastScreenName = screenName;
397
+
398
+ // Remaining steps warning
399
+ const remaining = maxSteps - step;
400
+ if (remaining === 5) {
401
+ this.observations.push(
402
+ `⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
403
+ );
404
+ } else if (remaining === 2) {
405
+ this.observations.push(
406
+ `⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
407
+ );
408
+ }
409
+ }
410
+
411
+ // ─── User Prompt Assembly (mirrors PageAgentCore.#assembleUserPrompt) ──
412
+
413
+ private assembleUserPrompt(
414
+ step: number,
415
+ maxSteps: number,
416
+ contextualMessage: string,
417
+ screenName: string,
418
+ screenContent: string,
419
+ ): string {
420
+ let prompt = '';
421
+
422
+ // 1. <instructions> (optional system/screen instructions)
423
+ prompt += this.getInstructions(screenName);
424
+
425
+ // 2. <agent_state> — user request + step info (mirrors page-agent)
426
+ prompt += '<agent_state>\n';
427
+ prompt += '<user_request>\n';
428
+ prompt += `${contextualMessage}\n`;
429
+ prompt += '</user_request>\n';
430
+ prompt += '<step_info>\n';
431
+ prompt += `Step ${step + 1} of ${maxSteps} max possible steps\n`;
432
+ prompt += '</step_info>\n';
433
+ prompt += '</agent_state>\n\n';
434
+
435
+ // 3. <agent_history> — structured per-step history (mirrors page-agent)
436
+ prompt += '<agent_history>\n';
437
+
438
+ let stepIndex = 0;
439
+ for (const event of this.history) {
440
+ stepIndex++;
441
+ prompt += `<step_${stepIndex}>\n`;
442
+ prompt += `Previous Goal Eval: ${event.reflection.previousGoalEval}\n`;
443
+ prompt += `Memory: ${event.reflection.memory}\n`;
444
+ prompt += `Plan: ${event.reflection.plan}\n`;
445
+ prompt += `Action Result: ${event.action.output}\n`;
446
+ prompt += `</step_${stepIndex}>\n`;
447
+ }
448
+
449
+ // Inject system observations
450
+ for (const obs of this.observations) {
451
+ prompt += `<sys>${obs}</sys>\n`;
452
+ }
453
+ this.observations = [];
454
+
455
+ prompt += '</agent_history>\n\n';
456
+
457
+ // 4. <screen_state> — dehydrated screen content
458
+ prompt += '<screen_state>\n';
459
+ prompt += `Current Screen: ${screenName}\n`;
460
+ prompt += screenContent + '\n';
461
+ prompt += '</screen_state>\n';
462
+
463
+ return prompt;
464
+ }
465
+
466
+ // ─── Main Execution Loop ──────────────────────────────────────
328
467
 
329
468
  async execute(userMessage: string): Promise<ExecutionResult> {
330
469
  if (this.isRunning) {
@@ -333,6 +472,8 @@ export class AgentRuntime {
333
472
 
334
473
  this.isRunning = true;
335
474
  this.history = [];
475
+ this.observations = [];
476
+ this.lastScreenName = '';
336
477
  const maxSteps = this.config.maxSteps || DEFAULT_MAX_STEPS;
337
478
  const stepDelay = this.config.stepDelay ?? 300;
338
479
 
@@ -374,13 +515,16 @@ export class AgentRuntime {
374
515
  screenContent = await this.config.transformScreenContent(screenContent);
375
516
  }
376
517
 
377
- // 3. Build context message with instructions + screen state
378
- const instructionsBlock = this.getInstructions(screenName);
379
- const contextMessage = step === 0
380
- ? `${instructionsBlock}<user_request>${contextualMessage}</user_request>\n\n<screen_state>\n${screenContent}\n</screen_state>`
381
- : `${instructionsBlock}<screen_state>\n${screenContent}\n</screen_state>`;
518
+ // 3. Handle observations (mirrors page-agent #handleObservations)
519
+ this.handleObservations(step, maxSteps, screenName);
520
+
521
+ // 4. Assemble structured user prompt (mirrors page-agent #assembleUserPrompt)
522
+ const contextMessage = this.assembleUserPrompt(
523
+ step, maxSteps, contextualMessage, screenName, screenContent,
524
+ );
382
525
 
383
- // 4. Send to AI provider
526
+ // 5. Send to AI provider
527
+ this.config.onStatusUpdate?.('Analyzing screen...');
384
528
  const systemPrompt = buildSystemPrompt(this.config.language || 'en');
385
529
  const tools = this.buildToolsForProvider();
386
530
 
@@ -393,7 +537,7 @@ export class AgentRuntime {
393
537
  this.history,
394
538
  );
395
539
 
396
- // 5. Process tool calls
540
+ // 6. Process tool calls
397
541
  if (!response.toolCalls || response.toolCalls.length === 0) {
398
542
  logger.warn('AgentRuntime', 'No tool calls in response. Text:', response.text);
399
543
  const result: ExecutionResult = {
@@ -405,65 +549,77 @@ export class AgentRuntime {
405
549
  return result;
406
550
  }
407
551
 
408
- for (const toolCall of response.toolCalls) {
409
- logger.info('AgentRuntime', `Tool: ${toolCall.name}(${JSON.stringify(toolCall.args)})`);
552
+ // 7. Structured reasoning from provider (no regex parsing needed)
553
+ const { reasoning } = response;
554
+ logger.info('AgentRuntime', `🧠 Plan: ${reasoning.plan}`);
555
+ if (reasoning.memory) {
556
+ logger.debug('AgentRuntime', `💾 Memory: ${reasoning.memory}`);
557
+ }
558
+
559
+ // Only process the FIRST tool call per step (one action per step).
560
+ // After one action, the loop re-reads the screen with fresh indexes.
561
+ const toolCall = response.toolCalls[0]!;
562
+ if (response.toolCalls.length > 1) {
563
+ logger.warn('AgentRuntime', `AI returned ${response.toolCalls.length} tool calls, executing only the first one.`);
564
+ }
410
565
 
411
- // Find and execute the tool
412
- const tool = this.tools.get(toolCall.name) ||
413
- this.buildToolsForProvider().find(t => t.name === toolCall.name);
566
+ logger.info('AgentRuntime', `Tool: ${toolCall.name}(${JSON.stringify(toolCall.args)})`);
414
567
 
415
- let output: string;
416
- if (tool) {
417
- output = await tool.execute(toolCall.args);
418
- } else {
419
- output = `❌ Unknown tool: ${toolCall.name}`;
420
- }
568
+ // Dynamic status update based on tool being executed
569
+ const statusLabel = this.getToolStatusLabel(toolCall.name, toolCall.args);
570
+ this.config.onStatusUpdate?.(statusLabel);
421
571
 
422
- logger.info('AgentRuntime', `Result: ${output}`);
423
-
424
- // Record step
425
- const agentStep: AgentStep = {
426
- stepIndex: step,
427
- reflection: {
428
- evaluationPreviousGoal: step > 0 ? 'Evaluating...' : 'First step',
429
- memory: '',
430
- nextGoal: '',
431
- },
432
- action: {
433
- name: toolCall.name,
434
- input: toolCall.args,
435
- output,
436
- },
572
+ // Find and execute the tool
573
+ const tool = this.tools.get(toolCall.name) ||
574
+ this.buildToolsForProvider().find(t => t.name === toolCall.name);
575
+
576
+ let output: string;
577
+ if (tool) {
578
+ output = await tool.execute(toolCall.args);
579
+ } else {
580
+ output = `❌ Unknown tool: ${toolCall.name}`;
581
+ }
582
+
583
+ logger.info('AgentRuntime', `Result: ${output}`);
584
+
585
+ // Record step with structured reasoning
586
+ const agentStep: AgentStep = {
587
+ stepIndex: step,
588
+ reflection: reasoning,
589
+ action: {
590
+ name: toolCall.name,
591
+ input: toolCall.args,
592
+ output,
593
+ },
594
+ };
595
+ this.history.push(agentStep);
596
+
597
+ // Lifecycle: onAfterStep (mirrors page-agent)
598
+ await this.config.onAfterStep?.(this.history);
599
+
600
+ // Check if done
601
+ if (toolCall.name === 'done') {
602
+ const result: ExecutionResult = {
603
+ success: toolCall.args.success !== false,
604
+ message: toolCall.args.text || output,
605
+ steps: this.history,
437
606
  };
438
- this.history.push(agentStep);
439
-
440
- // Lifecycle: onAfterStep (mirrors page-agent)
441
- await this.config.onAfterStep?.(this.history);
442
-
443
- // Check if done
444
- if (toolCall.name === 'done') {
445
- const result: ExecutionResult = {
446
- success: toolCall.args.success !== false,
447
- message: output,
448
- steps: this.history,
449
- };
450
- logger.info('AgentRuntime', `Task completed: ${output}`);
451
- await this.config.onAfterTask?.(result);
452
- return result;
453
- }
607
+ logger.info('AgentRuntime', `Task completed: ${result.message}`);
608
+ await this.config.onAfterTask?.(result);
609
+ return result;
610
+ }
454
611
 
455
- // Check if asking user
456
- if (toolCall.name === 'ask_user') {
457
- this.lastAskUserQuestion = toolCall.args.question || output;
458
-
459
- const result: ExecutionResult = {
460
- success: true,
461
- message: output,
462
- steps: this.history,
463
- };
464
- await this.config.onAfterTask?.(result);
465
- return result;
466
- }
612
+ // Check if asking user (legacy path — only breaks loop when onAskUser is NOT set)
613
+ if (toolCall.name === 'ask_user' && !this.config.onAskUser) {
614
+ this.lastAskUserQuestion = toolCall.args.question || output;
615
+
616
+ const result: ExecutionResult = {
617
+ success: true,
618
+ message: output,
619
+ steps: this.history,
620
+ };
621
+ await this.config.onAfterTask?.(result);
622
+ return result;
467
623
  }
468
624
 
469
625
  // Step delay (mirrors page-agent stepDelay)