explorbot 0.5.10 → 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 (151) hide show
  1. package/bin/explorbot-cli.ts +494 -482
  2. package/boat/api-tester/src/ai/curler.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +4 -4
  4. package/boat/doc-collector/src/screenshots.ts +1 -1
  5. package/dist/bin/explorbot-cli.js +455 -444
  6. package/dist/boat/api-tester/src/ai/curler.js +1 -1
  7. package/dist/boat/api-tester/src/cli.js +4 -4
  8. package/dist/models.json +1 -1
  9. package/dist/package.json +2 -1
  10. package/dist/rules/navigator/output.md +2 -8
  11. package/dist/rules/navigator/verification-actions.md +6 -2
  12. package/dist/rules/planner/ignored-pages.md +1 -0
  13. package/dist/src/action-result.js +1 -1
  14. package/dist/src/action.d.ts +0 -1
  15. package/dist/src/action.js +0 -4
  16. package/dist/src/ai/captain/idle-mode.js +42 -26
  17. package/dist/src/ai/captain/mixin.d.ts +2 -0
  18. package/dist/src/ai/captain/web-mode.js +11 -0
  19. package/dist/src/ai/captain.d.ts +6 -4
  20. package/dist/src/ai/captain.js +25 -1
  21. package/dist/src/ai/driller.js +1 -1
  22. package/dist/src/ai/experience-compactor.js +2 -2
  23. package/dist/src/ai/judge.d.ts +7 -0
  24. package/dist/src/ai/judge.js +8 -1
  25. package/dist/src/ai/navigator.d.ts +1 -0
  26. package/dist/src/ai/navigator.js +11 -7
  27. package/dist/src/ai/planner/subpages.js +1 -1
  28. package/dist/src/ai/planner.d.ts +12 -5
  29. package/dist/src/ai/planner.js +138 -86
  30. package/dist/src/ai/provider.d.ts +1 -1
  31. package/dist/src/ai/provider.js +16 -5
  32. package/dist/src/ai/rerunner.js +1 -1
  33. package/dist/src/ai/researcher/cache.js +14 -6
  34. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
  35. package/dist/src/ai/researcher/deep-analysis.js +7 -2
  36. package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
  37. package/dist/src/ai/researcher.js +44 -24
  38. package/dist/src/ai/rules.js +6 -7
  39. package/dist/src/ai/scout.d.ts +12 -2
  40. package/dist/src/ai/scout.js +103 -8
  41. package/dist/src/ai/tester.d.ts +9 -5
  42. package/dist/src/ai/tester.js +22 -42
  43. package/dist/src/ai/tools.js +10 -8
  44. package/dist/src/application-spec.d.ts +2 -0
  45. package/dist/src/application-spec.js +17 -12
  46. package/dist/src/commands/context-experience-command.js +0 -1
  47. package/dist/src/commands/context-knowledge-command.js +0 -1
  48. package/dist/src/commands/explore-command.d.ts +3 -2
  49. package/dist/src/commands/explore-command.js +35 -23
  50. package/dist/src/commands/help-json-command.js +3 -1
  51. package/dist/src/commands/init-command.js +4 -4
  52. package/dist/src/commands/learn-command.js +0 -2
  53. package/dist/src/commands/plan-command.d.ts +3 -0
  54. package/dist/src/commands/plan-command.js +43 -17
  55. package/dist/src/commands/plan-save-command.js +6 -17
  56. package/dist/src/commands/plans-command.js +1 -1
  57. package/dist/src/commands/test-command.js +12 -2
  58. package/dist/src/components/App.d.ts +1 -2
  59. package/dist/src/components/App.js +1 -3
  60. package/dist/src/components/InputPane.js +0 -5
  61. package/dist/src/config.d.ts +3 -2
  62. package/dist/src/config.js +2 -12
  63. package/dist/src/explorbot.d.ts +3 -2
  64. package/dist/src/explorbot.js +32 -10
  65. package/dist/src/explorer.js +6 -7
  66. package/dist/src/knowledge-tracker.d.ts +2 -2
  67. package/dist/src/knowledge-tracker.js +18 -6
  68. package/dist/src/playwright-recorder.d.ts +0 -1
  69. package/dist/src/playwright-recorder.js +0 -2
  70. package/dist/src/reporter.d.ts +1 -1
  71. package/dist/src/reporter.js +3 -3
  72. package/dist/src/test-plan.js +1 -1
  73. package/dist/src/utils/error-page.d.ts +17 -0
  74. package/dist/src/utils/error-page.js +53 -2
  75. package/dist/src/utils/html-diff.d.ts +1 -2
  76. package/dist/src/utils/html-diff.js +4 -44
  77. package/dist/src/utils/logger.js +27 -10
  78. package/dist/src/utils/markdown-terminal.js +1 -2
  79. package/dist/src/utils/next-steps.js +20 -16
  80. package/dist/src/utils/page-readiness.d.ts +2 -1
  81. package/dist/src/utils/page-readiness.js +24 -5
  82. package/dist/src/utils/test-plan-markdown.d.ts +3 -0
  83. package/dist/src/utils/test-plan-markdown.js +35 -4
  84. package/dist/src/utils/url-matcher.d.ts +1 -0
  85. package/dist/src/utils/url-matcher.js +5 -0
  86. package/docs/basics/providers.md +1 -1
  87. package/docs/contributing/regression-tests.md +19 -4
  88. package/docs/reference/commands.md +6 -2
  89. package/docs/reference/configuration.md +1 -1
  90. package/docs/web-testing/agents.md +8 -0
  91. package/docs/web-testing/customization.md +2 -0
  92. package/docs/web-testing/researcher.md +1 -1
  93. package/models.json +1 -1
  94. package/package.json +2 -1
  95. package/rules/navigator/output.md +2 -8
  96. package/rules/navigator/verification-actions.md +6 -2
  97. package/rules/planner/ignored-pages.md +1 -0
  98. package/src/action-result.ts +1 -2
  99. package/src/action.ts +0 -5
  100. package/src/ai/captain/idle-mode.ts +38 -26
  101. package/src/ai/captain/mixin.ts +2 -1
  102. package/src/ai/captain/web-mode.ts +12 -0
  103. package/src/ai/captain.ts +25 -7
  104. package/src/ai/driller.ts +1 -2
  105. package/src/ai/experience-compactor.ts +2 -2
  106. package/src/ai/judge.ts +10 -1
  107. package/src/ai/navigator.ts +12 -7
  108. package/src/ai/planner/subpages.ts +1 -2
  109. package/src/ai/planner.ts +140 -92
  110. package/src/ai/provider.ts +15 -6
  111. package/src/ai/rerunner.ts +1 -2
  112. package/src/ai/researcher/cache.ts +16 -7
  113. package/src/ai/researcher/deep-analysis.ts +7 -2
  114. package/src/ai/researcher/fingerprint-worker.ts +5 -5
  115. package/src/ai/researcher.ts +44 -28
  116. package/src/ai/rules.ts +6 -7
  117. package/src/ai/scout.ts +98 -10
  118. package/src/ai/tester.ts +29 -47
  119. package/src/ai/tools.ts +11 -9
  120. package/src/application-spec.ts +19 -13
  121. package/src/commands/clean-command.ts +1 -1
  122. package/src/commands/context-command.ts +0 -1
  123. package/src/commands/context-experience-command.ts +0 -1
  124. package/src/commands/context-knowledge-command.ts +0 -1
  125. package/src/commands/explore-command.ts +36 -23
  126. package/src/commands/help-json-command.ts +8 -6
  127. package/src/commands/init-command.ts +5 -3
  128. package/src/commands/learn-command.ts +0 -2
  129. package/src/commands/plan-command.ts +44 -17
  130. package/src/commands/plan-save-command.ts +6 -17
  131. package/src/commands/plans-command.ts +1 -1
  132. package/src/commands/test-command.ts +11 -2
  133. package/src/components/App.tsx +2 -6
  134. package/src/components/InputPane.tsx +0 -5
  135. package/src/config.ts +4 -16
  136. package/src/experience-tracker.ts +1 -1
  137. package/src/explorbot.ts +31 -11
  138. package/src/explorer.ts +6 -7
  139. package/src/knowledge-tracker.ts +18 -7
  140. package/src/playwright-recorder.ts +0 -2
  141. package/src/reporter.ts +3 -4
  142. package/src/test-plan.ts +1 -2
  143. package/src/utils/error-page.ts +64 -2
  144. package/src/utils/html-diff.ts +4 -56
  145. package/src/utils/html.ts +0 -1
  146. package/src/utils/logger.ts +30 -12
  147. package/src/utils/markdown-terminal.ts +1 -2
  148. package/src/utils/next-steps.ts +19 -17
  149. package/src/utils/page-readiness.ts +33 -7
  150. package/src/utils/test-plan-markdown.ts +36 -4
  151. package/src/utils/url-matcher.ts +6 -0
