explorbot 0.5.9 → 0.6.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 (167) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +495 -482
  3. package/boat/api-tester/src/ai/curler.ts +1 -1
  4. package/boat/api-tester/src/cli.ts +4 -4
  5. package/boat/api-tester/src/commands/init-command.ts +2 -0
  6. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
  7. package/boat/doc-collector/src/screenshots.ts +1 -1
  8. package/boat/prima/bin/prima-cli.ts +2 -1
  9. package/dist/bin/explorbot-cli.js +456 -444
  10. package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
  11. package/dist/boat/api-tester/src/ai/curler.js +1 -1
  12. package/dist/boat/api-tester/src/cli.js +4 -4
  13. package/dist/boat/api-tester/src/commands/init-command.js +2 -0
  14. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
  15. package/dist/boat/prima/bin/prima-cli.js +2 -1
  16. package/dist/models.json +1 -1
  17. package/dist/package.json +2 -1
  18. package/dist/rules/navigator/output.md +2 -8
  19. package/dist/rules/navigator/verification-actions.md +6 -2
  20. package/dist/rules/planner/ignored-pages.md +1 -0
  21. package/dist/src/action-result.js +1 -1
  22. package/dist/src/action.d.ts +0 -1
  23. package/dist/src/action.js +0 -4
  24. package/dist/src/ai/captain/idle-mode.js +42 -26
  25. package/dist/src/ai/captain/mixin.d.ts +2 -0
  26. package/dist/src/ai/captain/web-mode.js +11 -0
  27. package/dist/src/ai/captain.d.ts +6 -4
  28. package/dist/src/ai/captain.js +25 -1
  29. package/dist/src/ai/driller.js +1 -1
  30. package/dist/src/ai/experience-compactor.js +2 -2
  31. package/dist/src/ai/judge.d.ts +7 -0
  32. package/dist/src/ai/judge.js +8 -1
  33. package/dist/src/ai/navigator.d.ts +1 -0
  34. package/dist/src/ai/navigator.js +11 -7
  35. package/dist/src/ai/planner/subpages.js +1 -1
  36. package/dist/src/ai/planner.d.ts +12 -5
  37. package/dist/src/ai/planner.js +138 -86
  38. package/dist/src/ai/provider.d.ts +1 -1
  39. package/dist/src/ai/provider.js +16 -5
  40. package/dist/src/ai/rerunner.js +1 -1
  41. package/dist/src/ai/researcher/cache.js +14 -6
  42. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
  43. package/dist/src/ai/researcher/deep-analysis.js +7 -2
  44. package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
  45. package/dist/src/ai/researcher.js +44 -24
  46. package/dist/src/ai/rules.js +6 -7
  47. package/dist/src/ai/scout.d.ts +12 -2
  48. package/dist/src/ai/scout.js +103 -8
  49. package/dist/src/ai/tester.d.ts +9 -5
  50. package/dist/src/ai/tester.js +22 -42
  51. package/dist/src/ai/tools.js +10 -8
  52. package/dist/src/application-spec.d.ts +2 -0
  53. package/dist/src/application-spec.js +17 -12
  54. package/dist/src/commands/context-experience-command.js +0 -1
  55. package/dist/src/commands/context-knowledge-command.js +0 -1
  56. package/dist/src/commands/explore-command.d.ts +3 -2
  57. package/dist/src/commands/explore-command.js +35 -23
  58. package/dist/src/commands/help-json-command.js +3 -1
  59. package/dist/src/commands/init-command.js +8 -4
  60. package/dist/src/commands/learn-command.js +0 -2
  61. package/dist/src/commands/options/auth-option.d.ts +6 -0
  62. package/dist/src/commands/options/auth-option.js +14 -0
  63. package/dist/src/commands/options/index.d.ts +2 -0
  64. package/dist/src/commands/options/index.js +2 -0
  65. package/dist/src/commands/plan-command.d.ts +3 -0
  66. package/dist/src/commands/plan-command.js +43 -17
  67. package/dist/src/commands/plan-save-command.js +6 -17
  68. package/dist/src/commands/plans-command.js +1 -1
  69. package/dist/src/commands/test-command.js +12 -2
  70. package/dist/src/components/App.d.ts +1 -2
  71. package/dist/src/components/App.js +1 -3
  72. package/dist/src/components/InputPane.js +0 -5
  73. package/dist/src/config.d.ts +3 -2
  74. package/dist/src/config.js +3 -12
  75. package/dist/src/explorbot.d.ts +3 -2
  76. package/dist/src/explorbot.js +32 -10
  77. package/dist/src/explorer.js +6 -7
  78. package/dist/src/knowledge-tracker.d.ts +2 -2
  79. package/dist/src/knowledge-tracker.js +18 -6
  80. package/dist/src/playwright-recorder.d.ts +0 -1
  81. package/dist/src/playwright-recorder.js +0 -2
  82. package/dist/src/reporter.d.ts +1 -1
  83. package/dist/src/reporter.js +3 -3
  84. package/dist/src/test-plan.js +1 -1
  85. package/dist/src/utils/error-page.d.ts +17 -0
  86. package/dist/src/utils/error-page.js +53 -2
  87. package/dist/src/utils/html-diff.d.ts +1 -2
  88. package/dist/src/utils/html-diff.js +4 -44
  89. package/dist/src/utils/logger.js +27 -10
  90. package/dist/src/utils/markdown-terminal.js +1 -2
  91. package/dist/src/utils/next-steps.js +20 -16
  92. package/dist/src/utils/page-readiness.d.ts +2 -1
  93. package/dist/src/utils/page-readiness.js +24 -5
  94. package/dist/src/utils/test-plan-markdown.d.ts +3 -0
  95. package/dist/src/utils/test-plan-markdown.js +35 -4
  96. package/dist/src/utils/url-matcher.d.ts +1 -0
  97. package/dist/src/utils/url-matcher.js +5 -0
  98. package/docs/basics/providers.md +1 -1
  99. package/docs/contributing/regression-tests.md +19 -4
  100. package/docs/reference/commands.md +18 -2
  101. package/docs/reference/configuration.md +1 -1
  102. package/docs/web-testing/agents.md +8 -0
  103. package/docs/web-testing/customization.md +2 -0
  104. package/docs/web-testing/researcher.md +1 -1
  105. package/docs/workflow/agentic-usage.md +7 -0
  106. package/docs/workflow/knowledge.md +6 -0
  107. package/models.json +1 -1
  108. package/package.json +2 -1
  109. package/rules/navigator/output.md +2 -8
  110. package/rules/navigator/verification-actions.md +6 -2
  111. package/rules/planner/ignored-pages.md +1 -0
  112. package/src/action-result.ts +1 -2
  113. package/src/action.ts +0 -5
  114. package/src/ai/captain/idle-mode.ts +38 -26
  115. package/src/ai/captain/mixin.ts +2 -1
  116. package/src/ai/captain/web-mode.ts +12 -0
  117. package/src/ai/captain.ts +25 -7
  118. package/src/ai/driller.ts +1 -2
  119. package/src/ai/experience-compactor.ts +2 -2
  120. package/src/ai/judge.ts +10 -1
  121. package/src/ai/navigator.ts +12 -7
  122. package/src/ai/planner/subpages.ts +1 -2
  123. package/src/ai/planner.ts +140 -92
  124. package/src/ai/provider.ts +15 -6
  125. package/src/ai/rerunner.ts +1 -2
  126. package/src/ai/researcher/cache.ts +16 -7
  127. package/src/ai/researcher/deep-analysis.ts +7 -2
  128. package/src/ai/researcher/fingerprint-worker.ts +5 -5
  129. package/src/ai/researcher.ts +44 -28
  130. package/src/ai/rules.ts +6 -7
  131. package/src/ai/scout.ts +98 -10
  132. package/src/ai/tester.ts +29 -47
  133. package/src/ai/tools.ts +11 -9
  134. package/src/application-spec.ts +19 -13
  135. package/src/commands/clean-command.ts +1 -1
  136. package/src/commands/context-command.ts +0 -1
  137. package/src/commands/context-experience-command.ts +0 -1
  138. package/src/commands/context-knowledge-command.ts +0 -1
  139. package/src/commands/explore-command.ts +36 -23
  140. package/src/commands/help-json-command.ts +8 -6
  141. package/src/commands/init-command.ts +10 -3
  142. package/src/commands/learn-command.ts +0 -2
  143. package/src/commands/options/auth-option.ts +15 -0
  144. package/src/commands/options/index.ts +2 -0
  145. package/src/commands/plan-command.ts +44 -17
  146. package/src/commands/plan-save-command.ts +6 -17
  147. package/src/commands/plans-command.ts +1 -1
  148. package/src/commands/test-command.ts +11 -2
  149. package/src/components/App.tsx +2 -6
  150. package/src/components/InputPane.tsx +0 -5
  151. package/src/config.ts +5 -16
  152. package/src/experience-tracker.ts +1 -1
  153. package/src/explorbot.ts +31 -11
  154. package/src/explorer.ts +6 -7
  155. package/src/knowledge-tracker.ts +18 -7
  156. package/src/playwright-recorder.ts +0 -2
  157. package/src/reporter.ts +3 -4
  158. package/src/test-plan.ts +1 -2
  159. package/src/utils/error-page.ts +64 -2
  160. package/src/utils/html-diff.ts +4 -56
  161. package/src/utils/html.ts +0 -1
  162. package/src/utils/logger.ts +30 -12
  163. package/src/utils/markdown-terminal.ts +1 -2
  164. package/src/utils/next-steps.ts +19 -17
  165. package/src/utils/page-readiness.ts +33 -7
  166. package/src/utils/test-plan-markdown.ts +36 -4
  167. package/src/utils/url-matcher.ts +6 -0
