explorbot 0.1.28 → 0.1.30

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 (68) hide show
  1. package/README.md +83 -245
  2. package/bin/explorbot-cli.ts +1 -0
  3. package/boat/doc-collector/src/ai/documentarian.ts +37 -13
  4. package/boat/doc-collector/src/ai/tools.ts +60 -20
  5. package/boat/doc-collector/src/cli.ts +3 -0
  6. package/boat/doc-collector/src/config.ts +7 -0
  7. package/boat/doc-collector/src/docbot.ts +23 -5
  8. package/boat/doc-collector/src/docs-renderer.ts +14 -1
  9. package/boat/doc-collector/src/screenshots.ts +126 -0
  10. package/dist/bin/explorbot-cli.js +1 -0
  11. package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
  12. package/dist/boat/doc-collector/src/ai/tools.js +53 -20
  13. package/dist/boat/doc-collector/src/cli.js +3 -0
  14. package/dist/boat/doc-collector/src/config.js +3 -0
  15. package/dist/boat/doc-collector/src/docbot.js +19 -4
  16. package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
  17. package/dist/boat/doc-collector/src/screenshots.js +90 -0
  18. package/dist/package.json +8 -6
  19. package/dist/rules/navigator/verification-actions.md +2 -0
  20. package/dist/src/action.js +26 -23
  21. package/dist/src/ai/fisherman.js +14 -3
  22. package/dist/src/ai/historian/codeceptjs.js +3 -2
  23. package/dist/src/ai/historian/experience.js +48 -6
  24. package/dist/src/ai/historian/playwright.js +2 -1
  25. package/dist/src/ai/historian/utils.js +1 -19
  26. package/dist/src/ai/historian.js +1 -1
  27. package/dist/src/ai/pilot.js +19 -4
  28. package/dist/src/ai/planner.js +16 -5
  29. package/dist/src/ai/provider.js +53 -18
  30. package/dist/src/ai/quartermaster.js +2 -2
  31. package/dist/src/ai/researcher.js +7 -1
  32. package/dist/src/ai/rules.js +44 -0
  33. package/dist/src/ai/tester.js +73 -7
  34. package/dist/src/ai/tools.js +66 -1
  35. package/dist/src/experience-tracker.js +1 -1
  36. package/dist/src/explorbot.js +14 -3
  37. package/dist/src/explorer.js +30 -27
  38. package/dist/src/stats.js +16 -0
  39. package/dist/src/utils/aria.js +66 -6
  40. package/dist/src/utils/browser-errors.js +5 -0
  41. package/dist/src/utils/page-readiness.js +48 -0
  42. package/dist/src/utils/step-analyzer.js +68 -0
  43. package/package.json +8 -6
  44. package/rules/navigator/verification-actions.md +2 -0
  45. package/src/action.ts +24 -26
  46. package/src/ai/fisherman.ts +14 -3
  47. package/src/ai/historian/codeceptjs.ts +3 -2
  48. package/src/ai/historian/experience.ts +51 -6
  49. package/src/ai/historian/playwright.ts +2 -1
  50. package/src/ai/historian/utils.ts +1 -21
  51. package/src/ai/historian.ts +1 -1
  52. package/src/ai/pilot.ts +19 -4
  53. package/src/ai/planner.ts +16 -5
  54. package/src/ai/provider.ts +51 -19
  55. package/src/ai/quartermaster.ts +2 -2
  56. package/src/ai/researcher.ts +8 -1
  57. package/src/ai/rules.ts +46 -0
  58. package/src/ai/tester.ts +77 -7
  59. package/src/ai/tools.ts +79 -1
  60. package/src/config.ts +2 -0
  61. package/src/experience-tracker.ts +1 -1
  62. package/src/explorbot.ts +13 -3
  63. package/src/explorer.ts +28 -27
  64. package/src/stats.ts +18 -0
  65. package/src/utils/aria.ts +63 -6
  66. package/src/utils/browser-errors.ts +6 -0
  67. package/src/utils/page-readiness.ts +59 -0
  68. package/src/utils/step-analyzer.ts +73 -0