@@ -1,6 +1,7 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
+ import { pageEvidence } from '../judge.ts';
4
5
  import { actionRule, locatorRule, sectionContextRule } from '../rules.ts';
5
6
  import { createAgentTools, createCodeceptJSTools } from '../tools.ts';
6
7
  import { type Constructor, type ModeContext, debugLog } from './mixin.ts';
@@ -37,6 +38,16 @@ export function WithWebMode<T extends Constructor>(Base: T) {
37
38
  },
38
39
  }),
39
40
 
41
+ oracle: tool({
42
+ description: 'Ask a yes/no question about the current page. Much cheaper than see or context.',
43
+ inputSchema: z.object({ question: z.string() }),
44
+ execute: async ({ question }) => {
45
+ const captured = await ctx.explorBot.getExplorer().capture({ screenshot: true });
46
+ const holds = await ctx.explorBot.getProvider().oracle(question, pageEvidence(captured), { agentName: 'captain', screenshot: captured.screenshot, telemetry: { functionId: 'captain.oracle' } });
47
+ return { success: holds !== null, question, answer: holds };
48
+ },
49
+ }),
50
+
40
51
  browser: tool({
41
52
  description: dedent`
42
53
  Direct browser access via Playwright. Use for diagnostics and browser management.
@@ -157,6 +168,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
157
168
  - click() accepts array of commands to try in order — include ARIA, CSS, XPath variants
158
169
  - If click() fails with all provided commands, use visualClick() tool as fallback
159
170
  - When an action fails or result is unexpected, investigate or inform the user instead of retrying silently
171
+ - For a quick yes/no question about the page, use oracle instead of see or context
160
172
  `;
161
173
  }
162
174
  };
package/src/ai/captain.ts CHANGED
@@ -7,6 +7,7 @@ import type { ExplorBot } from '../explorbot.ts';
7
7
  import type { WebPageState } from '../state-manager.ts';
8
8
  import { Task, Test } from '../test-plan.ts';
9
9
  import { formatHeadings } from '../utils/context-formatter.ts';
10
+ import { BlockedError } from '../utils/error-page.ts';
10
11
  import { HooksRunner } from '../utils/hooks-runner.ts';
11
12
  import { startLogCapture, stopLogCapture, tag } from '../utils/logger.js';
12
13
  import { loop } from '../utils/loop.js';