@@ -10,8 +10,10 @@ import { ErrorPageError, detectPageCondition } from "../utils/error-page.js";
10
10
  import { isBodyEmpty } from "../utils/html.js";
11
11
  import { createDebug, tag } from '../utils/logger.js';
12
12
  import { mdq } from "../utils/markdown-query.js";
13
+ import { waitForDomChange, waitForPageReadiness } from "../utils/page-readiness.js";
13
14
  import { RulesLoader } from "../utils/rules-loader.js";
14
15
  import { annotatePageElements } from "../utils/web-annotate.js";
16
+ import { UNDECIDED, pageEvidence } from "./judge.js";
15
17
  import { ContextLengthError } from './provider.js';
16
18
  import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from "./researcher/cache.js";
17
19
  import { WithCoordinates } from "./researcher/coordinates.js";
@@ -25,6 +27,7 @@ import { WithSections } from "./researcher/sections.js";
25
27
  import { locatorRule as generalLocatorRuleText } from './rules.js';
26
28
  import { TaskAgent } from "./task-agent.js";
27
29
  const debugLog = createDebug('explorbot:researcher');
30
+ const HIDDEN_CONTENT_STATEMENT = 'The page has collapsed or hidden content behind controls that open or expand it.';
28
31
  export const POSSIBLE_SECTIONS = {
29
32
  overlay: 'dialog, modal, drawer, popup, or active form overlay',
30
33
  list: 'list area (items collection, table, cards, or list view)',
@@ -106,7 +109,9 @@ export class Researcher extends ResearcherBase {
106
109
  if (light)
107
110
  tag('info').log('Light research: single pass, locator verification skipped');
108
111
  setActivity(`${this.emoji} Researching...`, 'action');
109
- await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
112
+ const retrying = opts._retriesLeft !== undefined;
113
+ if (!retrying)
114
+ await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
110
115
  await this.hooksRunner.runBeforeHook('researcher', state.url);
111
116
  const { elements: annotatedElements } = await this.explorer.withPage(annotatePageElements);
112
117
  debugLog(`Annotated ${annotatedElements.length} interactive elements with eidx`);
@@ -116,7 +121,7 @@ export class Researcher extends ResearcherBase {
116
121
  tag('warning').log(`Detected error page at ${state.url}`);
117
122
  throw new ErrorPageError(state.url, this.actionResult.title, this.actionResult.httpStatus);
118
123
  }
119
- if (condition === 'loading') {
124
+ if (condition === 'loading' && !retrying) {
120
125
  const settled = await this.waitUntilSettled(screenshot);
121
126
  if (!settled) {
122
127
  tag('warning').log(`Page at ${state.url} did not finish loading within timeout, continuing with best-effort research`);
@@ -243,7 +248,7 @@ export class Researcher extends ResearcherBase {
243
248
  if (fallback)
244
249
  markSectionAsFocused(result, fallback);
245
250
  }
246
- if (!interrupted() && deep) {
251
+ if (!interrupted() && deep && (await this.hasHiddenContent())) {
247
252
  try {
248
253
  await this.performDeepAnalysis(researchState, result);
249
254
  }
@@ -307,34 +312,49 @@ export class Researcher extends ResearcherBase {
307
312
  }
308
313
  async waitUntilSettled(screenshot) {
309
314
  const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
310
- if (errorPageTimeout <= 0)
311
- return false;
312
- const includeScreenshot = screenshot && this.provider.hasVision();
313
- try {
314
- await this.explorer.withPage((page) => page.waitForLoadState('networkidle', { timeout: errorPageTimeout * 1000 }));
315
- }
316
- catch { }
317
- await this.explorer.withPage(annotatePageElements);
318
- this.actionResult = await this.explorer.capture({ screenshot: includeScreenshot });
319
- let condition = detectPageCondition(this.actionResult);
320
- if (condition === 'error') {
321
- throw new ErrorPageError(this.actionResult.url, this.actionResult.title, this.actionResult.httpStatus);
322
- }
323
- if (condition === 'ok')
324
- return true;
325
- for (let i = 0; i < 3; i++) {
326
- await new Promise((r) => setTimeout(r, 1000));
315
+ const deadline = Date.now() + errorPageTimeout * 1000;
316
+ for (let remaining = errorPageTimeout * 1000; remaining > 0; remaining = deadline - Date.now()) {
317
+ const rendered = await this.explorer.withPage((page) => waitForPageReadiness(page, { timeout: remaining, spinnerSelectors: this.config.playwright.spinnerSelectors }));
327
318
  await this.explorer.withPage(annotatePageElements);
328
- this.actionResult = await this.explorer.capture({ screenshot: includeScreenshot });
329
- condition = detectPageCondition(this.actionResult);
330
- if (condition === 'error') {
319
+ this.actionResult = await this.explorer.capture({ screenshot: screenshot && this.provider.hasVision() });
320
+ const condition = await this.pageCondition(rendered);
321
+ if (condition === 'error')
331
322
  throw new ErrorPageError(this.actionResult.url, this.actionResult.title, this.actionResult.httpStatus);
332
- }
333
323
  if (condition === 'ok')
334
324
  return true;
325
+ tag('step').log('Page is still loading, waiting for content...');
326
+ if (!(await this.explorer.withPage((page) => waitForDomChange(page, deadline - Date.now()))))
327
+ return false;
335
328
  }
336
329
  return false;
337
330
  }
331
+ async pageCondition(rendered) {
332
+ const condition = detectPageCondition(this.actionResult);
333
+ if (condition !== 'loading')
334
+ return condition;
335
+ if (!this.judge && rendered)
336
+ return 'ok';
337
+ if (!this.judge)
338
+ return condition;
339
+ const conditions = {
340
+ 'still loading: blank, splash screen, skeleton or loading placeholder': 'loading',
341
+ 'error page: shows an error instead of the requested content': 'error',
342
+ 'loaded: the main content is rendered': 'ok',
343
+ };
344
+ const decision = await this.judge.decide('The page was blank or barely rendered when it opened. What state is it in now?', [...Object.keys(conditions), UNDECIDED], pageEvidence(this.actionResult));
345
+ return conditions[decision.value ?? ''] || 'ok';
346
+ }
347
+ async hasHiddenContent() {
348
+ const captured = await this.explorer.capture({ screenshot: true });
349
+ const hidden = await this.provider.oracle(HIDDEN_CONTENT_STATEMENT, pageEvidence(captured), { agentName: 'researcher', screenshot: captured.screenshot, telemetry: { functionId: 'researcher.hasHiddenContent' } });
350
+ if (hidden === null) {
351
+ tag('warning').log('Could not check the page for hidden content, running deep analysis');
352
+ return true;
353
+ }
354
+ if (!hidden)
355
+ tag('step').log('Nothing to expand on this page, skipping deep analysis');
356
+ return hidden;
357
+ }
338
358
  getConfiguredSections() {
339
359
  const configSections = this.settings.sections;
340
360
  if (!configSections?.length)
@@ -41,7 +41,7 @@ const locatorStrategyRule = dedent `
41
41
  3. Use ARIA snapshot to identify element state (focused, visible, selected, expanded, etc.)
42
42
  4. Match semantic proximity - elements near fields you just interacted with
43
43
 
44
- Once the correct element is identified pick the best unique locator following priority: ARIA → CSS → XPath
44
+ Once the correct element is identified pick the best unique locator following priority: ARIA → text → CSS → XPath
45
45
  </disambiguation>
46
46
 
47
47
  ARIA locators must specify role. Specify locator type as JSON string with role and text keys.
@@ -97,7 +97,7 @@ const locatorStrategyRule = dedent `
97
97
  <xpath_rules>
98
98
  XPath locators must start with //.
99
99
  XPath may use positional indices [1], [2] and contains(., "text") as a last-resort disambiguator when attribute/text strategies are exhausted.
100
- XPath should rely less on class names — prefer element hierarchy, position, and text content.
100
+ XPath should rely less on class names — prefer element hierarchy, attributes, and text content.
101
101
  XPath and CSS MUST provide different strategies for finding the same element.
102
102
  </xpath_rules>
103
103
 
@@ -114,7 +114,7 @@ const locatorStrategyRule = dedent `
114
114
 
115
115
  <bad locator example>
116
116
  'a.filter-tab:nth-of-type(1)' // WRONG: positional in CSS, use :has-text("Active") instead
117
- '//a[contains(@class,"filter-tab") and contains(@class,"active")]' // WRONG: XPath repeats CSS approach, provide a different strategy e.g. //a[contains(@class,"filter-tab")][1]
117
+ '//a[contains(@class,"filter-tab") and contains(@class,"active")]' // WRONG: XPath repeats CSS approach, provide a different strategy e.g. //a[normalize-space()="Active"]
118
118
  '//table//tbody/tr[1]//button[contains(@onclick,'fn()')]' // onclick is not semantic attribute
119
119
  '//html/body/vue-button-123' // vue-framework specific locator
120
120
  'link "New Item"' // WRONG: malformed string, use {"role":"link","text":"New Item"}
@@ -212,7 +212,7 @@ export const focusedElementRule = dedent `
212
212
  When a text input element is focused (textbox, combobox, contenteditable):
213
213
 
214
214
  To CLEAR the field before typing:
215
- - I.pressKey(['Meta', 'a']); // Select all (use 'Control' on Windows/Linux)
215
+ - I.pressKey(['CommandOrControl', 'a']); // Select all
216
216
  - I.pressKey('Backspace'); // Delete selection
217
217
 
218
218
  To TYPE into the focused element:
@@ -402,8 +402,7 @@ export const actionRule = dedent `
402
402
  I.pressKey('Escape'); // Press Escape key
403
403
  I.pressKey('Tab'); // Press Tab key
404
404
  I.pressKey('Backspace'); // Press Backspace key
405
- I.pressKey(['Control', 'a']); // Select all (Ctrl+A)
406
- I.pressKey(['Meta', 'a']); // Select all on Mac (Cmd+A)
405
+ I.pressKey(['CommandOrControl', 'a']); // Select all
407
406
  I.pressKey(['Shift', 'Tab']); // Shift+Tab to go back
408
407
  I.pressKey('ArrowDown'); // Navigate dropdown options
409
408
  </example>
@@ -500,7 +499,7 @@ export const actionRule = dedent `
500
499
  I.seeClipboardEquals('ABC-123');
501
500
  </example>
502
501
 
503
- [DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
502
+ [Prefer the commands listed here; use other CodeceptJS I.* commands when none of these fits]
504
503
  [INTERACT ONLY WITH ELEMENTS THAT ARE ON THE PAGE HTML]
505
504
  [DO NOT USE WAIT FUNCTIONS]
506
505
 
@@ -12,10 +12,20 @@ export declare class Scout implements Agent {
12
12
  runSession(corpus: ScoutCorpus, query: ScoutQuery): Promise<string | null>;
13
13
  buildSystemPrompt(toolNames: string[], corpus: ScoutCorpus, query: ScoutQuery, scanner: 'rg' | 'grep'): string;
14
14
  buildTaskPrompt(query: ScoutQuery): string;
15
+ saveDigest(query: ScoutQuery, digest: string): string;
16
+ digestPath(query: ScoutQuery): string;
17
+ loadCachedDigest(query: ScoutQuery): string | null;
15
18
  }
16
- export interface ScoutQuery {
19
+ export declare class ScoutQuery {
17
20
  url?: string;
18
21
  title?: string;
19
22
  feature?: string;
20
- excludeUrls: string[];
23
+ excludeUrls?: string[];
24
+ constructor(init?: Partial<ScoutQuery>);
25
+ static fromState(state: {
26
+ url?: string;
27
+ title?: string;
28
+ }): ScoutQuery;
29
+ get exclude(): string[] | undefined;
30
+ set exclude(value: string[] | undefined);
21
31
  }
@@ -1,11 +1,18 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
1
4
  import dedent from 'dedent';
5
+ import matter from 'gray-matter';
6
+ import { outputPath } from "../config.js";
2
7
  import { tag } from "../utils/logger.js";
3
8
  import { loop } from "../utils/loop.js";
9
+ import { sanitizeFilename } from "../utils/strings.js";
4
10
  import { createScoutTools, excludeCorpusUrls } from "./scout/tools.js";
5
11
  const MAX_ITERATIONS = 3;
6
12
  const MAX_TOOL_ROUNDTRIPS = 5;
7
13
  const CACHE_LIMIT = 40;
8
14
  const URL_LISTING_LIMIT = 40;
15
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
9
16
  export class Scout {
10
17
  provider;
11
18
  corpus;
@@ -21,11 +28,18 @@ export class Scout {
21
28
  async collectDocs(query) {
22
29
  if (!this.isAvailable())
23
30
  return '';
24
- const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
31
+ const cacheKey = `${query.url || ''}|${query.feature || ''}|${(query.excludeUrls || []).join(',')}`;
25
32
  const cached = this.cache.get(cacheKey);
26
33
  if (cached !== undefined)
27
34
  return cached;
28
- const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls);
35
+ const fileCached = this.loadCachedDigest(query);
36
+ if (fileCached !== null) {
37
+ if (this.cache.size > CACHE_LIMIT)
38
+ this.cache.clear();
39
+ this.cache.set(cacheKey, fileCached);
40
+ return fileCached;
41
+ }
42
+ const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls || []);
29
43
  if (corpus.files.length === 0)
30
44
  return '';
31
45
  const result = await this.runSession(corpus, query);
@@ -76,9 +90,14 @@ export class Scout {
76
90
  return null;
77
91
  const digest = getResult();
78
92
  if (digest) {
79
- const preview = digest.slice(0, 600);
80
- const ellipsis = digest.length > 600 ? '…' : '';
81
- tag('info').log(`Scout digest:\n${preview}${ellipsis}`);
93
+ const filePath = this.saveDigest(query, digest);
94
+ const words = digest.split(/\s+/).filter(Boolean).length;
95
+ tag('info').log(`Scout found relevant docs: ${words} words → ${filePath}`);
96
+ const lines = digest.split('\n').slice(0, 5).join('\n');
97
+ tag('substep').log(lines);
98
+ }
99
+ else {
100
+ tag('info').log(`Scout found no relevant docs for ${query.feature || query.url || 'the current page'}`);
82
101
  }
83
102
  return digest;
84
103
  }
@@ -114,7 +133,7 @@ export class Scout {
114
133
  ${pagesListing}
115
134
 
116
135
  These pages are already provided to the planner in full — do not re-report them:
117
- ${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
136
+ ${(query.excludeUrls || []).map((url) => `- ${url}`).join('\n') || '- none'}
118
137
 
119
138
  AVAILABLE TOOLS:
120
139
  ${toolNames.join(', ')}.
@@ -129,12 +148,32 @@ export class Scout {
129
148
  2. Read the files whose hits look most relevant
130
149
  3. Report the digest as your final message — no tool call is needed to finish
131
150
 
132
- RULES:
151
+ <rules>
133
152
  - Report only what the documentation states. Never fill gaps with assumptions about the application
134
153
  - Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
135
154
  - Name the page URL each item belongs to, so scenarios anchor to real routes
136
- - Explore briefly: a few scans and reads are enough, then report
137
155
  - A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
156
+ - For vague terms such as valid, supported, recent, all, or available, seek exact definitions and provide example of valid/invalid values.
157
+ - Follow relevant references for missing rules, shared policies, exceptions, and conflicting claims.
158
+ </rules>
159
+
160
+ Look for facts we need to collect for testing the related feature:
161
+
162
+ <facts>
163
+ - **Actors/access:** Find exact roles, permissions, ownership, scope, and denial behavior. Distinguish hidden, disabled, forbidden, and redirected.
164
+ - **Prerequisites:** Find required data, settings, integrations, plans, flags, and dependencies.
165
+ - **Actions/navigation:** Find entry points, available operations, destinations, confirmations, and cancellation behavior.
166
+ - **Inputs/validation:** Find accepted values, formats, defaults, required fields, exact limits/units, normalization, and error messages. Distinguish omitted, empty, whitespace, and unchanged.
167
+ - **Duplicates:** Find identity rules, uniqueness scope, and whether matches are rejected, reused, merged, or updated.
168
+ - **Conditions/states:** Find guards, exceptions, state transitions, prohibited actions, and intentional no-ops.
169
+ - **Outcomes:** Find visible feedback, persistent changes, side effects, and subsequent places where results appear.
170
+ - **Failures:** Find retained changes, rollback, partial success, recovery, and retry behavior. Never assume an error means nothing changed.
171
+ - **Async/time:** Find start versus completion, progress indicators, refresh behavior, delays, expiry, schedules, and retry limits.
172
+ - **Lists/bulk actions:** Find search/filter semantics, sorting, pagination, count definitions, selection scope, and skipped-item behavior.
173
+ - **Relationships/deletion:** Find inheritance, cascades, copy versus reference, unlink versus delete, restoration, and irreversible effects.
174
+ - **Files/integrations:** Find supported formats, mappings, processing limits, sync direction, overwrite precedence, and unavailable-service behavior.
175
+ - **Variants:** Find differences by role, plan, configuration, deployment, browser, or channel. Keep browser and API behavior separate.
176
+ </facts>
138
177
  `;
139
178
  const customPrompt = this.provider.getSystemPromptForAgent('scout');
140
179
  if (customPrompt)
@@ -150,7 +189,63 @@ export class Scout {
150
189
  Report the documented capabilities, states and transitions a test planner could turn into scenarios.
151
190
  `;
152
191
  }
192
+ saveDigest(query, digest) {
193
+ const filePath = this.digestPath(query);
194
+ const dir = outputPath('scout');
195
+ if (!existsSync(dir))
196
+ mkdirSync(dir, { recursive: true });
197
+ const frontmatter = {};
198
+ if (query.url)
199
+ frontmatter.url = query.url;
200
+ if (query.feature)
201
+ frontmatter.feature = query.feature;
202
+ if (query.excludeUrls?.length)
203
+ frontmatter.excludeUrls = query.excludeUrls;
204
+ frontmatter.cachedAt = new Date().toISOString();
205
+ const content = matter.stringify(digest, frontmatter);
206
+ writeFileSync(filePath, content, 'utf8');
207
+ tag('data').log('scout', { path: filePath, content: digest });
208
+ return filePath;
209
+ }
210
+ digestPath(query) {
211
+ const cacheKey = `${query.url || ''}|${query.feature || ''}|${(query.excludeUrls || []).join(',')}`;
212
+ const hash = createHash('sha1').update(cacheKey).digest('hex').slice(0, 8);
213
+ const raw = query.feature || query.url || 'general';
214
+ const name = sanitizeFilename(raw) || 'general';
215
+ return join(outputPath('scout'), `digest-${name}-${hash}.md`);
216
+ }
217
+ loadCachedDigest(query) {
218
+ const filePath = this.digestPath(query);
219
+ if (!existsSync(filePath))
220
+ return null;
221
+ if (Date.now() - statSync(filePath).mtimeMs > CACHE_TTL_MS)
222
+ return null;
223
+ const content = matter(readFileSync(filePath, 'utf8')).content;
224
+ const words = content.split(/\s+/).filter(Boolean).length;
225
+ tag('info').log(`Scout found previous spec digest in ${relative(process.cwd(), filePath)}`);
226
+ tag('multiline').log(content);
227
+ return content;
228
+ }
153
229
  }
154
230
  function toPosix(path) {
155
231
  return path.split('\\').join('/');
156
232
  }
233
+ export class ScoutQuery {
234
+ url;
235
+ title;
236
+ feature;
237
+ excludeUrls;
238
+ constructor(init) {
239
+ if (init)
240
+ Object.assign(this, init);
241
+ }
242
+ static fromState(state) {
243
+ return new ScoutQuery({ url: state.url, title: state.title });
244
+ }
245
+ get exclude() {
246
+ return this.excludeUrls;
247
+ }
248
+ set exclude(value) {
249
+ this.excludeUrls = value;
250
+ }
251
+ }
@@ -3,7 +3,6 @@ import type { RequestStore } from '../api/request-store.js';
3
3
  import type { TestRun } from '../explorer.js';
4
4
  import { type Test } from '../test-plan.js';
5
5
  import type { Agent, AgentDeps } from './agent.js';
6
- import type { Captain } from './captain.js';
7
6
  import type { Conversation } from './conversation.js';
8
7
  import { Navigator } from './navigator.js';
9
8
  import type { Pilot } from './pilot.js';
@@ -18,7 +17,7 @@ export declare class Tester extends TaskAgent implements Agent {
18
17
  testRun: TestRun | null;
19
18
  currentConversation: Conversation | null;
20
19
  pilot: Pilot | null;
21
- captain: Captain | null;
20
+ supervisor: Supervisor | null;
22
21
  MAX_ITERATIONS: number;
23
22
  MAX_EXTENSIONS: number;
24
23
  ASSERTION_TOOLS: string[];
@@ -40,7 +39,7 @@ export declare class Tester extends TaskAgent implements Agent {
40
39
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
41
40
  getNavigator(): Navigator;
42
41
  setPilot(pilot: Pilot): void;
43
- setCaptain(captain: Captain): void;
42
+ setSupervisor(supervisor: Supervisor): void;
44
43
  getCurrentState(): ActionResult;
45
44
  get progressCheckInterval(): number;
46
45
  getConversation(): Conversation | null;
@@ -65,7 +64,7 @@ export declare class Tester extends TaskAgent implements Agent {
65
64
  getDeletableSessionNames(task: Test): string[];
66
65
  buildAvailableFiles(): string;
67
66
  buildDeletionScope(task: Test): string;
68
- createTestFlowTools(task: Test, currentState: ActionResult, conversation: Conversation): {
67
+ createTestFlowTools(task: Test, conversation: Conversation): {
69
68
  reset: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
70
69
  reason?: string;
71
70
  }, any, import("@ai-sdk/provider-utils").Context>>;
@@ -92,12 +91,17 @@ export declare class Tester extends TaskAgent implements Agent {
92
91
  }, import("@ai-sdk/provider-utils").Context>>;
93
92
  };
94
93
  handleLoopError(task: Test, error: unknown): Promise<'continue' | 'stop'>;
95
- shouldStopAfterStalledLoopError(task: Test): boolean;
94
+ shouldStopAfterStall(task: Test, note: string): boolean;
96
95
  cleanupStartedTest(task: Test): Promise<void>;
97
96
  }
98
97
  interface TestSessionHandlers {
99
98
  offFailedRequest?: () => void;
100
99
  }
100
+ export type Supervisor = (input: string, test: Test) => Promise<SupervisorAction>;
101
+ export interface SupervisorAction {
102
+ action: 'inject' | 'stop' | 'pass' | 'skip';
103
+ message: string;
104
+ }
101
105
  export interface TestOptions {
102
106
  startOnCurrentPage?: boolean;
103
107
  deadline?: number;
@@ -9,10 +9,12 @@ import { Observability } from "../observability.js";
9
9
  import { normalizeUrl } from "../state-manager.js";
10
10
  import { Stats } from "../stats.js";
11
11
  import { TestResult } from "../test-plan.js";
12
- import { ErrorPageError, isErrorPage } from "../utils/error-page.js";
12
+ import { ErrorPageError, TestStartBlockedError, isErrorPage } from "../utils/error-page.js";
13
13
  import { createDebug, tag } from "../utils/logger.js";
14
14
  import { loop } from "../utils/loop.js";
15
15
  import { compactErrorMessage } from "../utils/strings.js";
16
+ import { isSamePageIgnoringQuery } from "../utils/url-matcher.js";
17
+ import { pageEvidence } from "./judge.js";
16
18
  import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from "./rules.js";
17
19
  import { TaskAgent } from "./task-agent.js";
18
20
  import { createCodeceptJSTools, createIframeTools, withdrawVisionTools } from "./tools.js";
@@ -36,7 +38,7 @@ export class Tester extends TaskAgent {
36
38
  testRun = null;
37
39
  currentConversation = null;
38
40
  pilot = null;
39
- captain = null;
41
+ supervisor = null;
40
42
  MAX_ITERATIONS = 30;
41
43
  MAX_EXTENSIONS = 2;
42
44
  ASSERTION_TOOLS = ['verify'];
@@ -73,8 +75,8 @@ export class Tester extends TaskAgent {
73
75
  setPilot(pilot) {
74
76
  this.pilot = pilot;
75
77
  }
76
- setCaptain(captain) {
77
- this.captain = captain;
78
+ setSupervisor(supervisor) {
79
+ this.supervisor = supervisor;
78
80
  }
79
81
  getCurrentState() {
80
82
  return ActionResult.fromState(this.stateManager.getCurrentState());
@@ -102,7 +104,6 @@ export class Tester extends TaskAgent {
102
104
  this.previousRegionPresent = null;
103
105
  this.regionTransitioned = false;
104
106
  this.stateManager.clearHistory();
105
- this.resetFailureCount();
106
107
  this.pilot?.reset();
107
108
  const requestStore = this.requestStore;
108
109
  requestStore.clear();
@@ -132,6 +133,11 @@ export class Tester extends TaskAgent {
132
133
  offFailedRequest?.();
133
134
  return await this.abortStartedTestOnErrorPage(task, initialState);
134
135
  }
136
+ const startEvidence = { ...pageEvidence(initialState), scenario: task.scenario, expected: task.expected, plannedSteps: task.plannedSteps, notes: task.notesToString() };
137
+ if ((await this.judge?.assert('This is the right page to start the test', startEvidence)) === false) {
138
+ offFailedRequest?.();
139
+ throw new TestStartBlockedError(task.scenario, task.startUrl, initialState.url || '');
140
+ }
135
141
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'tester');
136
142
  conversation.markLastMessageCacheable();
137
143
  this.currentConversation = conversation;
@@ -225,7 +231,7 @@ export class Tester extends TaskAgent {
225
231
  let shouldContinue = true;
226
232
  while (shouldContinue) {
227
233
  shouldContinue = false;
228
- await loop(async ({ stop, pause, iteration, userInput }) => {
234
+ await loop(async ({ stop, iteration, userInput }) => {
229
235
  if (opts.deadline != null && Date.now() >= opts.deadline) {
230
236
  deadlineReached = true;
231
237
  task.addNote('Time budget reached. Stopped');
@@ -233,16 +239,10 @@ export class Tester extends TaskAgent {
233
239
  return;
234
240
  }
235
241
  debugLog('iteration', iteration);
236
- if (!(await this.explorer.recover()).ok) {
237
- task.addNote('Browser page is unavailable');
238
- task.finish(TestResult.FAILED);
239
- stop();
240
- return;
241
- }
242
242
  const currentState = this.getCurrentState();
243
243
  const tools = {
244
244
  ...codeceptjsTools,
245
- ...this.createTestFlowTools(task, currentState, conversation),
245
+ ...this.createTestFlowTools(task, conversation),
246
246
  ...this.agentTools,
247
247
  };
248
248
  if (currentState.isInsideIframe) {
@@ -275,7 +275,7 @@ export class Tester extends TaskAgent {
275
275
  conversation.cleanupTag('page_ui_map_overlay', '...cleaned UI overlay...', 1);
276
276
  conversation.compactToolResults(2);
277
277
  if (iteration > 1) {
278
- const isNewPage = this.previousUrl !== null && this.previousUrl !== currentState.url;
278
+ const isNewPage = this.previousUrl !== null && !isSamePageIgnoringQuery(this.previousUrl, currentState.url);
279
279
  let nextStep = '';
280
280
  nextStep += await this.reinjectContextIfNeeded(iteration, currentState);
281
281
  nextStep += await this.prepareInstructionsForNextStep(task);
@@ -294,7 +294,6 @@ export class Tester extends TaskAgent {
294
294
  }
295
295
  if (guidance)
296
296
  nextStep += `\n\n${guidance}`;
297
- this.consecutiveFailures = 0;
298
297
  this.lastAnalyzedStateHash = currentState.hash;
299
298
  }
300
299
  conversation.addUserText(nextStep);
@@ -315,12 +314,6 @@ export class Tester extends TaskAgent {
315
314
  const actionPerformed = !!allToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
316
315
  assertionPerformed = !!successfulToolNames.find((toolName) => this.ASSERTION_TOOLS.includes(toolName));
317
316
  const wasSuccessful = result?.toolExecutions?.every((execution) => execution.wasSuccessful);
318
- this.trackToolExecutions(result?.toolExecutions || []);
319
- if (this.consecutiveEmptyResults >= 5) {
320
- task.addNote('AI model is not responding with actions. Stopped');
321
- stop();
322
- return;
323
- }
324
317
  if (actionPerformed && !wasSuccessful) {
325
318
  const noted = new Set(Object.values(task.notes).map((note) => note.message));
326
319
  result?.toolExecutions
@@ -372,11 +365,11 @@ export class Tester extends TaskAgent {
372
365
  }, {
373
366
  maxAttempts: this.MAX_ITERATIONS,
374
367
  interruptPrompt: 'Test interrupted. Enter new instruction (or "stop" to cancel):',
375
- onInterrupt: this.captain
368
+ onInterrupt: this.supervisor
376
369
  ? async (userInput, context) => {
377
370
  if (!userInput)
378
371
  return;
379
- const result = await this.captain.processSupervisorInterrupt(userInput, task);
372
+ const result = await this.supervisor(userInput, task);
380
373
  tag('info').log(`🧑‍✈️ Supervisor: ${result.action} — ${result.message}`);
381
374
  const terminalResults = {
382
375
  stop: TestResult.FAILED,
@@ -450,11 +443,9 @@ export class Tester extends TaskAgent {
450
443
  return true;
451
444
  }
452
445
  get isStruggling() {
453
- return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
446
+ return this.stalledIterations >= this.MAX_STALLED_ITERATIONS - 1;
454
447
  }
455
448
  shouldStopForStalledExecution(task, previousState, toolExecutions) {
456
- if (task.hasFinished)
457
- return false;
458
449
  const currentState = this.getCurrentState();
459
450
  const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
460
451
  const actionTools = [...this.ACTION_TOOLS, ...this.DELEGATED_ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
@@ -464,14 +455,7 @@ export class Tester extends TaskAgent {
464
455
  this.stalledIterations = 0;
465
456
  return false;
466
457
  }
467
- const hasNoBrowserProgress = toolExecutions.length === 0 || toolExecutions.every((execution) => !actionTools.includes(execution.toolName) || !execution.wasSuccessful);
468
- if (!hasNoBrowserProgress)
469
- return false;
470
- this.stalledIterations++;
471
- if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
472
- return false;
473
- task.addNote('No further browser progress on unchanged page; requesting final review');
474
- return true;
458
+ return this.shouldStopAfterStall(task, 'No further browser progress on unchanged page; requesting final review');
475
459
  }
476
460
  async prepareInstructionsForNextStep(task) {
477
461
  let outcomeStatus = dedent `
@@ -871,9 +855,8 @@ export class Tester extends TaskAgent {
871
855
  }
872
856
  return '';
873
857
  }
874
- createTestFlowTools(task, currentState, conversation) {
858
+ createTestFlowTools(task, conversation) {
875
859
  const resetUrl = task.startUrl;
876
- const visitedUrls = task.getVisitedUrls();
877
860
  return {
878
861
  reset: tool({
879
862
  description: dedent `
@@ -1070,8 +1053,6 @@ export class Tester extends TaskAgent {
1070
1053
  task.finish(hasPassed ? TestResult.PASSED : TestResult.FAILED);
1071
1054
  }
1072
1055
  }
1073
- const remainingExpectations = task.getRemainingExpectations();
1074
- // const suggestion = input.status !== null && remainingExpectations.length > 0 ? `Continue testing to check the remaining expected outcomes: ${remainingExpectations.join(', ')}` : 'Continue with your testing strategy based on these findings.';
1075
1056
  return {
1076
1057
  success: true,
1077
1058
  action: 'record',
@@ -1095,24 +1076,23 @@ export class Tester extends TaskAgent {
1095
1076
  return 'stop';
1096
1077
  }
1097
1078
  if (result.recovered) {
1098
- this.resetFailureCount();
1099
1079
  this.previousUrl = null;
1100
1080
  this.previousStateHash = null;
1101
1081
  this.stalledIterations = 0;
1102
1082
  }
1103
- else if (this.shouldStopAfterStalledLoopError(task)) {
1083
+ else if (this.shouldStopAfterStall(task, 'No browser progress after repeated execution errors; requesting final review')) {
1104
1084
  return 'stop';
1105
1085
  }
1106
1086
  this.currentConversation?.addUserText(result.message);
1107
1087
  return 'continue';
1108
1088
  }
1109
- shouldStopAfterStalledLoopError(task) {
1089
+ shouldStopAfterStall(task, note) {
1110
1090
  if (task.hasFinished)
1111
1091
  return false;
1112
1092
  this.stalledIterations++;
1113
1093
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
1114
1094
  return false;
1115
- task.addNote('No browser progress after repeated execution errors; requesting final review');
1095
+ task.addNote(note);
1116
1096
  return true;
1117
1097
  }
1118
1098
  async cleanupStartedTest(task) {