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
@@ -25,7 +25,7 @@ export class Curler {
25
25
  debugLog('Starting test:', test.scenario);
26
26
  this.requestState.clear();
27
27
  test.start();
28
- await this.reporter.reportTestStart(test);
28
+ await this.reporter.reportTestStart();
29
29
  const conversation = this.provider.startConversation(this.buildSystemPrompt(), 'curler', this.provider.getAgenticModel('curler'));
30
30
  const tools = createCurlerTools(this.apiClient, this.requestState, test, opts?.searchSpec);
31
31
  const knowledge = test.startUrl && this.knowledgeTracker?.renderEndpointKnowledge(test.startUrl);
@@ -18,7 +18,7 @@ export function createApiCommands(name = 'api') {
18
18
  .option('--style <style>', 'Planning style: basename of a file in rules/chief/styles/')
19
19
  .option('--fresh', 'Start planning from scratch')
20
20
  .action(async (endpoint, options) => {
21
- await run(name, options, endpoint, async (bot) => {
21
+ await run(options, endpoint, async (bot) => {
22
22
  const command = new PlanCommand(bot);
23
23
  command.prefix = name;
24
24
  command.style = options.style;
@@ -47,7 +47,7 @@ export function createApiCommands(name = 'api') {
47
47
  });
48
48
  RecommendedModelsCommand.register(cmd);
49
49
  addCommonOptions(cmd.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1-3, *')).action(async (planfile, index, options) => {
50
- await run(name, options, undefined, async (bot) => {
50
+ await run(options, undefined, async (bot) => {
51
51
  const command = new TestCommand(bot);
52
52
  command.index = index;
53
53
  await command.execute(planfile);
@@ -57,7 +57,7 @@ export function createApiCommands(name = 'api') {
57
57
  });
58
58
  });
59
59
  addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan, execute tests, re-plan. Use * to cover many endpoints, or the base endpoint for all of them')).action(async (endpoint, options) => {
60
- await run(name, options, endpoint, async (bot) => {
60
+ await run(options, endpoint, async (bot) => {
61
61
  const command = new ExploreCommand(bot);
62
62
  await command.execute(endpoint);
63
63
  if (command.result.failed)
@@ -96,7 +96,7 @@ export function createApiCommands(name = 'api') {
96
96
  });
97
97
  return cmd;
98
98
  }
99
- async function run(name, options, endpoint, body) {
99
+ async function run(options, endpoint, body) {
100
100
  setPreserveConsoleLogs(true);
101
101
  try {
102
102
  if (endpoint && URL.canParse(endpoint))
package/dist/models.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "model": "openai/gpt-oss-20b:nitro",
4
4
  "visionModel": "openai/gpt-5.6-luna",
5
5
  "agenticModel": "anthropic/claude-haiku-5.5",
6
- "decisionModel": "typesafe/jev-latest"
6
+ "decisionModel": "~typesafe/jev-latest"
7
7
  },
8
8
  "poolside": {
9
9
  "model": "poolside/laguna-xs-2.1"
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.10",
3
+ "version": "0.6.0",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -131,6 +131,7 @@
131
131
  "sambanova-ai-provider": "^1.2.2",
132
132
  "strip-ansi": "^7.1.2",
133
133
  "turndown": "^7.2.1",
134
+ "wrap-ansi": "^9.0.2",
134
135
  "unique-names-generator": "^4.7.1",
135
136
  "yaml": "^2.8.3",
136
137
  "yargs": "^17.7.2",
@@ -65,14 +65,8 @@ Trying to fill the form on the page
65
65
  ```
66
66
 
67
67
  ```js
68
- I.fillField('/html/body/div/div/div/form/input[@name="name"]', 'Value');
68
+ I.fillField('//html/body//form//input[@name="name"]', 'Value');
69
69
  ```
70
70
  </example_output>
71
71
 
72
- If you don't know the answer, answer as:
73
-
74
- <example_output>
75
- ```js
76
- throw new Error('No resolution');
77
- ```
78
- </example_output>
72
+ If no solution is possible, call stop() with the reason instead of proposing code.
@@ -26,6 +26,10 @@ Prefer ARIA locators for reliable element detection.
26
26
  I.seeElement('.success-message');
27
27
  </example>
28
28
 
29
+ To assert text inside element via `has-text` CSS, prefer using I.see() instead:
30
+
31
+ Prefer `I.see('Collection', '.users')` over `I.seeElement(".users div:has-text("Collection")")`
32
+
29
33
  ### I.seeInField
30
34
 
31
35
  I.seeInField(<locator>, <value>, <context>)
@@ -141,8 +145,8 @@ NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking
141
145
  NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
142
146
  </verification_rules>
143
147
 
144
- [DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
148
+ [Prefer the commands listed here; use other CodeceptJS I.* commands when none of these fits]
145
149
  [INTERACT ONLY WITH ELEMENTS THAT ARE ON THE PAGE HTML OR ARIA SNAPSHOT]
146
150
  [DO NOT USE WAIT FUNCTIONS]
147
151
 
148
- </actions>
152
+ </actions>
@@ -3,4 +3,5 @@ Do not plan tests for these pages, return an empty scenarios list:
3
3
  - sign in
4
4
  - sign up
5
5
  - password recovery
6
+ If the page shows a sign-in form instead of the content its URL is for, also set prerequisite.
6
7
  </ignored_pages>
@@ -615,7 +615,7 @@ export class Diff {
615
615
  async calculate() {
616
616
  if (!this.previous)
617
617
  return;
618
- this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
618
+ this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html);
619
619
  if (!this._isSameUrl) {
620
620
  this._messages = liveRegionMessages(this.previous.html, this.current.html);
621
621
  return;
@@ -7,7 +7,6 @@ declare class Action {
7
7
  stateManager: StateManager;
8
8
  actionResult: ActionResult | null;
9
9
  config: ExplorbotConfig;
10
- action: string | null;
11
10
  lastError: Error | null;
12
11
  playwrightHelper: any;
13
12
  playwrightGroupId: string | null;
@@ -27,8 +27,6 @@ class Action {
27
27
  stateManager;
28
28
  actionResult = null;
29
29
  config;
30
- // action info
31
- action = null;
32
30
  lastError = null;
33
31
  playwrightHelper;
34
32
  playwrightGroupId = null;
@@ -366,7 +364,6 @@ class Action {
366
364
  }
367
365
  }
368
366
  async executeOnce(code, verbatim = false) {
369
- let error = null;
370
367
  setActivity('🔎 Browsing...', 'action');
371
368
  let codeString = code.replace(/^\(I\) => /, '').trim();
372
369
  const executedSteps = [];
@@ -410,7 +407,6 @@ class Action {
410
407
  }
411
408
  catch (err) {
412
409
  debugLog('Action error', errorToString(err));
413
- error = err;
414
410
  if (!isPlaywright) {
415
411
  await recorder.reset();
416
412
  await recorder.start();
@@ -3,7 +3,9 @@ import { createBashTool } from 'bash-tool';
3
3
  import dedent from 'dedent';
4
4
  import { z } from 'zod';
5
5
  import { ConfigParser } from "../../config.js";
6
- import { Test } from "../../test-plan.js";
6
+ import { Plan, Test } from "../../test-plan.js";
7
+ import { getStyles } from "../planner/styles.js";
8
+ import { PlanningBlockedError } from "../../utils/error-page.js";
7
9
  import { listRecentArtifacts, readCaptainFile } from "./file-tools.js";
8
10
  import { resolveProjectRoot } from "./mixin.js";
9
11
  let cachedBashTool = null;
@@ -32,44 +34,58 @@ export function WithIdleMode(Base) {
32
34
  });
33
35
  }
34
36
  const tools = {
35
- updatePlan: tool({
36
- description: 'Update the current plan by replacing or appending tests',
37
+ plan: tool({
38
+ description: dedent `
39
+ Plan tests for the current page. Adds them to the current plan when it is for this page, otherwise starts a new plan.
40
+ Without tests, the Planner writes the tests. With tests, exactly those tests are added and the Planner does not run: use it for tests the user dictates.
41
+ Returns how many tests were added, or why planning failed.
42
+ `,
37
43
  inputSchema: z.object({
38
- action: z.enum(['replace', 'append']).optional().describe('replace clears existing tests, append keeps them'),
39
- title: z.string().optional().describe('New plan title'),
44
+ feature: z.string().optional().describe('Focus area for the Planner'),
45
+ style: z
46
+ .enum(Object.keys(getStyles()))
47
+ .optional()
48
+ .describe('Planning style for the Planner; omit to let it choose'),
40
49
  tests: z
41
50
  .array(z.object({
42
51
  scenario: z.string(),
43
52
  priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).optional(),
44
53
  expected: z.array(z.string()).optional(),
45
54
  }))
46
- .optional(),
55
+ .optional()
56
+ .describe('Tests to add as written, without running the Planner'),
57
+ replace: z.boolean().optional().describe('Remove the tests already in this page plan first'),
58
+ title: z.string().optional().describe('New plan title'),
47
59
  }),
48
- execute: async ({ action, title, tests }) => {
60
+ execute: async ({ feature, style, tests, replace, title }) => {
61
+ if (ctx.blocker instanceof PlanningBlockedError) {
62
+ feature = ctx.blocker.feature;
63
+ style = ctx.blocker.style;
64
+ }
65
+ const url = ctx.explorBot.stateManager().getCurrentState()?.url || '';
49
66
  let plan = ctx.explorBot.getCurrentPlan();
50
- if (!plan) {
51
- plan = await ctx.explorBot.plan();
67
+ if (plan?.url && plan.url !== url)
68
+ plan = undefined;
69
+ if (replace)
70
+ plan?.tests.splice(0);
71
+ const before = new Set(plan?.tests ?? []);
72
+ if (!tests?.length) {
73
+ plan = await ctx.explorBot.plan(feature, { style });
74
+ const error = ctx.explorBot.lastPlanError;
75
+ if (error)
76
+ return { success: false, message: error.message };
52
77
  }
53
78
  if (!plan) {
54
- return { success: false, message: 'Plan unavailable' };
55
- }
56
- if (title) {
57
- plan.title = title;
79
+ plan = new Plan(url);
80
+ ctx.explorBot.setCurrentPlan(plan);
58
81
  }
59
- if (tests?.length) {
60
- if (!action || action === 'replace') {
61
- plan.tests.length = 0;
62
- }
63
- const currentUrl = ctx.explorBot.stateManager().getCurrentState()?.url || '';
64
- for (const testInput of tests) {
65
- const priority = testInput.priority || 'normal';
66
- const expected = testInput.expected?.length ? testInput.expected : [];
67
- const test = new Test(testInput.scenario, priority, expected, currentUrl);
68
- plan.addTest(test);
69
- }
82
+ for (const t of tests ?? []) {
83
+ plan.addTest(new Test(t.scenario, t.priority || 'normal', t.expected ?? [], url));
70
84
  }
85
+ if (title)
86
+ plan.title = title;
71
87
  plan.updateStatus();
72
- return { success: true, tests: plan.tests.length };
88
+ return { success: true, testsAdded: plan.tests.filter((t) => !before.has(t)).length, tests: plan.tests.length };
73
89
  },
74
90
  }),
75
91
  project: tool({
@@ -132,7 +148,7 @@ export function WithIdleMode(Base) {
132
148
  const outputDir = config.dirs?.output || 'output';
133
149
  return dedent `
134
150
  <idle_capabilities>
135
- - Plan management: updatePlan() — replace or append tests in the current plan
151
+ - Plan management: plan() — run the Planner on the current page, or add tests you write yourself
136
152
  - readFile() — read specific report, plan, log, generated test, knowledge, or experience file content
137
153
  - bash() — discover files and inspect file metadata
138
154
  - READ from: ${knowledgeDir}/, ${experienceDir}/, ${outputDir}/
@@ -1,10 +1,12 @@
1
1
  import type { ExplorBot } from '../../explorbot.js';
2
2
  import type { Task } from '../../test-plan.js';
3
+ import type { BlockedError } from '../../utils/error-page.js';
3
4
  export type Constructor<T = object> = new (...args: any[]) => T;
4
5
  export declare const debugLog: (...args: any[]) => void;
5
6
  export type CaptainMode = 'idle' | 'web' | 'test' | 'heal';
6
7
  export interface ModeContext {
7
8
  explorBot: ExplorBot;
8
9
  task: Task;
10
+ blocker: BlockedError | null;
9
11
  }
10
12
  export declare function resolveProjectRoot(): string | null;
@@ -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.js";
4
5
  import { actionRule, locatorRule, sectionContextRule } from "../rules.js";
5
6
  import { createAgentTools, createCodeceptJSTools } from "../tools.js";
6
7
  import { debugLog } from "./mixin.js";
@@ -35,6 +36,15 @@ export function WithWebMode(Base) {
35
36
  }
36
37
  },
37
38
  }),
39
+ oracle: tool({
40
+ description: 'Ask a yes/no question about the current page. Much cheaper than see or context.',
41
+ inputSchema: z.object({ question: z.string() }),
42
+ execute: async ({ question }) => {
43
+ const captured = await ctx.explorBot.getExplorer().capture({ screenshot: true });
44
+ const holds = await ctx.explorBot.getProvider().oracle(question, pageEvidence(captured), { agentName: 'captain', screenshot: captured.screenshot, telemetry: { functionId: 'captain.oracle' } });
45
+ return { success: holds !== null, question, answer: holds };
46
+ },
47
+ }),
38
48
  browser: tool({
39
49
  description: dedent `
40
50
  Direct browser access via Playwright. Use for diagnostics and browser management.
@@ -146,6 +156,7 @@ export function WithWebMode(Base) {
146
156
  - click() accepts array of commands to try in order — include ARIA, CSS, XPath variants
147
157
  - If click() fails with all provided commands, use visualClick() tool as fallback
148
158
  - When an action fails or result is unexpected, investigate or inform the user instead of retrying silently
159
+ - For a quick yes/no question about the page, use oracle instead of see or context
149
160
  `;
150
161
  }
151
162
  };
@@ -2,6 +2,7 @@ import type { ExperienceTracker } from '../experience-tracker.js';
2
2
  import type { ExplorBot } from '../explorbot.js';
3
3
  import type { WebPageState } from '../state-manager.js';
4
4
  import { Task, Test } from '../test-plan.js';
5
+ import { BlockedError } from '../utils/error-page.js';
5
6
  import { HooksRunner } from '../utils/hooks-runner.js';
6
7
  import type { Agent } from './agent.js';
7
8
  import { type CaptainMode, type ModeContext } from './captain/mixin.js';
@@ -9,6 +10,7 @@ import { type Conversation } from './conversation.js';
9
10
  import type { Navigator } from './navigator.js';
10
11
  import type { Provider } from './provider.js';
11
12
  import { TaskAgent } from './task-agent.js';
13
+ import type { SupervisorAction } from './tester.js';
12
14
  declare const CaptainBase: {
13
15
  new (...args: any[]): {
14
16
  testModeTools(ctx: ModeContext): Record<string, any>;
@@ -39,6 +41,9 @@ export declare class Captain extends CaptainBase implements Agent {
39
41
  description: string;
40
42
  options: string;
41
43
  }[];
44
+ unresolvedBlockers: Set<string>;
45
+ handling: boolean;
46
+ blocker: BlockedError | null;
42
47
  constructor(explorBot: ExplorBot);
43
48
  setCommandExecutor(fn: (cmd: string) => Promise<void>, descriptions: {
44
49
  name: string;
@@ -148,12 +153,9 @@ export declare class Captain extends CaptainBase implements Agent {
148
153
  handle(input: string, options?: {
149
154
  reset?: boolean;
150
155
  }): Promise<string | null>;
156
+ resolveBlocker(error: unknown, resolved: () => boolean | Promise<boolean>): Promise<boolean>;
151
157
  }
152
158
  export default Captain;
153
- interface SupervisorAction {
154
- action: 'inject' | 'stop' | 'pass' | 'skip';
155
- message: string;
156
- }
157
159
  interface ExecutionRecoveryAction {
158
160
  action: 'continue' | 'stop';
159
161
  message: string;
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { ActionResult } from '../action-result.js';
5
5
  import { Task } from "../test-plan.js";
6
6
  import { formatHeadings } from "../utils/context-formatter.js";
7
+ import { BlockedError } from "../utils/error-page.js";
7
8
  import { HooksRunner } from "../utils/hooks-runner.js";
8
9
  import { startLogCapture, stopLogCapture, tag } from '../utils/logger.js';
9
10
  import { loop } from '../utils/loop.js';
@@ -26,6 +27,9 @@ export class Captain extends CaptainBase {
26
27
  hooksRunner = null;
27
28
  commandExecutor = null;
28
29
  commandDescriptions = [];
30
+ unresolvedBlockers = new Set();
31
+ handling = false;
32
+ blocker = null;
29
33
  constructor(explorBot) {
30
34
  super();
31
35
  this.explorBot = explorBot;
@@ -106,6 +110,7 @@ export class Captain extends CaptainBase {
106
110
  - 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
107
111
  - Prefer completing in fewer tool calls over thoroughness
108
112
  - NEVER run tests unless the user explicitly asks
113
+ - When an agent reports it is blocked, decide which agent can resolve it and delegate to that agent
109
114
  - 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.
110
115
  ${mode === 'web' || mode === 'heal' ? this.webModeRules() : ''}
111
116
  ${mode === 'test' || mode === 'heal' ? this.testModeRules() : ''}
@@ -281,7 +286,7 @@ export class Captain extends CaptainBase {
281
286
  }
282
287
  async tools(task, onDone) {
283
288
  const mode = this.getMode();
284
- const ctx = { explorBot: this.explorBot, task };
289
+ const ctx = { explorBot: this.explorBot, task, blocker: this.blocker };
285
290
  const core = this.coreTools(task, onDone);
286
291
  const idle = await this.idleModeTools(ctx);
287
292
  if (mode === 'heal')
@@ -393,6 +398,7 @@ export class Captain extends CaptainBase {
393
398
  Execute the request using available tools. After a successful action, check the pageDiff — if it confirms the goal, call done() immediately.
394
399
  `;
395
400
  conversation.addUserText(initialPrompt);
401
+ this.handling = true;
396
402
  await loop(async ({ stop, iteration, userInput }) => {
397
403
  debugLog(`Captain iteration ${iteration}`);
398
404
  if (isDone) {
@@ -452,6 +458,8 @@ export class Captain extends CaptainBase {
452
458
  tag('error').log(`Captain error: ${error}`);
453
459
  stop();
454
460
  },
461
+ }).finally(() => {
462
+ this.handling = false;
455
463
  });
456
464
  const finalUrl = stateManager.getCurrentState()?.url || startUrl;
457
465
  await this.getHooksRunner().runAfterHook('captain', finalUrl);
@@ -463,6 +471,22 @@ export class Captain extends CaptainBase {
463
471
  }
464
472
  return null;
465
473
  }
474
+ async resolveBlocker(error, resolved) {
475
+ if (!(error instanceof BlockedError) || this.handling)
476
+ return false;
477
+ const key = `${error.name} ${error.url} ${error.blocker}`;
478
+ if (this.unresolvedBlockers.has(key))
479
+ return false;
480
+ tag('info').log(`Captain takes over: ${error.message}`);
481
+ this.blocker = error;
482
+ await this.handle(error.request, { reset: true }).finally(() => {
483
+ this.blocker = null;
484
+ });
485
+ if (await resolved())
486
+ return true;
487
+ this.unresolvedBlockers.add(key);
488
+ return false;
489
+ }
466
490
  }
467
491
  export default Captain;
468
492
  function isExplicitSlashRequest(input, command) {
@@ -119,7 +119,7 @@ export class Driller extends TaskAgent {
119
119
  setActivity(`${this.emoji} Capturing annotated page state...`, 'action');
120
120
  const action = this.explorer.action();
121
121
  try {
122
- const annotated = await Promise.race([
122
+ await Promise.race([
123
123
  this.explorer.withPage(annotatePageElements),
124
124
  new Promise((_, reject) => {
125
125
  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 { RECENT_WINDOW_DAYS } from '../experience-tracker.js';
@@ -258,7 +258,7 @@ export class ExperienceCompactor {
258
258
  mergedFrom: group.files.map((f) => f.data.url),
259
259
  };
260
260
  this.experienceTracker.writeExperienceFile(targetStateHash, combinedContent, newFrontmatter);
261
- log(`Merged ${group.files.length} experience files into ${targetFile.filePath}`);
261
+ log(`Merged ${group.files.length} experience files into ${relative(process.cwd(), targetFile.filePath)}`);
262
262
  debugLog('New URL pattern:', group.pattern);
263
263
  for (const sourceFile of sourceFiles) {
264
264
  try {
@@ -1,3 +1,4 @@
1
+ import type { ActionResult } from '../action-result.js';
1
2
  import type { AIConfig } from '../config.js';
2
3
  import { JudgeProvider } from './judge-provider.js';
3
4
  export declare const UNDECIDED = "undecided";
@@ -17,6 +18,7 @@ export declare class Judge {
17
18
  direct: boolean;
18
19
  };
19
20
  threshold: number;
21
+ failureReported: boolean;
20
22
  constructor(provider: JudgeProvider, enabled: {
21
23
  tool: boolean;
22
24
  direct: boolean;
@@ -30,3 +32,8 @@ export declare class Judge {
30
32
  request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
31
33
  recordFailure(error: unknown): null;
32
34
  }
35
+ export declare function pageEvidence(actionResult: ActionResult): {
36
+ url: string;
37
+ title: string;
38
+ page: string;
39
+ };
@@ -1,6 +1,6 @@
1
1
  import { clearActivity, setActivity } from "../activity.js";
2
2
  import { Observability } from "../observability.js";
3
- import { createDebug } from "../utils/logger.js";
3
+ import { createDebug, tag } from "../utils/logger.js";
4
4
  import { JudgeProvider } from "./judge-provider.js";
5
5
  const debugLog = createDebug('explorbot:judge');
6
6
  const APPROVAL_THRESHOLD = 0.7;
@@ -27,6 +27,7 @@ export class Judge {
27
27
  provider;
28
28
  enabled;
29
29
  threshold;
30
+ failureReported = false;
30
31
  constructor(provider, enabled, threshold = APPROVAL_THRESHOLD) {
31
32
  this.provider = provider;
32
33
  this.enabled = enabled;
@@ -95,6 +96,9 @@ export class Judge {
95
96
  }
96
97
  recordFailure(error) {
97
98
  debugLog('judge declined: %s', error);
99
+ if (!this.failureReported)
100
+ tag('warning').log(`Decision model ${this.provider.model} failed (${error}); continuing without it`);
101
+ this.failureReported = true;
98
102
  const span = Observability.getSpan();
99
103
  span?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
100
104
  span?.setAttribute('langfuse.observation.level', 'ERROR');
@@ -102,3 +106,6 @@ export class Judge {
102
106
  return null;
103
107
  }
104
108
  }
109
+ export function pageEvidence(actionResult) {
110
+ return { url: actionResult.url, title: actionResult.title, page: actionResult.getCompactARIA().slice(0, JUDGE_PAGE_CAP) };
111
+ }
@@ -36,6 +36,7 @@ declare class Navigator implements Agent {
36
36
  }, expectedUrl: string): string;
37
37
  isSameExpectedOrigin(expectedUrl: string, stateManager: any): boolean;
38
38
  isOnExpectedPage(expectedUrl: string, stateManager: any): boolean;
39
+ isOn(url: string): boolean;
39
40
  visit(url: string, opts?: {
40
41
  recover?: boolean;
41
42
  }): Promise<boolean>;
@@ -7,7 +7,7 @@ import { normalizeUrl } from '../state-manager.js';
7
7
  import { isFatalBrowserError } from "../utils/browser-errors.js";
8
8
  import { getCliName } from "../utils/cli-name.js";
9
9
  import { extractCodeBlocks } from '../utils/code-extractor.js';
10
- import { isLoginPage } from "../utils/error-page.js";
10
+ import { NavigationBlockedError, isLoginPage } from "../utils/error-page.js";
11
11
  import { HooksRunner } from "../utils/hooks-runner.js";
12
12
  import { createDebug, pluralize, tag } from '../utils/logger.js';
13
13
  import { loop, pause } from '../utils/loop.js';
@@ -20,6 +20,7 @@ import { actionRule, credentialsRule, locatorRule, unexpectedPopupRule } from '.
20
20
  import { isInteractive } from './task-agent.js';
21
21
  import { createLearnExperienceTool } from "./tools.js";
22
22
  const debugLog = createDebug('explorbot:navigator');
23
+ const MAX_TRIED_STEPS = 5;
23
24
  class Navigator {
24
25
  emoji = '🧭';
25
26
  provider;
@@ -120,6 +121,9 @@ class Navigator {
120
121
  const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
121
122
  return matchesNavigationUrl(expectedUrl, currentUrl);
122
123
  }
124
+ isOn(url) {
125
+ return this.isOnExpectedPage(url, this.stateManager);
126
+ }
123
127
  async visit(url, opts = {}) {
124
128
  return this.visitOnce(url, opts.recover !== false);
125
129
  }
@@ -171,9 +175,9 @@ class Navigator {
171
175
  }
172
176
  }
173
177
  navigationError(url, fallback) {
174
- if (this.lastFailureReason)
175
- return new Error(`Navigation to ${url} failed: ${this.lastFailureReason}`);
176
- return new Error(`Navigation to ${url} failed: ${fallback}`);
178
+ const currentUrl = this.stateManager.getCurrentState()?.url || '';
179
+ const tried = this.executedSteps.slice(-MAX_TRIED_STEPS).map((step) => step.command);
180
+ return new NavigationBlockedError(url, currentUrl, this.lastFailureReason || fallback, tried);
177
181
  }
178
182
  failureReason(stopReason, knowledge, url) {
179
183
  const reasons = [];
@@ -364,7 +368,7 @@ class Navigator {
364
368
  Propose different solutions to achieve the result.
365
369
  Solution should be valid CodeceptJS code.
366
370
  Use only data from the <page> context to plan the solution.
367
- The page is given as an ARIA snapshot only: use ARIA and text locators from it.
371
+ 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.
368
372
  Full HTML is provided if these solutions fail.
369
373
  Try various ways to achieve the result
370
374
  </task>
@@ -692,7 +696,7 @@ class Navigator {
692
696
  Do not generate assertions that would pass even if the specific claim is false.
693
697
  </task>
694
698
 
695
- ${RulesLoader.loadRules('navigator', ['verification-actions'], actionResult.url || '')}
699
+ ${RulesLoader.loadRules('navigator', ['verification-actions', 'verification-output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.verifyAttempts))}
696
700
 
697
701
  ${experience}
698
702
 
@@ -765,7 +769,7 @@ class Navigator {
765
769
  const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
766
770
  let verified = successfulCodes.length > 0 && !failures;
767
771
  if (successfulCodes.length && failures)
768
- verified = await this.provider.oracle(message, { assertions: results.map(({ code, passed, error }) => ({ code, passed, error })) }, { agentName: 'navigator', telemetry: { functionId: 'navigator.verifyState' } });
772
+ verified = (await this.provider.oracle(message, { assertions: results.map(({ code, passed, error }) => ({ code, passed, error })) }, { agentName: 'navigator', telemetry: { functionId: 'navigator.verifyState' } })) === true;
769
773
  if (alreadyVerified)
770
774
  verified = true;
771
775
  const inexpressible = !alreadyVerified && totalAttempted === 0;
@@ -68,7 +68,7 @@ export function WithSubPages(Base) {
68
68
  async pickNextSubPage(candidates) {
69
69
  if (candidates.length === 0)
70
70
  return null;
71
- const plannedEntries = [...planRegistry.entries()].map(([key, record]) => `- ${record.url} (${record.plan.tests.length} tests${record.feature ? `, feature: ${record.feature}` : ''})`).join('\n');
71
+ const plannedEntries = [...planRegistry.entries()].map(([, record]) => `- ${record.url} (${record.plan.tests.length} tests${record.feature ? `, feature: ${record.feature}` : ''})`).join('\n');
72
72
  const candidateList = candidates.map((c) => `- ${c.url} (visits: ${c.visitCount}${c.title ? `, title: "${c.title}"` : ''}${c.h1 ? `, h1: "${c.h1}"` : ''})`).join('\n');
73
73
  const conversation = this.provider.startConversation(dedent `
74
74
  You pick the next most business-relevant sub-page to test from a list of candidates.