explorbot 0.1.28 → 0.1.29

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.
@@ -1,6 +1,7 @@
1
+ import { OpenTelemetry } from '@ai-sdk/otel';
1
2
  import { LangfuseSpanProcessor } from '@langfuse/otel';
2
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
3
- import { generateObject, generateText, stepCountIs } from 'ai';
4
+ import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
4
5
  import type { ModelMessage } from 'ai';
5
6
  import { clearActivity, setActivity } from '../activity.ts';
6
7
  import type { AIConfig } from '../config.js';
@@ -19,9 +20,11 @@ const responseLog = createDebug('explorbot:provider:in');
19
20
  class AiError extends Error {}
20
21
  export class ContextLengthError extends Error {}
21
22
 
23
+ let telemetryRegistered = false;
24
+
22
25
  function extractCachedTokens(usage: any): number {
23
26
  if (!usage) return 0;
24
- const direct = usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
27
+ const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
25
28
  if (typeof direct === 'number') return direct;
26
29
  const raw = usage.raw;
27
30
  const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
@@ -86,7 +89,7 @@ export class Provider {
86
89
  await generateText({
87
90
  model: this.config.model,
88
91
  prompt: 'hi',
89
- maxTokens: 1,
92
+ maxOutputTokens: 1,
90
93
  });
91
94
  } catch (error: any) {
92
95
  throw new AiError(`AI connection failed: ${error.message}`);
@@ -110,6 +113,16 @@ export class Provider {
110
113
  return this.config.agenticModel || this.config.model;
111
114
  }
112
115
 
116
+ getConfiguredModels(): Record<string, string> {
117
+ const models: Record<string, string> = { model: this.getModelName(this.config.model) };
118
+ if (this.config.agenticModel) models.agenticModel = this.getModelName(this.config.agenticModel);
119
+ if (this.config.visionModel) models.visionModel = this.getModelName(this.config.visionModel);
120
+ for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
121
+ if (agentConfig?.model) models[agent] = this.getModelName(agentConfig.model);
122
+ }
123
+ return models;
124
+ }
125
+
113
126
  getSystemPromptForAgent(agentName: string, currentUrl?: string): string | undefined {
114
127
  const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
115
128
  const parts: string[] = [];
@@ -129,6 +142,12 @@ export class Provider {
129
142
  return agentConfig?.providerOptions;
130
143
  }
131
144
 
145
+ getReasoningForAgent(agentName?: string): string | undefined {
146
+ if (!agentName) return undefined;
147
+ const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
148
+ return agentConfig?.reasoning;
149
+ }
150
+
132
151
  private getRetryOptions(options: any = {}): RetryOptions {
133
152
  return {
134
153
  ...this.defaultRetryOptions,
@@ -146,6 +165,12 @@ export class Provider {
146
165
  };
147
166
  }
148
167
 
168
+ private finalizeConfig(config: Record<string, any>, options: any, telemetry: any): void {
169
+ if (telemetry) config.telemetry = telemetry;
170
+ const reasoning = this.getReasoningForAgent(options.agentName);
171
+ if (reasoning) config.reasoning ??= reasoning;
172
+ }
173
+
149
174
  private initLangfuse() {
150
175
  const langfuseConfig = this.config.langfuse;
151
176
  const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
@@ -167,6 +192,10 @@ export class Provider {
167
192
  instrumentations: [],
168
193
  });
169
194
  void this.otelSdk.start();
195
+ if (!telemetryRegistered) {
196
+ registerTelemetry(new OpenTelemetry());
197
+ telemetryRegistered = true;
198
+ }
170
199
  this.telemetryEnabled = true;
171
200
  }
172
201
 
@@ -177,20 +206,20 @@ export class Provider {
177
206
 
178
207
  const runTelemetry = Observability.getTelemetry();
179
208
 
180
- if (!options.experimental_telemetry) {
181
- return runTelemetry || { isEnabled: true };
209
+ if (!options.telemetry) {
210
+ return runTelemetry;
182
211
  }
183
212
 
184
213
  if (!runTelemetry) {
185
- return options.experimental_telemetry;
214
+ return options.telemetry;
186
215
  }
187
216
 
188
217
  return {
189
218
  ...runTelemetry,
190
- ...options.experimental_telemetry,
219
+ ...options.telemetry,
191
220
  metadata: {
192
221
  ...runTelemetry.metadata,
193
- ...options.experimental_telemetry.metadata,
222
+ ...options.telemetry.metadata,
194
223
  },
195
224
  };
196
225
  }
@@ -242,15 +271,16 @@ export class Provider {
242
271
  const telemetry = this.getTelemetry(options);
243
272
  const config = this.mergeProviderOptions(
244
273
  {
245
- maxTokens: 16384,
274
+ maxOutputTokens: 16384,
275
+ allowSystemInMessages: true,
246
276
  ...(this.config.config || {}),
247
277
  ...options,
248
- ...(telemetry ? { experimental_telemetry: telemetry } : {}),
249
278
  model,
250
279
  abortSignal: executionController.getAbortSignal(),
251
280
  },
252
281
  options.agentName
253
282
  );
283
+ this.finalizeConfig(config, options, telemetry);
254
284
 
255
285
  promptLog(messages[messages.length - 1].content);
256
286
  try {
@@ -259,7 +289,7 @@ export class Provider {
259
289
  if (!result.text) {
260
290
  debugLog(result);
261
291
  if (result.finishReason === 'length') {
262
- throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxTokens in config or use a model with higher output capacity.');
292
+ throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
263
293
  }
264
294
  throw new Error('No response text from AI');
265
295
  }
@@ -315,23 +345,24 @@ export class Provider {
315
345
  const telemetry = this.getTelemetry(options);
316
346
  const maxRoundtrips = options.maxToolRoundtrips ?? 5;
317
347
  const extraStop = options.stopWhen;
318
- const stopConditions: any[] = [stepCountIs(maxRoundtrips)];
348
+ const stopConditions: any[] = [isStepCount(maxRoundtrips)];
319
349
  if (extraStop) stopConditions.push(extraStop);
320
350
  const { stopWhen: _ignoredStopWhen, ...optionsWithoutStop } = options;
321
351
  const config = this.mergeProviderOptions(
322
352
  {
323
353
  tools,
324
- maxTokens: 16384,
354
+ maxOutputTokens: 16384,
325
355
  toolChoice: 'auto',
356
+ allowSystemInMessages: true,
326
357
  ...(this.config.config || {}),
327
358
  ...optionsWithoutStop,
328
359
  stopWhen: stopConditions,
329
- ...(telemetry ? { experimental_telemetry: telemetry } : {}),
330
360
  model,
331
361
  abortSignal: executionController.getAbortSignal(),
332
362
  },
333
363
  options.agentName
334
364
  );
365
+ this.finalizeConfig(config, options, telemetry);
335
366
  try {
336
367
  const response = await withRetry(async () => {
337
368
  const timeout = config.timeout || 30000;
@@ -346,7 +377,7 @@ export class Provider {
346
377
  ])) as any;
347
378
  const hasToolCall = (result.toolCalls?.length || 0) > 0;
348
379
  if (!result.text && !hasToolCall && result.finishReason === 'length') {
349
- throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxTokens in config or use a model with higher output capacity.');
380
+ throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
350
381
  }
351
382
  return result;
352
383
  } finally {
@@ -409,14 +440,15 @@ export class Provider {
409
440
  const config = this.mergeProviderOptions(
410
441
  {
411
442
  schema,
443
+ allowSystemInMessages: true,
412
444
  ...(this.config.config || {}),
413
445
  ...options,
414
- ...(telemetry ? { experimental_telemetry: telemetry } : {}),
415
446
  model: modelToUse,
416
447
  abortSignal: executionController.getAbortSignal(),
417
448
  },
418
449
  options.agentName
419
450
  );
451
+ this.finalizeConfig(config, options, telemetry);
420
452
 
421
453
  try {
422
454
  promptLog(messages[messages.length - 1].content);
@@ -617,13 +649,13 @@ export class Provider {
617
649
  ];
618
650
 
619
651
  const telemetry = this.getTelemetry({});
620
- const config = {
621
- maxTokens: 16384,
652
+ const config: Record<string, any> = {
653
+ maxOutputTokens: 16384,
622
654
  ...(this.config.config || {}),
623
- ...(telemetry ? { experimental_telemetry: telemetry } : {}),
624
655
  model: this.config.visionModel,
625
656
  abortSignal: executionController.getAbortSignal(),
626
657
  };
658
+ if (telemetry) config.telemetry = telemetry;
627
659
 
628
660
  try {
629
661
  promptLog(`Processing image with prompt: ${prompt}`);
@@ -68,6 +68,13 @@ export class Researcher extends ResearcherBase implements Agent {
68
68
  this.stateManager = explorer.getStateManager();
69
69
  this.experienceTracker = this.stateManager.getExperienceTracker();
70
70
  this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
71
+
72
+ const ai = explorer.getConfig().ai;
73
+ if (ai) {
74
+ ai.agents ??= {};
75
+ ai.agents.researcher ??= {};
76
+ ai.agents.researcher.reasoning ??= 'low';
77
+ }
71
78
  }
72
79
 
73
80
  protected getNavigator(): Navigator {
@@ -177,7 +184,7 @@ export class Researcher extends ResearcherBase implements Agent {
177
184
  } catch (error) {
178
185
  if (!(error instanceof ContextLengthError) || retriesLeft <= 0) {
179
186
  if (error instanceof ContextLengthError) {
180
- tag('warning').log('Output truncated. Try lowering reasoning effort or increasing maxTokens in ai.config.');
187
+ tag('warning').log('Output truncated. Try lowering reasoning effort or increasing maxOutputTokens in ai.config.');
181
188
  }
182
189
  throw error;
183
190
  }
package/src/ai/rules.ts CHANGED
@@ -153,6 +153,52 @@ export const protectionRule = dedent`
153
153
  </important>
154
154
  `;
155
155
 
156
+ export const dataProtectionRules = dedent`
157
+ <data_protection_rules>
158
+ ${protectionRule}
159
+
160
+ If the user request, scenario, focus, or test instructions explicitly prohibit creating,
161
+ editing, updating, deleting, removing, or otherwise mutating data, do not perform those
162
+ actions through the UI, API preconditions, cleanup, fallback steps, or Fisherman.
163
+
164
+ Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
165
+ filter, tab, or list-inspection constraint. Use visible existing data when it is available.
166
+ If no suitable data exists, report the missing precondition instead of creating data.
167
+
168
+ Destructive actions are allowed only against disposable data created by the current scenario
169
+ or prepared for that scenario by Fisherman/API preconditions. Existing application data must
170
+ remain unchanged.
171
+ </data_protection_rules>
172
+ `;
173
+
174
+ export const capabilityGroundingRule = dedent`
175
+ <capability_grounding>
176
+ When a scenario depends on a named action, menu item, status, option, workflow, or feature,
177
+ that capability must be visible or explicitly confirmed in the current research/page context
178
+ for the same target entity type.
179
+
180
+ Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
181
+ Do not replace a requested action with a synonym or related action unless the UI explicitly
182
+ shows that action for the target entity.
183
+
184
+ When an action is described as applying to an item, row, card, record, node, or entity,
185
+ the target must be grounded as that kind of data entity in the current context. Do not use
186
+ navigation links, filter tabs, counters, breadcrumbs, headings, toolbar controls, or other
187
+ page controls as the subject of row/entity actions.
188
+
189
+ When a scenario asks to open, view, inspect, or navigate to an entity detail view, success
190
+ requires evidence of that entity detail context. An active filter, selected tab, visible count,
191
+ or filtered list is not enough to prove an entity detail view opened.
192
+
193
+ Do not rewrite a scenario goal to match a similar outcome that happened accidentally. If the
194
+ requested entity detail/action/workflow was not achieved, report that mismatch instead of
195
+ passing the test for a related filter, tab, navigation, or status view.
196
+
197
+ If the required capability is not available for the target entity after reasonable discovery,
198
+ record the missing capability and stop instead of repeatedly trying unrelated locators.
199
+ </capability_grounding>
200
+ `;
201
+
156
202
  export const focusedElementRule = dedent`
157
203
  <focused_element_actions>
158
204
  When a text input element is focused (textbox, combobox, contenteditable):
package/src/ai/tester.ts CHANGED
@@ -25,7 +25,7 @@ import { Navigator } from './navigator.ts';
25
25
  import type { Pilot } from './pilot.ts';
26
26
  import { Provider } from './provider.ts';
27
27
  import { Researcher } from './researcher.ts';
28
- import { actionRule, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, protectionRule, sectionContextRule } from './rules.ts';
28
+ import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from './rules.ts';
29
29
  import { TaskAgent } from './task-agent.ts';
30
30
  import { createCodeceptJSTools, createSpecialContextTools } from './tools.ts';
31
31
 
@@ -43,7 +43,7 @@ const SAMPLE_FILES: Record<string, string> = {
43
43
  };
44
44
 
45
45
  export class Tester extends TaskAgent implements Agent {
46
- protected readonly ACTION_TOOLS = ['click', 'pressKey', 'form'];
46
+ protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
47
47
  protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
48
48
  emoji = '🧪';
49
49
  private explorer: Explorer;
@@ -66,6 +66,8 @@ export class Tester extends TaskAgent implements Agent {
66
66
  private hooksRunner: HooksRunner;
67
67
  private seenUiMapUrls = new Set<string>();
68
68
  private lastAnalyzedStateHash: string | null = null;
69
+ private stalledIterations = 0;
70
+ private readonly MAX_STALLED_ITERATIONS = 3;
69
71
 
70
72
  constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) {
71
73
  super();
@@ -126,6 +128,7 @@ export class Tester extends TaskAgent implements Agent {
126
128
  this.pageActionResult = null;
127
129
  this.seenUiMapUrls.clear();
128
130
  this.lastAnalyzedStateHash = null;
131
+ this.stalledIterations = 0;
129
132
  this.explorer.getStateManager().clearHistory();
130
133
  this.resetFailureCount();
131
134
  this.pilot?.reset();
@@ -348,6 +351,11 @@ export class Tester extends TaskAgent implements Agent {
348
351
  });
349
352
  }
350
353
 
354
+ if (this.shouldStopForStalledExecution(task, currentState, result?.toolExecutions || [])) {
355
+ stop();
356
+ return;
357
+ }
358
+
351
359
  if (assertionPerformed) {
352
360
  const message = result?.toolExecutions?.find((execution: any) => execution.toolName === 'verify')?.output?.message || '';
353
361
  task.addNote(message, wasSuccessful ? TestResult.PASSED : TestResult.FAILED);
@@ -456,6 +464,31 @@ export class Tester extends TaskAgent implements Agent {
456
464
  return true;
457
465
  }
458
466
 
467
+ private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
468
+ if (task.hasFinished) return false;
469
+
470
+ const currentState = this.getCurrentState();
471
+ const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
472
+ const actionTools = [...this.ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
473
+ const hasSuccessfulAction = toolExecutions.some((execution) => execution.wasSuccessful && actionTools.includes(execution.toolName));
474
+ const hasSuccessfulAssertion = toolExecutions.some((execution) => execution.wasSuccessful && this.ASSERTION_TOOLS.includes(execution.toolName));
475
+
476
+ if (stateChanged || hasSuccessfulAction || hasSuccessfulAssertion) {
477
+ this.stalledIterations = 0;
478
+ return false;
479
+ }
480
+
481
+ const hasNoBrowserProgress = toolExecutions.length === 0 || toolExecutions.every((execution) => !actionTools.includes(execution.toolName) || !execution.wasSuccessful);
482
+ if (!hasNoBrowserProgress) return false;
483
+
484
+ this.stalledIterations++;
485
+ if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
486
+
487
+ task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
488
+ task.finish(TestResult.FAILED);
489
+ return true;
490
+ }
491
+
459
492
  private async prepareInstructionsForNextStep(task: Test): Promise<string> {
460
493
  let outcomeStatus = dedent`
461
494
  <task>
@@ -464,6 +497,8 @@ export class Tester extends TaskAgent implements Agent {
464
497
 
465
498
  <rules>
466
499
  Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
500
+ Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
501
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
467
502
  Do not do unsuccesful clicks again.
468
503
  Do not run same tool calls with same parameters again.
469
504
  </rules>
@@ -746,6 +781,7 @@ export class Tester extends TaskAgent implements Agent {
746
781
  <rules>
747
782
  - Refer to UI Map from <page_ui_map> to understand the page structure and its main elements
748
783
  - Use only elements that exist in the provided ARIA tree or HTML, <page_aria> and <page_html>
784
+ - Use tool input schemas exactly as documented. Do not invent parameter names or add fields not listed by the tool schema.
749
785
  - Use click() for buttons, links, and clickable elements ONLY - do NOT include I.fillField() or I.type() commands in click() tool
750
786
  - click() commands array is for FALLBACK LOCATORS of the SAME element, NOT for clicking different elements in sequence. If you need to click two different elements, make two separate click() calls.
751
787
  - Use form() for text input (I.fillField, I.type), dropdown selection (I.selectOption), file uploads (I.attachFile), and multi-step form interactions
@@ -758,6 +794,7 @@ export class Tester extends TaskAgent implements Agent {
758
794
  - NEVER call record(status: "success") if your last verify() or see() call FAILED. A failed check means the outcome is NOT confirmed — use record(status: "fail") instead, or retry with a different approach.
759
795
  - Use finish() to complete the test, not record(). record() is for intermediate notes.
760
796
  - Call finish(verify) when all goals are achieved — provide an assertion to verify
797
+ - NEVER call finish() with a negative assertion that says the goal did NOT happen. If the goal cannot be achieved after real attempts, record the blocker and call stop().
761
798
  - ONLY call stop() if the scenario itself is completely irrelevant to this page and no expectations can be achieved
762
799
  - Use reset() ONLY as a last resort when the current page cannot host the scenario. Never reset after a successful flow just because an assertion or milestone did not match — verify differently or record() the finding instead. Reset is destructive and does not undo server-side side effects.
763
800
  - Be precise with locators (CSS or XPath)
@@ -771,6 +808,13 @@ export class Tester extends TaskAgent implements Agent {
771
808
  - When you interact with form with inputs, ensure that you click corresponding button to save its data
772
809
  - Follow <locator_priority> rules when selecting locators for all tools
773
810
  - Before retrying your actions check maybe they already achived expected results. Use see() tool for that
811
+ - If the current URL is already a create/edit/new form and the scenario is about creating/editing that entity, fill and submit that form. Do not click the list-page "New" button again from inside the form.
812
+ - If the scenario is about search/filter/sort/tabs/list inspection and the current URL is a create/edit/new form, go back or reset to the stable list page before interacting with list controls.
813
+ - When selecting related entities from a list, do not choose rows/options/cards marked as "0 items", "0 tests", or otherwise empty if the scenario requires selecting real content.
814
+ - In selection pickers, counters such as "Selected 0", "Matched tests 0", or disabled Save/Apply mean the selection did not register. Choose a non-empty item or change filters before submitting.
815
+ - A passed form/click command only means the command executed. If a required field remains empty, submit stays disabled, or the expected text is not visible, treat the action as not completed and correct the missing field/state.
816
+ - For filter/tab scenarios, success requires BOTH: the requested filter/tab is visibly active/selected AND the list content matches that filter. Do not finish from only one of these signals.
817
+ - Empty-state text such as "No matched items" only proves a filter when the requested filter/tab is active and the empty state belongs to the filtered list.
774
818
  - When filling complex form with lot of actions performed, use see() to look which fields were filled and which are not
775
819
  - When verify() fails, use see() to visually confirm the result — visual confirmation is equally valid evidence
776
820
  - For visual state verification (active tabs, selected items, counts, colors), prefer see() over DOM-based verify()
@@ -799,6 +843,10 @@ export class Tester extends TaskAgent implements Agent {
799
843
 
800
844
  ${formRequirementsRule}
801
845
 
846
+ ${capabilityGroundingRule}
847
+
848
+ ${dataProtectionRules}
849
+
802
850
  ${this.provider.getSystemPromptForAgent('tester', this.explorer.getStateManager().getCurrentState()?.url) || ''}
803
851
  `;
804
852
  }
@@ -822,10 +870,13 @@ export class Tester extends TaskAgent implements Agent {
822
870
  Try to achieve as many goals as possible.
823
871
  If goal is not achievable, log that and skip to next one.
824
872
  Do not hallucinate that goal was achieved when it was not.
873
+ If the scenario action could not be completed, do not finish with a verification of the failure state.
825
874
  When creating or editing items via form() or type() you should include ${task.sessionName} in the value (if it is not restricted by the application logic)
826
875
  Initial page URL: ${actionResult.url}
827
876
 
828
- ${protectionRule}
877
+ ${capabilityGroundingRule}
878
+
879
+ ${dataProtectionRules}
829
880
 
830
881
  ${this.buildDeletionScope(task)}
831
882
 
@@ -963,12 +1014,13 @@ export class Tester extends TaskAgent implements Agent {
963
1014
  }),
964
1015
  stop: tool({
965
1016
  description: dedent`
966
- Stop the current test because the scenario is fundamentally incompatible with the page.
967
- Use this ONLY when the scenario cannot be executed on the current page or application.
968
- Do NOT use this for failures — use reset() and retry instead.
1017
+ Stop the current test because it cannot be completed in the current session.
1018
+ Use this when the scenario is incompatible, required UI/data is absent, or repeated varied attempts
1019
+ show that automation cannot complete the workflow.
1020
+ Do NOT use this immediately after the first failed action — retry with a materially different approach first.
969
1021
  `,
970
1022
  inputSchema: z.object({
971
- reason: z.string().describe('Explanation why the scenario is incompatible'),
1023
+ reason: z.string().describe('Explanation why the scenario cannot be completed'),
972
1024
  }),
973
1025
  execute: async ({ reason }) => {
974
1026
  task.addNote(`Stop requested: ${reason}`);
@@ -1003,6 +1055,7 @@ export class Tester extends TaskAgent implements Agent {
1003
1055
  Provide a specific assertion to verify the final state.
1004
1056
  The assertion MUST prove that YOUR ACTIONS changed the page state.
1005
1057
  Do NOT verify something that was already true before you started testing.
1058
+ Do NOT provide an assertion that verifies absence, failure, an empty state, or that the goal did not happen.
1006
1059
 
1007
1060
  Examples of good assertions:
1008
1061
  - "New user 'john@example.com' is visible in the users list"
@@ -1135,12 +1188,26 @@ export class Tester extends TaskAgent implements Agent {
1135
1188
  this.resetFailureCount();
1136
1189
  this.previousUrl = null;
1137
1190
  this.previousStateHash = null;
1191
+ this.stalledIterations = 0;
1192
+ } else if (this.shouldStopAfterStalledLoopError(task)) {
1193
+ return 'stop';
1138
1194
  }
1139
1195
 
1140
1196
  this.currentConversation?.addUserText(result.message);
1141
1197
  return 'continue';
1142
1198
  }
1143
1199
 
1200
+ private shouldStopAfterStalledLoopError(task: Test): boolean {
1201
+ if (task.hasFinished) return false;
1202
+
1203
+ this.stalledIterations++;
1204
+ if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
1205
+
1206
+ task.addNote('No browser progress after repeated execution errors', TestResult.FAILED);
1207
+ task.finish(TestResult.FAILED);
1208
+ return true;
1209
+ }
1210
+
1144
1211
  private async cleanupStartedTest(task: Test): Promise<void> {
1145
1212
  await this.finishTest(task);
1146
1213
  await this.explorer.stopTest(task, {
package/src/ai/tools.ts CHANGED
@@ -18,7 +18,7 @@ import { isInteractive } from './task-agent.ts';
18
18
 
19
19
  const debugLog = createDebug('explorbot:tools');
20
20
 
21
- export const CODECEPT_TOOLS = ['click', 'pressKey', 'form'] as const;
21
+ export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'] as const;
22
22
  export const ASSERTION_TOOLS = ['verify'] as const;
23
23
 
24
24
  export function createCodeceptJSTools(explorer: Explorer, task: Task) {
@@ -160,6 +160,84 @@ export function createCodeceptJSTools(explorer: Explorer, task: Task) {
160
160
  },
161
161
  }),
162
162
 
163
+ hover: tool({
164
+ description: dedent`
165
+ Move the mouse cursor to an element to reveal hover-only controls.
166
+
167
+ Use this before clicking row actions, icon buttons, menus, or toolbars that appear only
168
+ when the user hovers a list item, table row, card, or tree node.
169
+
170
+ This tool ONLY accepts I.moveCursorTo(locator) commands. It does not click.
171
+ After hovering, use context(), see(), or click() the revealed control.
172
+ `,
173
+ inputSchema: z.object({
174
+ commands: z.array(z.string()).describe(dedent`
175
+ FALLBACK LOCATORS for ONE element to hover.
176
+ Order by reliability:
177
+ 1. I.moveCursorTo(text, container)
178
+ 2. I.moveCursorTo(ARIA, container)
179
+ 3. I.moveCursorTo(CSS, container)
180
+ 4. I.moveCursorTo(CSS) or I.moveCursorTo(XPath)
181
+ `),
182
+ explanation: z.string().describe('Why you are hovering this element'),
183
+ }),
184
+ execute: async ({ commands: rawCommands, explanation }) => {
185
+ const activeNote = task.startNote(explanation);
186
+
187
+ if (rawCommands.length === 0) {
188
+ activeNote.commit(TestResult.FAILED);
189
+ return failedToolResult('hover', 'No commands provided');
190
+ }
191
+
192
+ const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.moveCursorTo'));
193
+
194
+ if (invalidCommands.length > 0) {
195
+ activeNote.commit(TestResult.FAILED);
196
+ return failedToolResult('hover', `Invalid commands: ${invalidCommands.join(', ')}. Hover tool only accepts I.moveCursorTo() commands.`, {
197
+ suggestion: 'Use click() to click elements, or form() for typing/selecting.',
198
+ });
199
+ }
200
+
201
+ const commands = rawCommands.map((cmd) => {
202
+ const trimmed = cmd.trim();
203
+ if (trimmed.startsWith('I.moveCursorTo')) return trimmed;
204
+ return `I.moveCursorTo(${JSON.stringify(trimmed)})`;
205
+ });
206
+
207
+ const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
208
+ const action = explorer.createAction();
209
+ const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
210
+
211
+ for (const command of commands) {
212
+ const success = await action.attempt(command, explanation, true);
213
+ attempts.push({
214
+ command,
215
+ success,
216
+ ...(action.lastError && { error: action.lastError.toString() }),
217
+ });
218
+
219
+ if (!success) continue;
220
+
221
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
222
+ activeNote.commit(TestResult.PASSED);
223
+ return successToolResult('hover', { ...toolResult, attempts, code: command }, action);
224
+ }
225
+
226
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
227
+ activeNote.commit(TestResult.FAILED);
228
+ return failedToolResult(
229
+ 'hover',
230
+ 'All hover commands failed',
231
+ {
232
+ ...toolResult,
233
+ attempts,
234
+ suggestion: 'Use xpathCheck() to locate the row/card/tree node, or visualClick() if the hover target is only visually identifiable.',
235
+ },
236
+ action.lastError
237
+ );
238
+ },
239
+ }),
240
+
163
241
  pressKey: tool({
164
242
  description: dedent`
165
243
  Press a keyboard key or key combination. Use this for special keys like Enter, Escape, Tab, Arrow keys, or key combinations with modifiers.
@@ -488,6 +566,7 @@ export function createAgentTools({
488
566
  Check the page contents based on current page state and screenshot.
489
567
  This tool will trigger visual research to check the page contents on request.
490
568
  Use it to verify the actions were performed correctly and the page is in the expected state.
569
+ Input schema has exactly one field: request. Do not pass text, reason, assertion, or other fields.
491
570
 
492
571
  <example>
493
572
  request: "Check current state of the Login form"
package/src/config.ts CHANGED
@@ -59,6 +59,7 @@ interface AgentConfig extends HooksConfig {
59
59
  systemPrompt?: string;
60
60
  rules?: RuleEntry[];
61
61
  providerOptions?: Record<string, any>;
62
+ reasoning?: 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
62
63
  }
63
64
 
64
65
  interface ResearcherAgentConfig extends AgentConfig {
package/src/explorbot.ts CHANGED
@@ -25,12 +25,13 @@ import { ExperienceTracker } from './experience-tracker.ts';
25
25
  import Explorer from './explorer.ts';
26
26
  import { KnowledgeTracker } from './knowledge-tracker.ts';
27
27
  import { WebPageState } from './state-manager.ts';
28
+ import { Stats } from './stats.ts';
28
29
  import type { Suite } from './suite.ts';
29
30
  import { Plan, type Test } from './test-plan.ts';
30
- import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
31
31
  import { setVerboseMode, tag } from './utils/logger.ts';
32
32
  import { relativeToCwd } from './utils/next-steps.ts';
33
33
  import { sanitizeFilename } from './utils/strings.ts';
34
+ import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
34
35
 
35
36
  export interface ExplorBotOptions {
36
37
  from?: string;
@@ -494,7 +495,10 @@ export class ExplorBot {
494
495
 
495
496
  const reporter = this.explorer?.getReporter();
496
497
  if (reporter?.isEnabled()) {
497
- await reporter.setRunDescription(markdown);
498
+ let description = markdown;
499
+ const modelsTable = Stats.modelsTable(this.provider.getConfiguredModels());
500
+ if (modelsTable) description = `${markdown}\n\n${modelsTable}`;
501
+ await reporter.setRunDescription(description);
498
502
  }
499
503
 
500
504
  this.lastReportedTestCount = tests.length;
package/src/stats.ts CHANGED
@@ -54,6 +54,24 @@ export class Stats {
54
54
  return String(num);
55
55
  }
56
56
 
57
+ static modelsTable(roleModels: Record<string, string>): string {
58
+ const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
59
+ if (usedModels.length === 0) return '';
60
+
61
+ const rolesByModel: Record<string, string[]> = {};
62
+ for (const [role, model] of Object.entries(roleModels)) {
63
+ if (!rolesByModel[model]) rolesByModel[model] = [];
64
+ rolesByModel[model].push(role);
65
+ }
66
+
67
+ const rows = usedModels.map(([model, tokens]) => {
68
+ const roles = rolesByModel[model]?.join(', ') || '-';
69
+ return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
70
+ });
71
+
72
+ return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
73
+ }
74
+
57
75
  static hasActivity(): boolean {
58
76
  if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0) return true;
59
77
  const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);