@@ -6,12 +6,12 @@ import type { Reporter, ReporterStep } from '../../reporter.ts';
6
6
  import type { StateManager } from '../../state-manager.ts';
7
7
  import { type Task, Test } from '../../test-plan.ts';
8
8
  import { tag } from '../../utils/logger.ts';
9
+ import { isCodeceptToolName, isNonReusableCode, mergeUniqueStepsByCode, stripComments, toReusableSessionStep } from '../../utils/step-analyzer.ts';
9
10
  import { extractStatePath } from '../../utils/url-matcher.ts';
10
11
  import type { Conversation, ToolExecution } from '../conversation.ts';
11
12
  import type { Provider } from '../provider.ts';
12
- import { CODECEPT_TOOLS } from '../tools.ts';
13
13
  import { type Constructor, debugLog } from './mixin.ts';
14
- import { getExecutionLabel, isNonReusableCode, stripComments } from './utils.ts';
14
+ import { getExecutionLabel } from './utils.ts';
15
15
 
16
16
  export interface ExperienceMethods {
17
17
  saveSession(task: Task, initialState: ActionResult, conversation: Conversation): Promise<void>;
@@ -38,12 +38,18 @@ export function WithExperience<T extends Constructor>(Base: T) {
38
38
  task.generatedCode = this.isPlaywrightFramework() ? await this.toPlaywrightCode(conversation, task.description) : this.toCode(conversation, task.description);
39
39
  }
40
40
 
41
- const steps = await this.extractSteps(toolExecutions);
41
+ const conversationSteps = await this.extractSteps(toolExecutions);
42
+ const taskSteps = this.extractPassedCodeceptSteps(task);
43
+ const steps = mergeUniqueStepsByCode(conversationSteps, taskSteps);
42
44
 
43
45
  const skipExperience = result === 'failed' || (task instanceof Test && (task.hasFailed || task.isSkipped));
44
46
  if (!skipExperience) {
47
+ const hasExistingFlow = this.hasRelevantFlowExperience(initialState);
45
48
  await this.detectRetryPatterns(toolExecutions, initialState);
46
- const body = await this.curateFlow(steps, task, initialState);
49
+ let body = await this.curateFlow(steps, task, initialState);
50
+ if (!body.trim() && !hasExistingFlow) {
51
+ body = this.renderFlowFromSuccessfulSteps(steps, task);
52
+ }
47
53
  if (body.trim()) {
48
54
  const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
49
55
  this.experienceTracker.writeFlow(initialState, body, relatedUrls);
@@ -76,7 +82,7 @@ export function WithExperience<T extends Constructor>(Base: T) {
76
82
  const stepsWithDiffs: Array<{ step: SessionStep; ariaDiff: string | null }> = [];
77
83
 
78
84
  for (const exec of toolExecutions) {
79
- if (!CODECEPT_TOOLS.includes(exec.toolName as any)) continue;
85
+ if (!isCodeceptToolName(exec.toolName)) continue;
80
86
  if (!exec.output?.code) continue;
81
87
  if (!exec.wasSuccessful) continue;
82
88
  if (isNonReusableCode(exec.output.code)) continue;
@@ -96,6 +102,19 @@ export function WithExperience<T extends Constructor>(Base: T) {
96
102
  return stepsWithDiffs.map((s) => s.step);
97
103
  }
98
104
 
105
+ private hasRelevantFlowExperience(state: ActionResult): boolean {
106
+ return this.experienceTracker.getRelevantExperience(state).some((experience) => experience.content.includes('## FLOW:'));
107
+ }
108
+
109
+ private extractPassedCodeceptSteps(task: Task): SessionStep[] {
110
+ const steps: SessionStep[] = [];
111
+ for (const step of Object.values(task.steps)) {
112
+ const sessionStep = toReusableSessionStep(step);
113
+ if (sessionStep) steps.push(sessionStep);
114
+ }
115
+ return steps;
116
+ }
117
+
99
118
  private async curateFlow(steps: SessionStep[], task: Task, initialState: ActionResult): Promise<string> {
100
119
  if (steps.length === 0) return '';
101
120
 
@@ -207,6 +226,32 @@ export function WithExperience<T extends Constructor>(Base: T) {
207
226
  }
208
227
  }
209
228
 
229
+ private renderFlowFromSuccessfulSteps(steps: SessionStep[], task: Task): string {
230
+ if (steps.length === 0) return '';
231
+
232
+ const title = task.description.charAt(0).toLowerCase() + task.description.slice(1);
233
+ const blocks = steps
234
+ .filter((step) => step.code)
235
+ .map((step) => {
236
+ const lines = [`* ${step.message}`];
237
+ lines.push('');
238
+ lines.push('```js');
239
+ lines.push(stripComments(step.code || ''));
240
+ lines.push('```');
241
+ if (step.discovery) {
242
+ lines.push('');
243
+ for (const discovery of step.discovery.split('\n').filter((line) => line.trim())) {
244
+ lines.push(`> ${discovery.trim()}`);
245
+ }
246
+ }
247
+ return lines.join('\n');
248
+ });
249
+
250
+ if (blocks.length === 0) return '';
251
+
252
+ return `## FLOW: ${title}\n\n${blocks.join('\n\n')}\n\n---\n`;
253
+ }
254
+
210
255
  private async detectRetryPatterns(toolExecutions: ToolExecution[], initialState: ActionResult): Promise<void> {
211
256
  if (!this.experienceTracker || !this.stateManager) return;
212
257
 
@@ -214,7 +259,7 @@ export function WithExperience<T extends Constructor>(Base: T) {
214
259
  const candidates: Array<{ failed: ToolExecution[]; success: ToolExecution }> = [];
215
260
 
216
261
  for (const exec of toolExecutions) {
217
- if (!CODECEPT_TOOLS.includes(exec.toolName as any)) continue;
262
+ if (!isCodeceptToolName(exec.toolName)) continue;
218
263
  if (!exec.output?.code) continue;
219
264
 
220
265
  if (!exec.wasSuccessful) {
@@ -8,8 +8,9 @@ import type { Plan } from '../../test-plan.ts';
8
8
  import { tag } from '../../utils/logger.ts';
9
9
  import { relativeToCwd } from '../../utils/next-steps.ts';
10
10
  import { safeFilename } from '../../utils/strings.ts';
11
+ import { CODECEPT_TOOLS } from '../../utils/step-analyzer.ts';
11
12
  import type { Conversation } from '../conversation.ts';
12
- import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
13
+ import { ASSERTION_TOOLS } from '../tools.ts';
13
14
  import type { Constructor } from './mixin.ts';
14
15
  import { escapeString, getExecutionLabel } from './utils.ts';
15
16
 
@@ -1,30 +1,10 @@
1
- import { isDynamicId } from '../../utils/xpath.ts';
2
1
  import type { ToolExecution } from '../conversation.ts';
3
-
4
- export function isNonReusableCode(code: string): boolean {
5
- if (/\bI\.clickXY\s*\(/.test(code)) return true;
6
-
7
- for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
8
- if (isDynamicId(m[1])) return true;
9
- }
10
-
11
- return false;
12
- }
2
+ export { isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
13
3
 
14
4
  export function escapeString(str: string): string {
15
5
  return str.replace(/'/g, "\\'").replace(/\n/g, ' ');
16
6
  }
17
7
 
18
- export function stripComments(code: string): string {
19
- return code
20
- .split('\n')
21
- .filter((line) => {
22
- const trimmed = line.trim();
23
- return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
24
- })
25
- .join('\n');
26
- }
27
-
28
8
  export function getExecutionLabel(exec: ToolExecution, fallback?: string): string {
29
9
  return exec.input?.explanation || exec.input?.assertion || exec.input?.note || fallback || '';
30
10
  }
@@ -13,7 +13,7 @@ import { type PlaywrightMethods, WithPlaywright } from './historian/playwright.t
13
13
  import { type ScreencastMethods, WithScreencast } from './historian/screencast.ts';
14
14
  import type { Provider } from './provider.ts';
15
15
 
16
- export { isNonReusableCode } from './historian/utils.ts';
16
+ export { isNonReusableCode } from '../utils/step-analyzer.ts';
17
17
 
18
18
  const HistorianBase = WithScreencast(WithPlaywright(WithCodeceptJS(WithExperience(Object as unknown as new (...args: any[]) => object))));
19
19
 
package/src/ai/pilot.ts CHANGED
@@ -18,6 +18,7 @@ import type { Fisherman } from './fisherman.ts';
18
18
  import type { Navigator } from './navigator.ts';
19
19
  import type { Provider } from './provider.ts';
20
20
  import type { Researcher } from './researcher.ts';
21
+ import { capabilityGroundingRule, dataProtectionRules } from './rules.ts';
21
22
  import { isInteractive } from './task-agent.ts';
22
23
 
23
24
  const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
@@ -91,7 +92,7 @@ export class Pilot implements Agent {
91
92
 
92
93
  let visualAnalysis = '';
93
94
  let screenshotState: ActionResult | null = null;
94
- if (this.provider.hasVision()) {
95
+ if (type === 'finish' && this.provider.hasVision()) {
95
96
  try {
96
97
  screenshotState = await this.explorer.capturePageWithScreenshot();
97
98
  if (screenshotState.screenshot) {
@@ -164,7 +165,7 @@ export class Pilot implements Agent {
164
165
  try {
165
166
  const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), {
166
167
  agentName: 'pilot',
167
- experimental_telemetry: { functionId: 'pilot.reviewVerdict' },
168
+ telemetry: { functionId: 'pilot.reviewVerdict' },
168
169
  });
169
170
 
170
171
  const result = response?.object;
@@ -266,7 +267,7 @@ export class Pilot implements Agent {
266
267
  try {
267
268
  const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), {
268
269
  agentName: 'pilot',
269
- experimental_telemetry: { functionId: 'pilot.reviewReset' },
270
+ telemetry: { functionId: 'pilot.reviewReset' },
270
271
  });
271
272
 
272
273
  const result = response?.object;
@@ -378,6 +379,8 @@ export class Pilot implements Agent {
378
379
  You are Pilot — final decision maker for test pass/fail. Tester requested ${type}. Review the
379
380
  evidence and commit to a verdict; "continue" only when evidence is genuinely insufficient.
380
381
 
382
+ ${capabilityGroundingRule}
383
+
381
384
  ${this.buildSharedEvidenceRules(task)}
382
385
 
383
386
  DECISION:
@@ -386,6 +389,8 @@ export class Pilot implements Agent {
386
389
  Pick assertions DOM can express; for non-DOM regions (iframes, canvas, Monaco/CodeMirror), target a
387
390
  stable landmark (container, ARIA role) instead of literal inner text. Your "pass" stands even if the
388
391
  DOM assertion can't be made.
392
+ Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
393
+ requested action, workflow, or entity detail goal.
389
394
  - "fail": scenario was attempted but the goal was not achieved.
390
395
  - "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
391
396
  crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or "continue".
@@ -424,6 +429,10 @@ export class Pilot implements Agent {
424
429
 
425
430
  FIRST: Decide if precondition() is needed.
426
431
 
432
+ ${capabilityGroundingRule}
433
+
434
+ ${dataProtectionRules}
435
+
427
436
  Call precondition() WHEN:
428
437
  - The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
429
438
  - The scenario needs specific data clearly NOT on the current page (e.g., items with specific statuses for filtering)
@@ -576,7 +585,7 @@ export class Pilot implements Agent {
576
585
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
577
586
  agentName: 'pilot',
578
587
  stopWhen: opts.task ? () => opts.task!.hasFinished : undefined,
579
- experimental_telemetry: { functionId },
588
+ telemetry: { functionId },
580
589
  });
581
590
  const text = result?.response?.text || '';
582
591
  const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => e.output.content);
@@ -1029,9 +1038,13 @@ export class Pilot implements Agent {
1029
1038
 
1030
1039
  Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck, visualClick,
1031
1040
  back, getVisitedStates, reset, stop, finish, record.
1041
+ Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
1042
+
1043
+ ${capabilityGroundingRule}
1032
1044
 
1033
1045
  YOUR Pilot-only tool: precondition(description) — create FRESH disposable test data via API. Never
1034
1046
  request users. Use when:
1047
+
1035
1048
  - Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
1036
1049
  - Scenario needs auxiliary data (labels, categories, statuses for filtering).
1037
1050
  - Tester failed because required data is missing (empty dropdown, empty list).
@@ -1041,6 +1054,8 @@ export class Pilot implements Agent {
1041
1054
  - Current page already shows the exact data needed.
1042
1055
  - Scenario tests navigation, search UI, or viewing.
1043
1056
 
1057
+ ${dataProtectionRules}
1058
+
1044
1059
  Describe WHAT to create, not what exists. RIGHT: precondition("1 test"). WRONG:
1045
1060
  precondition("1 test suite named Updated Suite with existing tests"). Keep descriptions short.
1046
1061
 
package/src/ai/planner.ts CHANGED
@@ -24,7 +24,7 @@ import type { Provider } from './provider.js';
24
24
  import { POSSIBLE_SECTIONS, Researcher } from './researcher.ts';
25
25
  import { findSimilarStateHash } from './researcher/cache.ts';
26
26
  import { hasFocusedSection } from './researcher/focus.ts';
27
- import { fileUploadRule, protectionRule } from './rules.ts';
27
+ import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from './rules.ts';
28
28
 
29
29
  const debugLog = createDebug('explorbot:planner');
30
30
 
@@ -35,7 +35,7 @@ const TasksSchema = z.object({
35
35
  z.object({
36
36
  scenario: z.string().describe('A single sentence describing what to test'),
37
37
  priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
38
- startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL (only for tests on visited subpages)'),
38
+ startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
39
39
  steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
40
40
  expectedOutcomes: z
41
41
  .array(z.string())
@@ -90,6 +90,9 @@ export class Planner extends PlannerBase implements Agent {
90
90
  const featureDirective = feature
91
91
  ? `\n IMPORTANT: The user requested to focus specifically on: "${feature}"\n ALL scenarios MUST be directly related to this feature. Do not propose generic page tests unrelated to it.\n Use the user's exact wording to guide scenario names — do not substitute different entities (e.g., do not plan "suite" actions when user said "test").`
92
92
  : '';
93
+ const focusExistingDataDirective = feature
94
+ ? '\n If this focus asks for search, filter, tabs, sorting, or list behavior involving existing items, only use item names/values visible in the provided page research. If no concrete visible item names/values are present, do NOT propose scenarios that require an existing known item; propose no-match search, empty-state, clear-search, tab/filter empty-list, or other read-only list behavior instead.'
95
+ : '';
93
96
  return dedent`
94
97
  <role>
95
98
  You are ISTQB certified senior manual QA planning exploratory testing session of a web application.
@@ -113,7 +116,7 @@ export class Planner extends PlannerBase implements Agent {
113
116
  Bad: "Open delete dropdown" + "Confirm deletion" — these are ONE test, not two.
114
117
  Bad: "Search for X" + "Verify search results" — searching and verifying is ONE test.
115
118
  Bad: "Leave field empty" + "Click submit" — that's one negative test, not two.
116
- If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}
119
+ If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}${focusExistingDataDirective}
117
120
  </task>
118
121
 
119
122
  ${customPrompt || ''}
@@ -341,6 +344,11 @@ export class Planner extends PlannerBase implements Agent {
341
344
  If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible or API preconditions can create it.
342
345
  If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
343
346
  Do not assume hidden data exists just because a control is present.
347
+ For scenarios that act on existing items or search/filter by existing values, use only item names or values visible in research, visited pages, or prior observed flows.
348
+ If the list is empty or no concrete item names are visible, do not invent "known" or "existing" items. Prefer empty-state, no-match search, clear-search, or read-only list behavior scenarios.
349
+ Search, filter, sorting, tab, and list scenarios must start from a stable page where those controls are visible; avoid transient create/edit/new URLs unless the scenario tests that form.
350
+ For option values and list items, use only visible or previously observed data; do not add create/update/delete setup unless the user explicitly requests that workflow.
351
+ Detail-view scenarios must target visible data entities from list rows, cards, tree nodes, or detail links; do not use filter tabs, counters, status tabs, breadcrumbs, or navigation controls as detail targets.
344
352
  DO NOT propose "verification-only" tests that merely open a UI element (modal, dropdown, panel) and check it exists.
345
353
  Every test must complete a meaningful action that changes application state or produces a business outcome.
346
354
  Opening a modal is NOT a test — performing an action INSIDE the modal IS a test.
@@ -351,7 +359,8 @@ export class Planner extends PlannerBase implements Agent {
351
359
  Tests that only switch views, toggle filters, or paginate are LESS valuable — propose them only after data-changing tests are covered.
352
360
  If multiple ways to create or modify data exist (different types, different forms), propose a separate test for each.
353
361
  </priority_order>
354
- ${protectionRule}
362
+ ${capabilityGroundingRule}
363
+ ${dataProtectionRules}
355
364
  ${fileUploadRule}
356
365
  </rules>
357
366
 
@@ -514,7 +523,9 @@ export class Planner extends PlannerBase implements Agent {
514
523
  .join('\n')}
515
524
 
516
525
  You MAY propose tests starting from these pages if they are relevant to the plan "${this.currentPlan.title}".
517
- Set startUrl for such tests. Ignore pages that belong to a different feature area.
526
+ Set startUrl for such tests only when the page is a stable feature/list/detail page.
527
+ Do not use create/edit/new/modal URLs as startUrl for scenarios that need the underlying page.
528
+ Ignore pages that belong to a different feature area.
518
529
  </context_from_previous_tests>
519
530
 
520
531
  Propose ONLY new scenarios that are NOT in the existing tests list.
@@ -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}`);
@@ -6,9 +6,9 @@ import { ConfigParser } from '../config.ts';
6
6
  import type { StateManager, StateTransition, WebPageState } from '../state-manager.ts';
7
7
  import type { Task } from '../test-plan.ts';
8
8
  import { createDebug, tag } from '../utils/logger.ts';
9
+ import { isCodeceptToolName } from '../utils/step-analyzer.ts';
9
10
  import type { Conversation, ToolExecution } from './conversation.ts';
10
11
  import type { Provider } from './provider.ts';
11
- import { CODECEPT_TOOLS } from './tools.ts';
12
12
 
13
13
  const debugLog = createDebug('explorbot:quartermaster');
14
14
 
@@ -140,7 +140,7 @@ export class Quartermaster {
140
140
  const pageAnalysis = this.pageAnalyses.get(stateHash);
141
141
 
142
142
  const toolExecutions = conversation.getToolExecutions();
143
- const codeceptExecutions = toolExecutions.filter((e) => CODECEPT_TOOLS.includes(e.toolName as any));
143
+ const codeceptExecutions = toolExecutions.filter((e) => isCodeceptToolName(e.toolName));
144
144
 
145
145
  if (codeceptExecutions.length === 0 && !pageAnalysis?.axeViolations.length) {
146
146
  debugLog('No interactions or violations to analyze');
@@ -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):