explorbot 0.4.3 → 0.4.4

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 (44) hide show
  1. package/boat/api-tester/src/apibot.ts +8 -13
  2. package/boat/api-tester/src/cli.ts +7 -3
  3. package/boat/api-tester/src/config.ts +45 -9
  4. package/boat/prima/src/cli.ts +33 -99
  5. package/boat/prima/src/envelope.ts +3 -1
  6. package/boat/prima/src/help.ts +72 -0
  7. package/boat/prima/src/prima.ts +33 -43
  8. package/dist/boat/api-tester/src/apibot.js +7 -6
  9. package/dist/boat/api-tester/src/cli.js +9 -3
  10. package/dist/boat/api-tester/src/config.js +32 -6
  11. package/dist/boat/prima/src/cli.js +30 -86
  12. package/dist/boat/prima/src/envelope.js +2 -1
  13. package/dist/boat/prima/src/help.js +63 -0
  14. package/dist/boat/prima/src/prima.js +29 -41
  15. package/dist/package.json +1 -1
  16. package/dist/src/action-result.d.ts +3 -0
  17. package/dist/src/action-result.js +5 -0
  18. package/dist/src/action.js +12 -1
  19. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  20. package/dist/src/ai/researcher/deep-analysis.js +4 -1
  21. package/dist/src/ai/researcher/sections.d.ts +1 -1
  22. package/dist/src/ai/researcher/sections.js +2 -1
  23. package/dist/src/ai/researcher.js +25 -11
  24. package/dist/src/ai/tester.d.ts +1 -0
  25. package/dist/src/ai/tester.js +27 -33
  26. package/dist/src/ai/tools.js +5 -0
  27. package/dist/src/commands/config-command.js +6 -2
  28. package/dist/src/config.d.ts +3 -0
  29. package/dist/src/config.js +14 -0
  30. package/dist/src/state-manager.js +5 -1
  31. package/docs/api-testing/basics.md +12 -4
  32. package/docs/reference/commands.md +1 -0
  33. package/docs/workflow/agentic-usage.md +3 -1
  34. package/package.json +1 -1
  35. package/src/action-result.ts +7 -0
  36. package/src/action.ts +14 -2
  37. package/src/ai/researcher/deep-analysis.ts +4 -2
  38. package/src/ai/researcher/sections.ts +2 -2
  39. package/src/ai/researcher.ts +28 -11
  40. package/src/ai/tester.ts +25 -30
  41. package/src/ai/tools.ts +6 -0
  42. package/src/commands/config-command.ts +4 -1
  43. package/src/config.ts +16 -0
  44. package/src/state-manager.ts +6 -1
@@ -27,6 +27,7 @@ export const PROVIDERS = {
27
27
  };
28
28
  export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
29
29
  let cachedOutputRoot = null;