@@ -21,6 +22,7 @@ import type { Navigator } from './navigator.ts';
21
22
  import type { Provider } from './provider.ts';
22
23
  import { Researcher } from './researcher.ts';
23
24
  import { TaskAgent } from './task-agent.ts';
25
+ import type { SupervisorAction } from './tester.ts';
24
26
  import { createLearnExperienceTool, withdrawVisionTools } from './tools.ts';
25
27
 
26
28
  const MAX_STEPS = 15;
@@ -35,6 +37,9 @@ export class Captain extends CaptainBase implements Agent {
35
37
  private hooksRunner: HooksRunner | null = null;
36
38
  private commandExecutor: ((cmd: string) => Promise<void>) | null = null;
37
39
  private commandDescriptions: { name: string; description: string; options: string }[] = [];
40
+ private unresolvedBlockers = new Set<string>();
41
+ private handling = false;
42
+ private blocker: BlockedError | null = null;
38
43
 
39
44
  constructor(explorBot: ExplorBot) {
40
45
  super();
@@ -123,6 +128,7 @@ export class Captain extends CaptainBase implements Agent {
123
128
  - After a successful action, if the pageDiff confirms the goal, call done() immediately — do not verify with see() or context() unless the user explicitly asked for verification
124
129
  - Prefer completing in fewer tool calls over thoroughness
125
130
  - NEVER run tests unless the user explicitly asks
131
+ - When an agent reports it is blocked, decide which agent can resolve it and delegate to that agent
126
132
  - If you are answering with information rather than completing a browser action, include the actual user-facing answer in done({ details }). Do not only say that it was shown or explained.
127
133
  ${mode === 'web' || mode === 'heal' ? this.webModeRules() : ''}
128
134
  ${mode === 'test' || mode === 'heal' ? this.testModeRules() : ''}
@@ -304,7 +310,7 @@ export class Captain extends CaptainBase implements Agent {
304
310
 
305
311
  private async tools(task: Task, onDone: (summary: string) => void) {
306
312
  const mode = this.getMode();
307
- const ctx: ModeContext = { explorBot: this.explorBot, task };
313
+ const ctx: ModeContext = { explorBot: this.explorBot, task, blocker: this.blocker };
308
314
  const core = this.coreTools(task, onDone);
309
315
  const idle = await this.idleModeTools(ctx);
310
316
 
@@ -437,6 +443,7 @@ export class Captain extends CaptainBase implements Agent {
437
443
 
438
444
  conversation.addUserText(initialPrompt);
439
445
 
446
+ this.handling = true;
440
447
  await loop(
441
448
  async ({ stop, iteration, userInput }) => {
442
449
  debugLog(`Captain iteration ${iteration}`);
@@ -510,7 +517,9 @@ export class Captain extends CaptainBase implements Agent {
510
517
  stop();
511
518
  },
512
519
  }
513
- );
520
+ ).finally(() => {
521
+ this.handling = false;
522
+ });
514
523
 
515
524
  const finalUrl = stateManager.getCurrentState()?.url || startUrl;
516
525
  await this.getHooksRunner().runAfterHook('captain', finalUrl);
@@ -523,15 +532,24 @@ export class Captain extends CaptainBase implements Agent {
523
532
 
524
533
  return null;
525
534
  }
535
+
536
+ async resolveBlocker(error: unknown, resolved: () => boolean | Promise<boolean>): Promise<boolean> {
537
+ if (!(error instanceof BlockedError) || this.handling) return false;
538
+ const key = `${error.name} ${error.url} ${error.blocker}`;
539
+ if (this.unresolvedBlockers.has(key)) return false;
540
+ tag('info').log(`Captain takes over: ${error.message}`);
541
+ this.blocker = error;
542
+ await this.handle(error.request, { reset: true }).finally(() => {
543
+ this.blocker = null;
544
+ });
545
+ if (await resolved()) return true;
546
+ this.unresolvedBlockers.add(key);
547
+ return false;
548
+ }
526
549
  }
527
550
 
528
551
  export default Captain;
529
552
 
530
- interface SupervisorAction {
531
- action: 'inject' | 'stop' | 'pass' | 'skip';
532
- message: string;
533
- }
534
-
535
553
  interface ExecutionRecoveryAction {
536
554
  action: 'continue' | 'stop';
537
555
  message: string;
package/src/ai/driller.ts CHANGED
@@ -15,7 +15,6 @@ import { eidxInContainer } from '../utils/web-eidx.ts';
15
15
  import { WebElement } from '../utils/web-element.ts';
16
16
  import type { Agent, AgentDeps } from './agent.ts';
17
17
  import type { Navigator } from './navigator.ts';
18
- import type { Provider } from './provider.ts';
19
18
  import { drillLocatorRule } from './rules.ts';
20
19
  import { TaskAgent, isInteractive } from './task-agent.ts';
21
20
  import { createCodeceptJSTools, createLearnExperienceTool } from './tools.ts';
@@ -177,7 +176,7 @@ export class Driller extends TaskAgent implements Agent {
177
176
  setActivity(`${this.emoji} Capturing annotated page state...`, 'action');
178
177
  const action = this.explorer.action();
179
178
  try {
180
- const annotated = await Promise.race([
179
+ await Promise.race([
181
180
  this.explorer.withPage(annotatePageElements),
182
181
  new Promise<never>((_, reject) => {
183
182
  setTimeout(() => reject(new Error('annotateElements timeout')), 15000);
@@ -1,5 +1,5 @@
1
1
  import { unlinkSync } from 'node:fs';
2
- import { basename } from 'node:path';
2
+ import { basename, relative } from 'node:path';
3
3
  import dedent from 'dedent';
4
4
  import { z } from 'zod';
5
5
  import { type ExperienceFile, type ExperienceTracker, RECENT_WINDOW_DAYS } from '../experience-tracker.js';
@@ -322,7 +322,7 @@ export class ExperienceCompactor implements Agent {
322
322
  };
323
323
 
324
324
  this.experienceTracker.writeExperienceFile(targetStateHash, combinedContent, newFrontmatter);
325
- log(`Merged ${group.files.length} experience files into ${targetFile.filePath}`);
325
+ log(`Merged ${group.files.length} experience files into ${relative(process.cwd(), targetFile.filePath)}`);
326
326
  debugLog('New URL pattern:', group.pattern);
327
327
 
328
328
  for (const sourceFile of sourceFiles) {
package/src/ai/judge.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { clearActivity, setActivity } from '../activity.ts';
2
+ import type { ActionResult } from '../action-result.ts';
2
3
  import type { AIConfig } from '../config.ts';
3
4
  import { Observability } from '../observability.ts';
4
- import { createDebug } from '../utils/logger.ts';
5
+ import { createDebug, tag } from '../utils/logger.ts';
5
6
  import { JudgeProvider } from './judge-provider.ts';
6
7
 
7
8
  const debugLog = createDebug('explorbot:judge');
@@ -29,6 +30,8 @@ export class Decision {
29
30
  }
30
31
 
31
32
  export class Judge {
33
+ private failureReported = false;
34
+
32
35
  constructor(
33
36
  private provider: JudgeProvider,
34
37
  private enabled: { tool: boolean; direct: boolean },
@@ -93,6 +96,8 @@ export class Judge {
93
96
 
94
97
  private recordFailure(error: unknown): null {
95
98
  debugLog('judge declined: %s', error);
99
+ if (!this.failureReported) tag('warning').log(`Decision model ${this.provider.model} failed (${error}); continuing without it`);
100
+ this.failureReported = true;
96
101
  const span = Observability.getSpan();
97
102
  span?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
98
103
  span?.setAttribute('langfuse.observation.level', 'ERROR');
@@ -100,3 +105,7 @@ export class Judge {
100
105
  return null;
101
106
  }
102
107
  }
108
+
109
+ export function pageEvidence(actionResult: ActionResult) {
110
+ return { url: actionResult.url, title: actionResult.title, page: actionResult.getCompactARIA().slice(0, JUDGE_PAGE_CAP) };
111
+ }
@@ -13,7 +13,7 @@ import { type StateManager, normalizeUrl } from '../state-manager.js';
13
13
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
14
14
  import { getCliName } from '../utils/cli-name.ts';
15
15
  import { extractCodeBlocks } from '../utils/code-extractor.js';
16
- import { isLoginPage } from '../utils/error-page.ts';
16
+ import { NavigationBlockedError, isLoginPage } from '../utils/error-page.ts';
17
17
  import { HooksRunner } from '../utils/hooks-runner.ts';
18
18
  import { createDebug, pluralize, tag } from '../utils/logger.js';
19
19
  import { loop, pause } from '../utils/loop.js';
@@ -22,7 +22,6 @@ import { normalizeInlineText } from '../utils/strings.ts';
22
22
  import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
23
23
  import type { UrlFilter } from '../utils/url-filter.ts';
24
24
  import type { Agent, AgentDeps } from './agent.js';
25
- import type { Conversation } from './conversation.js';
26
25
  import { type Decision, JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
27
26
  import type { Provider } from './provider.js';
28
27
  import { Researcher } from './researcher.ts';
@@ -31,6 +30,7 @@ import { isInteractive } from './task-agent.js';
31
30
  import { createLearnExperienceTool } from './tools.ts';
32
31
 
33
32
  const debugLog = createDebug('explorbot:navigator');
33
+ const MAX_TRIED_STEPS = 5;
34
34
 
35
35
  class Navigator implements Agent {
36
36
  emoji = '🧭';
@@ -138,6 +138,10 @@ class Navigator implements Agent {
138
138
  return matchesNavigationUrl(expectedUrl, currentUrl);
139
139
  }
140
140
 
141
+ isOn(url: string): boolean {
142
+ return this.isOnExpectedPage(url, this.stateManager);
143
+ }
144
+
141
145
  async visit(url: string, opts: { recover?: boolean } = {}): Promise<boolean> {
142
146
  return this.visitOnce(url, opts.recover !== false);
143
147
  }
@@ -195,8 +199,9 @@ class Navigator implements Agent {
195
199
  }
196
200
 
197
201
  private navigationError(url: string, fallback: string): Error {
198
- if (this.lastFailureReason) return new Error(`Navigation to ${url} failed: ${this.lastFailureReason}`);
199
- return new Error(`Navigation to ${url} failed: ${fallback}`);
202
+ const currentUrl = this.stateManager.getCurrentState()?.url || '';
203
+ const tried = this.executedSteps.slice(-MAX_TRIED_STEPS).map((step) => step.command);
204
+ return new NavigationBlockedError(url, currentUrl, this.lastFailureReason || fallback, tried);
200
205
  }
201
206
 
202
207
  private failureReason(stopReason: string | null, knowledge: string, url: string): string | null {
@@ -402,7 +407,7 @@ class Navigator implements Agent {
402
407
  Propose different solutions to achieve the result.
403
408
  Solution should be valid CodeceptJS code.
404
409
  Use only data from the <page> context to plan the solution.
405
- The page is given as an ARIA snapshot only: use ARIA and text locators from it.
410
+ The page is given as an ARIA snapshot only: use ARIA and text locators from it; propose CSS/XPath only once <page_html> is provided.
406
411
  Full HTML is provided if these solutions fail.
407
412
  Try various ways to achieve the result
408
413
  </task>
@@ -772,7 +777,7 @@ class Navigator implements Agent {
772
777
  Do not generate assertions that would pass even if the specific claim is false.
773
778
  </task>
774
779
 
775
- ${RulesLoader.loadRules('navigator', ['verification-actions'], actionResult.url || '')}
780
+ ${RulesLoader.loadRules('navigator', ['verification-actions', 'verification-output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.verifyAttempts))}
776
781
 
777
782
  ${experience}
778
783
 
@@ -858,7 +863,7 @@ class Navigator implements Agent {
858
863
 
859
864
  const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
860
865
  let verified = successfulCodes.length > 0 && !failures;
861
- if (successfulCodes.length && failures) verified = await this.provider.oracle(message, { assertions: results.map(({ code, passed, error }) => ({ code, passed, error })) }, { agentName: 'navigator', telemetry: { functionId: 'navigator.verifyState' } });
866
+ if (successfulCodes.length && failures) verified = (await this.provider.oracle(message, { assertions: results.map(({ code, passed, error }) => ({ code, passed, error })) }, { agentName: 'navigator', telemetry: { functionId: 'navigator.verifyState' } })) === true;
862
867
  if (alreadyVerified) verified = true;
863
868
 
864
869
  const inexpressible = !alreadyVerified && totalAttempted === 0;
@@ -3,7 +3,6 @@ import { z } from 'zod';
3
3
  import { normalizeUrl } from '../../state-manager.ts';
4
4
  import type { StateManager } from '../../state-manager.ts';
5
5
  import type { Plan } from '../../test-plan.ts';
6
- import { tag } from '../../utils/logger.ts';
7
6
  import { isSamePageFamily } from '../../utils/url-matcher.ts';
8
7
  import type { Provider } from '../provider.ts';
9
8
  import type { Constructor } from '../researcher/mixin.ts';
@@ -85,7 +84,7 @@ export function WithSubPages<T extends Constructor>(Base: T) {
85
84
  async pickNextSubPage(candidates: SubPageCandidate[]): Promise<{ url: string; reason: string } | null> {
86
85
  if (candidates.length === 0) return null;
87
86
 
88
- const plannedEntries = [...planRegistry.entries()].map(([key, record]) => `- ${record.url} (${record.plan.tests.length} tests${record.feature ? `, feature: ${record.feature}` : ''})`).join('\n');
87
+ const plannedEntries = [...planRegistry.entries()].map(([, record]) => `- ${record.url} (${record.plan.tests.length} tests${record.feature ? `, feature: ${record.feature}` : ''})`).join('\n');
89
88
 
90
89
  const candidateList = candidates.map((c) => `- ${c.url} (visits: ${c.visitCount}${c.title ? `, title: "${c.title}"` : ''}${c.h1 ? `, h1: "${c.h1}"` : ''})`).join('\n');
91
90
 
package/src/ai/planner.ts CHANGED
@@ -1,19 +1,19 @@
1
1
  import dedent from 'dedent';
2
- import { z } from 'zod';
3
2
  import { ActionResult } from '../action-result.ts';
4
3
  import { setActivity } from '../activity.ts';
5
4
  import { ConfigParser } from '../config.ts';
6
5
  import type { ExperienceTracker } from '../experience-tracker.ts';
7
- import type Explorer from '../explorer.ts';
8
6
  import { Observability } from '../observability.ts';
9
7
  import type { StateManager } from '../state-manager.js';
10
8
  import { Stats } from '../stats.ts';
11
9
  import { Suite } from '../suite.ts';
12
10
  import { Plan, Test } from '../test-plan.ts';
11
+ import { PlanningBlockedError } from '../utils/error-page.ts';
13
12
  import { createDebug, tag } from '../utils/logger.js';
13
+ import { loop } from '../utils/loop.ts';
14
14
  import { jsonToTable } from '../utils/markdown-parser.ts';
15
15
  import { mdq } from '../utils/markdown-query.js';
16
- import { planToCompactAiContext } from '../utils/test-plan-markdown.ts';
16
+ import { findPlanMarkdownProblems, parsePlansFromMarkdownText, planToCompactAiContext, readPlanMarkdownComment } from '../utils/test-plan-markdown.ts';
17
17
  import type { Agent, AgentDeps } from './agent.js';
18
18
  import { Conversation } from './conversation.ts';
19
19
  import type { Fisherman } from './fisherman.ts';
@@ -26,33 +26,16 @@ import { findSimilarStateHash } from './researcher/cache.ts';
26
26
  import { hasFocusedSection } from './researcher/focus.ts';
27
27
  import { RulesLoader } from '../utils/rules-loader.ts';
28
28
  import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from './rules.ts';
29
- import type { Scout } from './scout.ts';
29
+ import { ScoutQuery, type Scout } from './scout.ts';
30
30
 
31
31
  const debugLog = createDebug('explorbot:planner');
32
32
 
33
- const TasksSchema = z.object({
34
- planName: z.string().describe('Short descriptive name for the test plan (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")'),
35
- scenarios: z
36
- .array(
37
- z.object({
38
- scenario: z.string().describe('A single sentence describing the behavior to test.'),
39
- priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
40
- 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.'),
41
- 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.'),
42
- expectedOutcomes: z
43
- .array(z.string())
44
- .describe('List of expected outcomes that can be verified. Each outcome should be simple, specific, and easy to check (e.g., "Success message appears", "URL changes to /dashboard", "Form field shows error"). Keep outcomes atomic - do not combine multiple checks into one.'),
45
- })
46
- )
47
- .describe('List of testing scenarios'),
48
- notPlannableReason: z.string().nullish().describe('When scenarios is empty, one sentence saying why this page has nothing to test; otherwise null.'),
49
- });
33
+ const MAX_FORMAT_ATTEMPTS = 3;
50
34
 
51
35
  const PlannerBase = WithSessionDedup(WithSubPages(Object as unknown as new (...args: any[]) => object));
52
36
 
53
37
  export class Planner extends PlannerBase implements Agent {
54
38
  emoji = '📋';
55
- private explorer: Explorer;
56
39
  provider: Provider;
57
40
  stateManager: StateManager;
58
41
  private experienceTracker: ExperienceTracker;
@@ -60,6 +43,8 @@ export class Planner extends PlannerBase implements Agent {
60
43
 
61
44
  MIN_TASKS = 3;
62
45
  MAX_TASKS = 12;
46
+ private minTasksOverride?: number;
47
+ private maxTasksOverride?: number;
63
48
  currentPlan: Plan | null = null;
64
49
  freshStart = false;
65
50
  private lastStyleName = '';
@@ -70,7 +55,6 @@ export class Planner extends PlannerBase implements Agent {
70
55
 
71
56
  constructor(deps: AgentDeps, researcher: Researcher) {
72
57
  super();
73
- this.explorer = deps.explorer;
74
58
  this.provider = deps.ai;
75
59
  this.researcher = researcher;
76
60
  this.stateManager = deps.stateManager;
@@ -95,8 +79,23 @@ export class Planner extends PlannerBase implements Agent {
95
79
  return Math.max(0, Math.min(100, value));
96
80
  }
97
81
 
98
- private getDefaultStartUrl(state: { url: string; fullUrl?: string }): string {
99
- return state.fullUrl || state.url;
82
+ private get effectiveMinTasks(): number {
83
+ if (this.minTasksOverride !== undefined) return this.minTasksOverride;
84
+ return ConfigParser.getInstance().getConfig().ai?.agents?.planner?.minTests ?? this.MIN_TASKS;
85
+ }
86
+
87
+ private get effectiveMaxTasks(): number {
88
+ if (this.maxTasksOverride !== undefined) return this.maxTasksOverride;
89
+ return ConfigParser.getInstance().getConfig().ai?.agents?.planner?.maxTests ?? this.MAX_TASKS;
90
+ }
91
+
92
+ setTestCount(opts: { minTests?: number; maxTests?: number }): void {
93
+ if (opts.minTests !== undefined) this.minTasksOverride = opts.minTests;
94
+ if (opts.maxTests !== undefined) this.maxTasksOverride = opts.maxTests;
95
+ }
96
+
97
+ private getDefaultStartUrl(state: { url: string }): string {
98
+ return state.url;
100
99
  }
101
100
 
102
101
  getSystemMessage(feature?: string): string {
@@ -150,7 +149,7 @@ export class Planner extends PlannerBase implements Agent {
150
149
  return getRegisteredPlan(url)?.plan || null;
151
150
  }
152
151
 
153
- async plan(feature?: string, style?: string, parentPlan?: Plan, completedPlans?: Plan[]): Promise<Plan> {
152
+ async plan(feature?: string, style?: string, parentPlan?: Plan): Promise<Plan> {
154
153
  Stats.plans++;
155
154
  const state = this.stateManager.getCurrentState();
156
155
  debugLog('Planning:', state?.url);
@@ -204,22 +203,42 @@ export class Planner extends PlannerBase implements Agent {
204
203
  tag('step').log('Focusing on main content of this page');
205
204
  }
206
205
 
207
- debugLog('Sending planning prompt to AI provider with structured output');
208
-
209
- const aiResult = await this.provider.generateObject(conversation.messages, TasksSchema, conversation.model);
206
+ debugLog('Sending planning prompt to AI provider');
207
+
208
+ let markdown = '';
209
+ await loop(
210
+ async ({ stop, iteration }) => {
211
+ const response = await this.provider.chat(conversation.messages, conversation.model, { agentName: 'planner' });
212
+ markdown = response.text;
213
+ const problems = findPlanMarkdownProblems(markdown);
214
+ if (problems.length === 0) stop();
215
+ if (iteration >= MAX_FORMAT_ATTEMPTS) throw new Error(`Planner output does not match the plan format: ${problems.join('; ')}`);
216
+ debugLog('Plan format problems: %o', problems);
217
+ conversation.addAssistantText(markdown);
218
+ conversation.addUserText(dedent`
219
+ Your plan does not match the required format:
220
+ ${problems.map((problem) => `- ${problem}`).join('\n')}
221
+ Rewrite the whole plan in the format from <output_format>. Keep the same scenarios; fix only the structure.
222
+ `);
223
+ },
224
+ { maxAttempts: MAX_FORMAT_ATTEMPTS, interruptible: false }
225
+ );
210
226
 
211
- if (!aiResult?.object?.scenarios) {
212
- throw new Error('No tasks were created successfully');
227
+ const defaultStartUrl = this.getDefaultStartUrl(state);
228
+ markdown = mdq(markdown).query('h1').first().insertAfter(`### Prerequisite\n\n* URL: ${defaultStartUrl}`).toString();
229
+ const parsedPlan = parsePlansFromMarkdownText(markdown)[0];
230
+ const fromPlanning = parsedPlan?.tests || [];
231
+ const blockedBy = readPlanMarkdownComment(markdown, 'blocked');
232
+ const prerequisite = readPlanMarkdownComment(markdown, 'prerequisite');
233
+
234
+ if (!this.currentPlan && fromPlanning.length === 0 && blockedBy) {
235
+ tag('warning').log(`Page is not plannable: ${blockedBy}`);
213
236
  }
214
-
215
- if (!this.currentPlan && aiResult.object.scenarios.length === 0 && aiResult.object.notPlannableReason) {
216
- tag('warning').log(`Page is not plannable: ${aiResult.object.notPlannableReason}`);
237
+ if (!this.currentPlan && fromPlanning.length === 0 && prerequisite) {
238
+ throw new PlanningBlockedError(state.url, prerequisite, feature, style);
217
239
  }
218
240
 
219
- const defaultStartUrl = this.getDefaultStartUrl(state);
220
- const fromPlanning = aiResult.object.scenarios.map((s: any) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
221
-
222
- return { tests: fromPlanning, planName: aiResult.object.planName };
241
+ return { tests: fromPlanning, planName: parsedPlan?.title };
223
242
  });
224
243
 
225
244
  const tests = result.tests;
@@ -345,9 +364,6 @@ export class Planner extends PlannerBase implements Agent {
345
364
  private async buildConversation(state: ActionResult, style?: string, parentPlan?: Plan, feature?: string): Promise<Conversation> {
346
365
  const model = this.provider.getAgenticModel('planner');
347
366
  const conversation = new Conversation([], model);
348
- conversation.autoTrimTag('page_research', 20000);
349
- conversation.autoTrimTag('tested_scenarios', 10000);
350
- conversation.autoTrimTag('docs_context', 8000);
351
367
 
352
368
  conversation.addUserText(this.getSystemMessage(feature));
353
369
 
@@ -380,23 +396,25 @@ export class Planner extends PlannerBase implements Agent {
380
396
  If exact wording is unknown, describe the expected result generically, for example "an authentication error is shown" or "the user stays on the login page" instead of guessing the literal text.
381
397
  If exact redirect destination is unknown, describe the destination by visible page identity, for example "the dashboard page opens" or "the current workspace home page opens" instead of inventing a URL slug.
382
398
  Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
383
- If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
384
- 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.
399
+ If a scenario needs existing records or other target data, propose using api if <api_data_preparation> section is set or reuired record is visible on page or can be created via UI.
400
+ If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks UI controls skip that page.
385
401
  Do not assume hidden data exists just because a control is present.
386
402
  Do not put record IDs or unique record names in test plans. Describe which record is needed and let Pilot choose it during execution; name a specific record only when research shows a small, complete list of available records.
387
403
  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.
388
- 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.
389
- 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.
390
- 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.
391
- DO NOT propose "verification-only" tests that merely open a UI element (modal, dropdown, panel) and check it exists.
404
+
405
+ <meaningful_tests>
392
406
  Every test must complete a meaningful action that changes application state or produces a business outcome.
407
+ DO NOT propose "verification-only" tests that have only assertions and no real state change
393
408
  Opening a modal is NOT a test — performing an action INSIDE the modal IS a test.
394
409
  Clicking a dropdown is NOT a test — selecting an option and verifying the result IS a test.
410
+ </meaningful_tests>
411
+
395
412
  <priority_order>
396
413
  Tests that change application data are MORE valuable than tests that only change the UI display.
397
414
  Prioritize interactions that create, update, or delete data — these test real application behavior.
398
415
  Tests that only switch views, toggle filters, or paginate are LESS valuable — propose them only after data-changing tests are covered.
399
416
  If multiple ways to create or modify data exist (different types, different forms), propose a separate test for each.
417
+ If <docs_content> is provided make sure you incorporate app knowledge into planning
400
418
  </priority_order>
401
419
  ${capabilityGroundingRule}
402
420
  ${dataProtectionRules}
@@ -406,28 +424,8 @@ export class Planner extends PlannerBase implements Agent {
406
424
 
407
425
  conversation.addUserText(planningPrompt);
408
426
  conversation.addUserText(this.getTasksMessage());
409
- const currentState = this.stateManager.getCurrentState();
410
- const research = await this.researcher.research(currentState || state, {
411
- deep: true,
412
- });
413
427
 
414
- let docsPromise: Promise<string> | null = null;
415
- if (this.scout && this.docsWeight > 0) {
416
- docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
417
- }
418
- let plannerResearch = mdq(research).query('code').replace('').toString();
419
- plannerResearch = mdq(plannerResearch)
420
- .query('table')
421
- .replaceEach((table) => {
422
- const rows = table.toJson();
423
- if (rows.length === 0 || !rows[0].Element) return table.text();
424
- const elementWithType = rows.map((r) => ({
425
- Element: r.Element,
426
- Type: r.Type || '',
427
- }));
428
- return jsonToTable(elementWithType, ['Element', 'Type']);
429
- })
430
- .toString();
428
+ const [plannerResearch, docs] = await Promise.all([this.collectResearch(state), this.collectDocs(state, feature)]);
431
429
 
432
430
  const hasFocusedOverlay = hasFocusedSection(plannerResearch);
433
431
  const focusNote = hasFocusedOverlay ? "IMPORTANT: One section is marked as **Focused** — this is the user's current focus area. Concentrate testing on the Focused section FIRST — test all interactions inside it before planning tests for the rest of the page." : '';
@@ -448,22 +446,23 @@ export class Planner extends PlannerBase implements Agent {
448
446
  const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
449
447
  if (applicationContext) {
450
448
  conversation.addUserText(applicationContext);
449
+ const words = applicationContext.split(/\s+/).filter(Boolean).length;
450
+ tag('info').log(`Added application spec to planning: ${words} words for ${state.url}`);
451
451
  }
452
452
 
453
- if (docsPromise) {
454
- const docs = await docsPromise;
455
- if (docs) {
456
- conversation.addUserText(dedent`
457
- <docs_context>
458
- Documentation retrieved from the collected corpus by the Scout agent.
459
- Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
453
+ if (docs) {
454
+ conversation.addUserText(dedent`
455
+ <docs_context>
456
+ Documentation retrieved from the collected corpus by the Scout agent.
457
+ Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
460
458
 
461
- Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
459
+ Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
462
460
 
463
- ${docs}
464
- </docs_context>
465
- `);
466
- }
461
+ ${docs}
462
+ </docs_context>
463
+ `);
464
+ const words = docs.split(/\s+/).filter(Boolean).length;
465
+ tag('info').log(`Added spec to planning (${words} words)`);
467
466
  }
468
467
 
469
468
  let activeRegion = '';
@@ -638,20 +637,14 @@ export class Planner extends PlannerBase implements Agent {
638
637
  private getTasksMessage(): string {
639
638
  return dedent`
640
639
  <task>
641
- Provide testing scenarios as structured data with the following requirements:
640
+ Provide testing scenarios with the following requirements:
642
641
  1. Create a short, descriptive plan name that summarizes what will be tested (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")
643
- 2. Assign priorities based on business importance:
644
- - CRITICAL: Core business functionality that defines the page purpose
645
- - IMPORTANT: Key user flows, primary features, CRUD operations
646
- - HIGH: Secondary features, edge cases for critical flows
647
- - NORMAL: Supporting actions, settings, configuration
648
- - LOW: Cosmetic checks, boundary testing, minor interactions
649
- 3. Propose tests following research section order: ${this.sectionOrder.join(', ')}
642
+ 2. Propose tests following research section order: ${this.sectionOrder.join(', ')}
650
643
  Cover sections in this order — first propose tests for elements from earlier sections, then later ones.
651
644
  Extended Research sections come after the main sections.
652
- 4. Focus on interactive elements of the page. Skip navigation links that lead away from current page.
653
- 5. Focus on tests you are 100% sure relevant to this page and can be achived from UI.
654
- 6. For each task, provide BOTH steps and expected outcomes as separate arrays:
645
+ 3. Focus on interactive elements of the page. Skip navigation links that lead away from current page.
646
+ 4. Focus on tests you are 100% sure relevant to this page and can be achived from UI.
647
+ 5. For each task, provide BOTH steps and expected outcomes as separate lists:
655
648
 
656
649
  STEPS - actionable commands:
657
650
  - Each step should be a specific action to perform
@@ -682,8 +675,63 @@ export class Planner extends PlannerBase implements Agent {
682
675
  - Do not wrap text in ** or * quotes, ( or ) brackets.
683
676
  - Avoid using emojis or special characters.
684
677
  7. Only tests that can be tested from web UI should be proposed.
685
- 8. For a fresh plan propose at least ${this.MIN_TASKS} tests; when expanding an existing plan return ONLY new scenarios not in the tested list, and an empty array if none remain.
678
+ 8. For a fresh plan propose at least ${this.effectiveMinTasks} and at most ${this.effectiveMaxTasks} tests; when expanding an existing plan return ONLY new scenarios not in the tested list, and no scenarios if none remain.
686
679
  </task>
680
+
681
+ <output_format>
682
+ Reply with the plan as markdown in exactly this format and nothing else:
683
+
684
+ <!-- suite -->
685
+ # {short plan name summarizing what is tested}
686
+
687
+ <!-- test -->
688
+ # {one sentence describing the behavior to test}
689
+
690
+ ## Steps
691
+ * {first action}
692
+ * {next action}
693
+ * {one bullet per action, as many as the workflow needs}
694
+
695
+ ## Expected
696
+ * {first verifiable outcome}
697
+ * {one bullet per outcome, as many as the scenario verifies}
698
+
699
+ Repeat the block from <!-- test to ## Expected for every scenario.
700
+ Every scenario needs its own <!-- test --> comment, its steps and its expected outcomes.
701
+ Steps list every action of the workflow in order, one action per bullet, from the starting page to the final action.
702
+ Expected lists every outcome to verify, one outcome per bullet.
703
+
704
+ When there is nothing to test, write no scenarios; after the plan name add one comment instead:
705
+ <!-- prerequisite: {step that must be done first} --> when the page shows a sign-in form or another required step instead of the content its URL is for
706
+ <!-- blocked: {one sentence why the page has nothing to test} --> otherwise
707
+ </output_format>
687
708
  `;
688
709
  }
710
+
711
+ private async collectResearch(state: ActionResult): Promise<string> {
712
+ const currentState = this.stateManager.getCurrentState();
713
+ const research = await this.researcher.research(currentState || state, { deep: true });
714
+ let plannerResearch = mdq(research).query('code').replace('').toString();
715
+ plannerResearch = mdq(plannerResearch)
716
+ .query('table')
717
+ .replaceEach((table) => {
718
+ const rows = table.toJson();
719
+ if (rows.length === 0 || !rows[0].Element) return table.text();
720
+ const elementWithType = rows.map((r) => ({
721
+ Element: r.Element,
722
+ Type: r.Type || '',
723
+ }));
724
+ return jsonToTable(elementWithType, ['Element', 'Type']);
725
+ })
726
+ .toString();
727
+ return plannerResearch;
728
+ }
729
+
730
+ private async collectDocs(state: ActionResult, feature?: string): Promise<string> {
731
+ if (!this.scout || this.docsWeight <= 0) return '';
732
+ const query = ScoutQuery.fromState(state);
733
+ query.feature = feature;
734
+ query.exclude = this.knowledgeTracker.applicationSpecUrls(state);
735
+ return this.scout.collectDocs(query);
736
+ }
689
737
  }