30
+ let runOutputDir = null;
30
31
  const config = {
31
32
  playwright: {
32
33
  browser: 'chromium',
@@ -49,6 +50,7 @@ export const EXPLORBOT_ENV_VARS = [
49
50
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
50
51
  { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
51
52
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
53
+ { name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
52
54
  { name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
53
55
  { name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
54
56
  ];
@@ -213,6 +215,7 @@ export class ConfigParser {
213
215
  // For testing purposes only
214
216
  static resetForTesting() {
215
217
  cachedOutputRoot = null;
218
+ runOutputDir = null;
216
219
  if (ConfigParser.instance) {
217
220
  ConfigParser.instance.config = null;
218
221
  ConfigParser.instance.configPath = null;
@@ -441,9 +444,20 @@ export class ConfigParser {
441
444
  }
442
445
  }
443
446
  }
447
+ export function setOutputDir(dir) {
448
+ runOutputDir = dir;
449
+ }
444
450
  export function outputPath(...segments) {
451
+ if (runOutputDir)
452
+ return path.join(runOutputDir, ...segments);
445
453
  return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
446
454
  }
455
+ export function agentSettings(config, agent) {
456
+ const ai = (config.ai ??= { model: null });
457
+ const agents = (ai.agents ??= {});
458
+ agents[agent] ??= {};
459
+ return agents[agent];
460
+ }
447
461
  export async function resolveModel(spec, role = 'model') {
448
462
  const separator = spec.indexOf('/');
449
463
  if (separator > 0) {
@@ -60,12 +60,16 @@ export class StateManager {
60
60
  updateState(actionResult, codeBlock, trigger = 'manual') {
61
61
  const previousState = this.currentState;
62
62
  const previousHash = previousState?.hash;
63
+ const hashChanged = actionResult.hash !== previousHash;
64
+ if (!hashChanged && previousState?.verifications) {
65
+ const stillTrue = Object.entries(previousState.verifications).filter(([, passed]) => passed);
66
+ actionResult.verifications = { ...Object.fromEntries(stillTrue), ...actionResult.verifications };
67
+ }
63
68
  const newState = actionResult;
64
69
  this.currentState = newState;
65
70
  this.currentState.id = this.nextStateId++;
66
71
  if (newState.url)
67
72
  this.allVisitedUrls.add(normalizeUrl(newState.url));
68
- const hashChanged = actionResult.hash !== previousHash;
69
73
  const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
70
74
  if (hashChanged || regionOpened) {
71
75
  const transition = {
@@ -30,7 +30,7 @@ export default {
30
30
 
31
31
  - **`baseEndpoint`** (required) — the base URL prepended to every request. Test steps use relative paths like `/users`; Curler adds the base for you.
32
32
  - **`spec`** (required) — one or more OpenAPI specs, given as HTTP(S) URLs or local file paths, in YAML or JSON. Chief uses the spec to plan; Curler uses it to look up schemas. Both agents refuse to run without one.
33
- - **`headers`** — sent with every request. This is where API keys and auth tokens go.
33
+ - **`headers`** — sent with every request. This is where API keys and auth tokens go. `-H "Name: value"` on the command line and `EXPLORBOT_API_HEADERS` add to them without a config file.
34
34
 
35
35
  See the [full configuration reference](../reference/configuration.md) for every option and [providers](../basics/providers.md) for choosing an AI model.
36
36
 
@@ -60,16 +60,24 @@ A matching `teardown` hook runs after all tests finish — use it to clean up da
60
60
 
61
61
  Chief and Curler need three things: where the API is, what its spec says, and how to authenticate. Pass all three on the command line and no config file is needed:
62
62
 
63
+ ```bash
64
+ npx explorbot api explore https://api.example.com/v1 \
65
+ --spec ./openapi.yaml \
66
+ -H "Authorization: Bearer $TOKEN"
67
+ ```
68
+
69
+ `api explore` takes the base endpoint as its argument, so one line covers the whole run: it plans in every style, executes each plan, and reports the totals. The other commands take a path within the API and read the base from `--endpoint`:
70
+
63
71
  ```bash
64
72
  npx explorbot api plan /users \
65
73
  --endpoint https://api.example.com/v1 \
66
74
  --spec ./openapi.yaml \
67
- --knowledge 'Send X-Api-Key: ${env.API_KEY} on every request'
75
+ -H "Authorization: Bearer $TOKEN"
68
76
  ```
69
77
 
70
- `--endpoint` and `--spec` each have an environment twin — `EXPLORBOT_URL` and `EXPLORBOT_API_SPEC` — and the flag wins when both are set. `--knowledge` adds to the facts `EXPLORBOT_KNOWLEDGE` and `EXPLORBOT_KNOWLEDGE_FILE` bring in rather than replacing them. Configure your models once with `npx explorbot init --global` and every run stores its plans and requests per host under `~/.explorbot/sites/<host>/`, so a later `api test` against the same API picks up where the last one left off. Knowledge given on the command line lasts for the run; `api know` is what writes it down.
78
+ Each flag has an environment twin — `EXPLORBOT_URL`, `EXPLORBOT_API_SPEC` and `EXPLORBOT_API_HEADERS` — and the flag wins when both are set. `-H` is repeatable and takes one `Name: value` per use; the variable takes one per line. Headers land on every request, the startup health check included, and merge over any `headers` a config file sets. `--knowledge` adds to the facts `EXPLORBOT_KNOWLEDGE` and `EXPLORBOT_KNOWLEDGE_FILE` bring in rather than replacing them. Configure your models once with `npx explorbot init --global` and every run stores its plans and requests per host under `~/.explorbot/sites/<host>/`, so a later `api test` against the same API picks up where the last one left off. Knowledge given on the command line lasts for the run; `api know` is what writes it down.
71
79
 
72
- `--endpoint` keeps its path prefix: given `https://api.example.com/v1`, steps stay relative (`/users`) and Curler sends them to `https://api.example.com/v1/users`. `api test`, which takes a plan file rather than an endpoint, reads it from the flag or the variable.
80
+ The base endpoint keeps its path prefix: given `https://api.example.com/v1`, steps stay relative (`/users`) and Curler sends them to `https://api.example.com/v1/users`. `api test`, which takes a plan file rather than an endpoint, reads the base from the flag or the variable.
73
81
 
74
82
  ### A dedicated API project
75
83
 
@@ -118,6 +118,7 @@ EXPLORBOT_AI_PROVIDER=openrouter \
118
118
  | `EXPLORBOT_KNOWLEDGE_FILE` | Path to a knowledge markdown file |
119
119
  | `EXPLORBOT_SPEC` | Docbot application spec directory or index.md, used as page knowledge |
120
120
  | `EXPLORBOT_API_SPEC` | OpenAPI spec path for the API boat |
121
+ | `EXPLORBOT_API_HEADERS` | Headers sent with every API request, one "Name: value" per line |
121
122
  | `EXPLORBOT_NO_BANNER` | Suppress the startup banner, for machine-readable output |
122
123
  | `EXPLORBOT_MAX_DURATION` | Wall-clock budget in minutes for an explore run; same as --max-duration |
123
124
  <!-- END env -->
@@ -58,6 +58,7 @@ No `init`, no config file, no project directory, no model IDs to look up. These
58
58
  | `EXPLORBOT_KNOWLEDGE_FILE` | no | Path to a knowledge markdown file |
59
59
  | `EXPLORBOT_SPEC` | no | Docbot application spec directory or index.md, used as page knowledge |
60
60
  | `EXPLORBOT_API_SPEC` | no | OpenAPI spec path for the API boat |
61
+ | `EXPLORBOT_API_HEADERS` | no | Headers sent with every API request, one "Name: value" per line |
61
62
  | `EXPLORBOT_NO_BANNER` | no | Suppress the startup banner, for machine-readable output |
62
63
  | `EXPLORBOT_MAX_DURATION` | no | Wall-clock budget in minutes for an explore run; same as --max-duration |
63
64
  <!-- END env -->
@@ -231,11 +232,12 @@ The same variables drive API testing and doc collection.
231
232
  ```bash
232
233
  EXPLORBOT_URL=https://api.example.com \
233
234
  EXPLORBOT_API_SPEC=./openapi.yaml \
235
+ EXPLORBOT_API_HEADERS="Authorization: Bearer $TOKEN" \
234
236
  EXPLORBOT_AI_PROVIDER=openrouter \
235
237
  npx explorbot api explore /users
236
238
  ```
237
239
 
238
- The API boat also takes those two as flags, so one line carries the whole run: `npx explorbot api explore /users --endpoint https://api.example.com --spec ./openapi.yaml`.
240
+ The API boat also takes those three as flags, so one line carries the whole run: `npx explorbot api explore https://api.example.com --spec ./openapi.yaml -H "Authorization: Bearer $TOKEN"`. Given a full URL, `api explore` reads it as the base endpoint; a path like `/users` needs the base in `--endpoint` or `EXPLORBOT_URL`.
239
241
 
240
242
  ```bash
241
243
  EXPLORBOT_AI_PROVIDER=openrouter \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -33,6 +33,7 @@ interface ActionResultData extends WebPageState {
33
33
  iframeSnapshots?: Array<{ src: string; html: string; id?: string }>;
34
34
  ariaSnapshot?: string | null;
35
35
  ariaSnapshotFile?: string;
36
+ regionAria?: string | null;
36
37
  focusedElement?: FocusedElement | null;
37
38
  iframeURL?: string;
38
39
  links?: Link[];
@@ -90,6 +91,7 @@ export class ActionResult implements ActionResultData {
90
91
  public links: Link[] = [];
91
92
  public verifications?: Record<string, boolean>;
92
93
  public overlay: Region = new Region();
94
+ public regionAria: string | null = null;
93
95
  private _diffCache: { previousId: number | undefined; diff: Diff } | null = null;
94
96
 
95
97
  constructor(data: ActionResultData) {
@@ -106,6 +108,7 @@ export class ActionResult implements ActionResultData {
106
108
  this.iframeURL = data.iframeURL;
107
109
  this.notes = data.notes ?? [];
108
110
  this.verifications = data.verifications;
111
+ this.regionAria = data.regionAria ?? null;
109
112
 
110
113
  // Set readonly properties
111
114
  if (data.screenshotFile !== undefined) {
@@ -304,6 +307,10 @@ export class ActionResult implements ActionResultData {
304
307
  return compactAriaSnapshot(this.ariaSnapshot, false);
305
308
  }
306
309
 
310
+ getRegionARIA(): string {
311
+ return compactAriaSnapshot(this.regionAria, false);
312
+ }
313
+
307
314
  getCompactARIA(): string {
308
315
  return compactAriaSnapshot(this.ariaSnapshot, true);
309
316
  }
package/src/action.ts CHANGED
@@ -14,8 +14,8 @@ import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError }
14
14
  import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
15
15
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
16
16
  import { Overlay, OverlayPage } from './utils/overlay.js';
17
- import type { Region } from './utils/region.js';
18
17
  import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
18
+ import type { Region } from './utils/region.js';
19
19
  import { safeFilename } from './utils/strings.ts';
20
20
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
21
21
 
@@ -182,7 +182,19 @@ class Action {
182
182
  focusedElement,
183
183
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
184
184
  });
185
- if (!frame) await this.detectRegion(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
185
+ if (!frame) {
186
+ await this.detectRegion(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
187
+ const regionRoot = result.overlay.root;
188
+ if (result.overlay.isModal && regionRoot) {
189
+ result.regionAria = await this.playwrightHelper.page
190
+ .locator(regionRoot)
191
+ .ariaSnapshot()
192
+ .catch((err: Error) => {
193
+ debugLog('Region ARIA snapshot failed:', err.message);
194
+ return null;
195
+ });
196
+ }
197
+ }
186
198
  this.stateManager.updateState(result, codeBlock);
187
199
  return result;
188
200
  } catch (err) {
@@ -1,6 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult, type Diff } from '../../action-result.js';
3
- import type { ExplorbotConfig } from '../../config.ts';
3
+ import { type ExplorbotConfig, agentSettings } from '../../config.ts';
4
4
  import { executionController } from '../../execution-controller.ts';
5
5
  import type Explorer from '../../explorer.ts';
6
6
  import type { StateManager } from '../../state-manager.js';
@@ -31,7 +31,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
31
31
  tag('info').log('Starting deep analysis of expandable elements');
32
32
  await (this as any).navigateTo(state.fullUrl || state.url);
33
33
 
34
- const maxClicks = (this.config.ai?.agents?.researcher as any)?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
34
+ const maxClicks = agentSettings(this.config, 'researcher').maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
35
35
 
36
36
  const expandedSections: string[] = [];
37
37
  const navigationLinks: Array<{ code: string; url: string }> = [];
@@ -88,6 +88,8 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
88
88
  }
89
89
 
90
90
  async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
91
+ if (!(this as any).isEnabled()) return null;
92
+
91
93
  const region = current.overlay;
92
94
  if (!region.isOpen || !region.name) return null;
93
95
 
@@ -1,6 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import type { ActionResult } from '../../action-result.js';
3
- import type { ExplorbotConfig } from '../../config.ts';
3
+ import { type ExplorbotConfig, agentSettings } from '../../config.ts';
4
4
  import { executionController } from '../../execution-controller.ts';
5
5
  import type Explorer from '../../explorer.ts';
6
6
  import type { StateManager } from '../../state-manager.js';
@@ -66,7 +66,7 @@ export function WithSections<T extends Constructor>(Base: T) {
66
66
  }
67
67
 
68
68
  private async _detectFocusCss(): Promise<string | null> {
69
- const focusSections = (this.config.ai?.agents?.researcher as any)?.focusSections as string[] | undefined;
69
+ const focusSections = agentSettings(this.config, 'researcher').focusSections;
70
70
  if (!focusSections?.length) return null;
71
71
 
72
72
  for (const css of focusSections) {
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../action-result.js';
3
3
  import { setActivity } from '../activity.ts';
4
- import { ConfigParser, type ExplorbotConfig, outputPath } from '../config.ts';
4
+ import { ConfigParser, type ExplorbotConfig, type ResearcherAgentConfig, agentSettings, outputPath } from '../config.ts';
5
5
  import { executionController } from '../execution-controller.ts';
6
6
  import type { ExperienceTracker } from '../experience-tracker.ts';
7
7
  import type Explorer from '../explorer.ts';
@@ -19,7 +19,7 @@ import { annotatePageElements } from '../utils/web-annotate.ts';
19
19
  import type { Agent, AgentDeps } from './agent.js';
20
20
  import type { Navigator } from './navigator.ts';
21
21
  import { ContextLengthError, type Provider } from './provider.js';
22
- import { findSimilarResearch, getCachedResearch, reportResearch, saveResearch } from './researcher/cache.ts';
22
+ import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from './researcher/cache.ts';
23
23
  import { type CoordinateMethods, WithCoordinates } from './researcher/coordinates.ts';
24
24
  import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
25
25
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
@@ -62,13 +62,23 @@ export class Researcher extends ResearcherBase implements Agent {
62
62
  constructor(deps: AgentDeps) {
63
63
  super(deps);
64
64
  this.experienceTracker = deps.stateManager.getExperienceTracker();
65
+ this.settings.reasoning ??= 'low';
66
+ }
65
67
 
66
- const ai = deps.config.ai;
67
- if (ai) {
68
- ai.agents ??= {};
69
- ai.agents.researcher ??= {};
70
- ai.agents.researcher.reasoning ??= 'low';
71
- }
68
+ get settings(): ResearcherAgentConfig {
69
+ return agentSettings(this.config, 'researcher');
70
+ }
71
+
72
+ isEnabled(): boolean {
73
+ return this.settings.enabled !== false;
74
+ }
75
+
76
+ enable(): void {
77
+ this.settings.enabled = true;
78
+ }
79
+
80
+ disable(): void {
81
+ this.settings.enabled = false;
72
82
  }
73
83
 
74
84
  protected getNavigator(): Navigator {
@@ -94,7 +104,7 @@ export class Researcher extends ResearcherBase implements Agent {
94
104
 
95
105
  async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number } = {}): Promise<string> {
96
106
  const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
97
- const maxRetries = (this.config.ai?.agents?.researcher as any)?.retries ?? 2;
107
+ const maxRetries = this.settings.retries ?? 2;
98
108
  let retriesLeft = opts._retriesLeft ?? maxRetries;
99
109
  this.actionResult = ActionResult.fromState(state);
100
110
  const stateHash = this.actionResult.baseHash;
@@ -109,6 +119,13 @@ export class Researcher extends ResearcherBase implements Agent {
109
119
  }
110
120
  }
111
121
 
122
+ if (!this.isEnabled()) {
123
+ debugLog('Researcher is disabled, answering with the recorded map');
124
+ const recorded = getPreviousResearch(stateHash);
125
+ if (recorded) reportResearch(stateHash, recorded);
126
+ return recorded;
127
+ }
128
+
112
129
  Stats.researches++;
113
130
 
114
131
  const sessionName = `researcher: ${state.url}`;
@@ -341,7 +358,7 @@ export class Researcher extends ResearcherBase implements Agent {
341
358
  }
342
359
 
343
360
  private async waitUntilSettled(screenshot: boolean): Promise<boolean> {
344
- const errorPageTimeout = (this.config.ai?.agents?.researcher as any)?.errorPageTimeout ?? 10;
361
+ const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
345
362
  if (errorPageTimeout <= 0) return false;
346
363
 
347
364
  const includeScreenshot = screenshot && this.provider.hasVision();
@@ -374,7 +391,7 @@ export class Researcher extends ResearcherBase implements Agent {
374
391
  }
375
392
 
376
393
  private getConfiguredSections(): Record<string, string> {
377
- const configSections = (this.config.ai?.agents?.researcher as any)?.sections as string[] | undefined;
394
+ const configSections = this.settings.sections;
378
395
  if (!configSections?.length) return POSSIBLE_SECTIONS;
379
396
  const filtered: Record<string, string> = {};
380
397
  for (const key of configSections) {
package/src/ai/tester.ts CHANGED
@@ -53,6 +53,7 @@ export class Tester extends TaskAgent implements Agent {
53
53
  MAX_ITERATIONS = 30;
54
54
  MAX_EXTENSIONS = 2;
55
55
  ASSERTION_TOOLS = ['verify'];
56
+ private pendingReview = '';
56
57
  researcher: Researcher;
57
58
  navigator: Navigator;
58
59
  agentTools: any;
@@ -119,6 +120,7 @@ export class Tester extends TaskAgent implements Agent {
119
120
  this.seenUiMapUrls.clear();
120
121
  this.lastAnalyzedStateHash = null;
121
122
  this.stalledIterations = 0;
123
+ this.pendingReview = '';
122
124
  this.previousRegionPresent = null;
123
125
  this.regionTransitioned = false;
124
126
  this.stateManager.clearHistory();
@@ -333,7 +335,7 @@ export class Tester extends TaskAgent implements Agent {
333
335
  const result = await this.provider.invokeConversation(conversation, tools, {
334
336
  maxToolRoundtrips: 3,
335
337
  toolChoice: 'required',
336
- stopWhen: () => task.hasFinished,
338
+ stopWhen: () => task.hasFinished || !!this.pendingReview,
337
339
  });
338
340
 
339
341
  if (!result) throw new Error('Failed to get response from provider');
@@ -388,6 +390,14 @@ export class Tester extends TaskAgent implements Agent {
388
390
  }
389
391
  }
390
392
 
393
+ if (this.pendingReview && this.pilot) {
394
+ const reviewed = this.pendingReview;
395
+ this.pendingReview = '';
396
+ const reviewState = this.getCurrentState();
397
+ if (reviewed === 'finish') await this.pilot.reviewFinish(task, reviewState, conversation, this.navigator);
398
+ if (reviewed === 'stop') await this.pilot.reviewStop(task, reviewState, conversation);
399
+ }
400
+
391
401
  if (task.hasFinished) {
392
402
  stop();
393
403
  return;
@@ -579,16 +589,19 @@ export class Tester extends TaskAgent implements Agent {
579
589
 
580
590
  if (region.isModal) {
581
591
  const areaName = region.name ? ` "${region.name}"` : '';
582
- let rootHint = '';
583
- if (region.root) rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
592
+ let scoping = 'Use <page_aria> to confirm the element you target is actually inside the overlay.';
593
+ if (region.root) {
594
+ scoping = `Its root is \`${region.root}\` — build every locator as ARIA scoped to that root, e.g. I.click({ role: 'button', text: 'Continue' }, '${region.root}')`;
595
+ }
584
596
  context += dedent`
585
597
  <overlay>
586
- An overlay${areaName} is currently open above the page.${rootHint}
587
- Scope all interactions to elements inside this overlay.
588
- Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it prefer the locator inside the overlay.
589
- Use <page_aria> to confirm the element you target is actually inside the overlay.
590
- </overlay>
598
+ You are inside an overlay${areaName} opened above the page.
599
+ ${scoping}
600
+ Elements outside the overlay are behind it and not actionable while it is open they may share names or roles with the ones inside, so never target them by bare text.
591
601
  `;
602
+ const regionAria = currentState.getRegionARIA();
603
+ if (regionAria) context += `\nIt holds exactly these elements:\n<overlay_aria>\n${regionAria}\n</overlay_aria>`;
604
+ context += '\n</overlay>\n';
592
605
  }
593
606
 
594
607
  if (!region.isModal && region.isOpen && isNewState) {
@@ -1006,18 +1019,9 @@ export class Tester extends TaskAgent implements Agent {
1006
1019
  }),
1007
1020
  execute: async ({ reason }) => {
1008
1021
  task.addNote(`Stop requested: ${reason}`);
1022
+ this.pendingReview = 'stop';
1009
1023
 
1010
- if (this.pilot) {
1011
- const currentState = this.getCurrentState();
1012
- await this.pilot.reviewStop(task, currentState, conversation);
1013
- if (!task.hasFinished) {
1014
- return {
1015
- success: false,
1016
- action: 'stop',
1017
- message: 'Stop rejected; Continue execution',
1018
- };
1019
- }
1020
- } else {
1024
+ if (!this.pilot) {
1021
1025
  task.addNote(reason, TestResult.FAILED);
1022
1026
  task.finish(TestResult.FAILED);
1023
1027
  }
@@ -1053,18 +1057,9 @@ export class Tester extends TaskAgent implements Agent {
1053
1057
  return { success: true, action: 'finish', message: 'already finished' };
1054
1058
  }
1055
1059
  task.addNote(`Finish requested: ${verify}`);
1060
+ this.pendingReview = 'finish';
1056
1061
 
1057
- if (this.pilot) {
1058
- const currentState = this.getCurrentState();
1059
- await this.pilot.reviewFinish(task, currentState, conversation, this.navigator);
1060
- if (!task.hasFinished) {
1061
- return {
1062
- success: false,
1063
- action: 'finish',
1064
- message: 'Finishing rejected; Continue execution',
1065
- };
1066
- }
1067
- } else {
1062
+ if (!this.pilot) {
1068
1063
  task.addNote('Test finished successfully', TestResult.PASSED);
1069
1064
  task.finish(TestResult.PASSED);
1070
1065
  }
package/src/ai/tools.ts CHANGED
@@ -737,6 +737,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
737
737
 
738
738
  const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
739
739
 
740
+ if (!researchResult) {
741
+ return failedToolResult('research', 'No UI map is available for this page.', {
742
+ suggestion: 'Use context() to read the page structure and act on the elements it lists.',
743
+ });
744
+ }
745
+
740
746
  return successToolResult('research', {
741
747
  analysis: researchResult,
742
748
  aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
@@ -45,7 +45,10 @@ export class ConfigCommand extends BaseCommand {
45
45
  const env: Record<string, string> = {};
46
46
  for (const variable of EXPLORBOT_ENV_VARS) {
47
47
  const value = process.env[variable.name];
48
- if (value) env[variable.name] = value;
48
+ if (!value) continue;
49
+ let shown = value;
50
+ if (variable.secret) shown = 'set';
51
+ env[variable.name] = shown;
49
52
  }
50
53
 
51
54
  const models: Record<string, string> = {};
package/src/config.ts CHANGED
@@ -22,6 +22,7 @@ export const PROVIDERS: Record<string, ProviderInfo> = {
22
22
  export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
23
23
 
24
24
  let cachedOutputRoot: string | null = null;
25
+ let runOutputDir: string | null = null;
25
26
 
26
27
  interface PlaywrightConfig {
27
28
  browser: 'chromium' | 'firefox' | 'webkit';
@@ -275,6 +276,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
275
276
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
276
277
  { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
277
278
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
279
+ { name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
278
280
  { name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
279
281
  { name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
280
282
  ];
@@ -496,6 +498,7 @@ export class ConfigParser {
496
498
  // For testing purposes only
497
499
  public static resetForTesting(): void {
498
500
  cachedOutputRoot = null;
501
+ runOutputDir = null;
499
502
  if (ConfigParser.instance) {
500
503
  ConfigParser.instance.config = null;
501
504
  ConfigParser.instance.configPath = null;
@@ -744,10 +747,22 @@ export class ConfigParser {
744
747
  }
745
748
  }
746
749
 
750
+ export function setOutputDir(dir: string): void {
751
+ runOutputDir = dir;
752
+ }
753
+
747
754
  export function outputPath(...segments: string[]): string {
755
+ if (runOutputDir) return path.join(runOutputDir, ...segments);
748
756
  return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
749
757
  }
750
758
 
759
+ export function agentSettings<K extends keyof AgentsConfig>(config: ExplorbotConfig, agent: K): NonNullable<AgentsConfig[K]> {
760
+ const ai = (config.ai ??= { model: null });
761
+ const agents = (ai.agents ??= {}) as Record<K, NonNullable<AgentsConfig[K]>>;
762
+ agents[agent] ??= {} as NonNullable<AgentsConfig[K]>;
763
+ return agents[agent];
764
+ }
765
+
751
766
  export async function resolveModel(spec: string, role: ModelRole = 'model'): Promise<any> {
752
767
  const separator = spec.indexOf('/');
753
768
  if (separator > 0) {
@@ -912,6 +927,7 @@ interface EnvVar {
912
927
  name: string;
913
928
  description: string;
914
929
  required?: boolean;
930
+ secret?: boolean;
915
931
  }
916
932
 
917
933
  export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
@@ -143,13 +143,18 @@ export class StateManager {
143
143
  updateState(actionResult: ActionResult, codeBlock?: string, trigger: 'manual' | 'navigation' | 'automatic' = 'manual'): WebPageState {
144
144
  const previousState = this.currentState;
145
145
  const previousHash = previousState?.hash;
146
+ const hashChanged = actionResult.hash !== previousHash;
147
+
148
+ if (!hashChanged && previousState?.verifications) {
149
+ const stillTrue = Object.entries(previousState.verifications).filter(([, passed]) => passed);
150
+ actionResult.verifications = { ...Object.fromEntries(stillTrue), ...actionResult.verifications };
151
+ }
146
152
 
147
153
  const newState = actionResult;
148
154
  this.currentState = newState;
149
155
  this.currentState.id = this.nextStateId++;
150
156
  if (newState.url) this.allVisitedUrls.add(normalizeUrl(newState.url));
151
157
 
152
- const hashChanged = actionResult.hash !== previousHash;
153
158
  const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
154
159
 
155
160
  if (hashChanged || regionOpened) {