explorbot 0.4.3 → 0.4.5

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 (87) 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 +41 -46
  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 +39 -44
  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/fisherman/tools.js +7 -1
  20. package/dist/src/ai/fisherman.js +2 -1
  21. package/dist/src/ai/pilot.d.ts +0 -1
  22. package/dist/src/ai/pilot.js +8 -24
  23. package/dist/src/ai/planner.d.ts +4 -0
  24. package/dist/src/ai/planner.js +28 -0
  25. package/dist/src/ai/provider.js +3 -1
  26. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  27. package/dist/src/ai/researcher/deep-analysis.js +4 -1
  28. package/dist/src/ai/researcher/sections.d.ts +1 -1
  29. package/dist/src/ai/researcher/sections.js +2 -1
  30. package/dist/src/ai/researcher.js +25 -11
  31. package/dist/src/ai/rules.js +8 -7
  32. package/dist/src/ai/scout/tools.d.ts +17 -0
  33. package/dist/src/ai/scout/tools.js +130 -0
  34. package/dist/src/ai/scout.d.ts +21 -0
  35. package/dist/src/ai/scout.js +150 -0
  36. package/dist/src/ai/tester.d.ts +1 -0
  37. package/dist/src/ai/tester.js +27 -33
  38. package/dist/src/ai/tools.js +61 -30
  39. package/dist/src/application-spec.d.ts +3 -0
  40. package/dist/src/application-spec.js +21 -5
  41. package/dist/src/commands/config-command.js +6 -2
  42. package/dist/src/config.d.ts +9 -1
  43. package/dist/src/config.js +14 -0
  44. package/dist/src/explorbot.d.ts +3 -0
  45. package/dist/src/explorbot.js +33 -0
  46. package/dist/src/knowledge-tracker.d.ts +1 -0
  47. package/dist/src/knowledge-tracker.js +3 -0
  48. package/dist/src/state-manager.js +5 -1
  49. package/dist/src/utils/aria-ref.d.ts +16 -0
  50. package/dist/src/utils/aria-ref.js +47 -0
  51. package/dist/src/utils/aria.js +3 -3
  52. package/dist/src/utils/web-annotate.js +3 -15
  53. package/dist/src/utils/web-element.d.ts +0 -2
  54. package/dist/src/utils/web-element.js +0 -8
  55. package/docs/api-testing/basics.md +12 -4
  56. package/docs/reference/commands.md +1 -0
  57. package/docs/reference/configuration.md +28 -1
  58. package/docs/web-testing/agents.md +9 -1
  59. package/docs/web-testing/planner.md +5 -0
  60. package/docs/workflow/agentic-usage.md +3 -1
  61. package/docs/workflow/application-spec.md +4 -0
  62. package/package.json +1 -1
  63. package/src/action-result.ts +7 -0
  64. package/src/action.ts +14 -2
  65. package/src/ai/fisherman/tools.ts +8 -1
  66. package/src/ai/fisherman.ts +2 -1
  67. package/src/ai/pilot.ts +8 -25
  68. package/src/ai/planner.ts +33 -0
  69. package/src/ai/provider.ts +2 -1
  70. package/src/ai/researcher/deep-analysis.ts +4 -2
  71. package/src/ai/researcher/sections.ts +2 -2
  72. package/src/ai/researcher.ts +28 -11
  73. package/src/ai/rules.ts +8 -7
  74. package/src/ai/scout/tools.ts +150 -0
  75. package/src/ai/scout.ts +173 -0
  76. package/src/ai/tester.ts +25 -30
  77. package/src/ai/tools.ts +75 -36
  78. package/src/application-spec.ts +22 -4
  79. package/src/commands/config-command.ts +4 -1
  80. package/src/config.ts +23 -0
  81. package/src/explorbot.ts +36 -0
  82. package/src/knowledge-tracker.ts +4 -0
  83. package/src/state-manager.ts +6 -1
  84. package/src/utils/aria-ref.ts +61 -0
  85. package/src/utils/aria.ts +3 -3
  86. package/src/utils/web-annotate.ts +3 -15
  87. package/src/utils/web-element.ts +0 -9
@@ -0,0 +1,63 @@
1
+ import dedent from 'dedent';
2
+ export const helpContract = dedent `
3
+ Prima drives the browser opened by playwright-cli.
4
+
5
+ playwright-cli open <url> starts the session
6
+ prima <command> ... drives it
7
+ playwright-cli close ends it
8
+
9
+ One call runs a whole job:
10
+
11
+ prima check "a workflow can be created and appears in the list" --expected "the new workflow is listed"
12
+ prima do "open the account menu" "choose the settings entry" "switch the theme to dark" "check it took effect"
13
+ prima pw "({ page }) => page.click('[data-test=submit]')"
14
+
15
+ Only research maps a page. Other commands read the accessibility tree; a cached
16
+ research map joins when present. On large or unclear pages, run prima research first.
17
+ Without a usable AI model only pw works; otherwise use playwright-cli.
18
+ DEBUG='explorbot:*' in front of a command logs everything it does.
19
+ `;
20
+ export const checkHelp = dedent `
21
+ check states the outcome, not the clicks; it finds the path itself. It stays
22
+ on the current page and never reloads it, so an open dialog survives.
23
+ --expected one required outcome, repeatable for several. Without it the
24
+ scenario text is the outcome. Each returns under
25
+ ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
26
+ "not verified" means never checked, not false.
27
+ Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
28
+ CONTRADICTION means screenshot and log disagree; judge the html, aria and
29
+ screenshot under ### Artifacts yourself.
30
+ ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
31
+ reported as such rather than as an app failure.
32
+ Side issues found on the way go under ### Answer, not as step failures.
33
+ `;
34
+ export const doHelp = dedent `
35
+ ### Steps marks each instruction ok, FAIL or ??. ?? means it ran but the run ended
36
+ without confirming it - read the steps above. Only FAIL fails the command.
37
+ Nothing runs past the last instruction. Batch the whole sequence in one call;
38
+ that is what keeps this tier cheap.
39
+ `;
40
+ export const askHelp = dedent `
41
+ Answers from a page screenshot, or from its structure with --no-vision.
42
+ `;
43
+ export const verifyHelp = dedent `
44
+ Reports each expressible assertion as PASSED or FAILED with its playwright form;
45
+ no overall verdict, read the lines. "none ran" means unexpressible, not false.
46
+ `;
47
+ export const researchHelp = dedent `
48
+ The map is saved per page state and joins later commands there, so one research run pays for all that follow it.
49
+ `;
50
+ export const statusHelp = dedent `
51
+ Reads recorded files, so it needs no browser and outlives the session.
52
+ The hash is matched across all recorded sites. ### Artifacts lists every kept
53
+ file: aria, html, screenshot and network log when captured, plus per-step captures of a do run.
54
+ `;
55
+ export const reportHelp = dedent `
56
+ Built from the command log, so it needs no browser and outlives the session.
57
+ Reports the latest session unless --pw-session names another.
58
+ `;
59
+ export const sessionHelp = dedent `
60
+ Parallel jobs need one --instance each. --session is ignored when attached,
61
+ since the attached session keeps its own. --framework is parsed but inactive;
62
+ reported code is CodeceptJS either way.
63
+ `;
@@ -18,19 +18,19 @@ import { findSiteWith, listSites } from "../../../src/global-config.js";
18
18
  import { Reporter } from "../../../src/reporter.js";
19
19
  import { Stats } from "../../../src/stats.js";
20
20
  import { Task, Test, TestResult } from "../../../src/test-plan.js";
21
+ import { ariaRefSnapshot } from "../../../src/utils/aria-ref.js";
21
22
  import { compactAriaSnapshot } from "../../../src/utils/aria.js";
22
23
  import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
23
24
  import { pluralize } from "../../../src/utils/logger.js";
24
25
  import { mdq } from "../../../src/utils/markdown-query.js";
25
26
  import { safeFilename } from "../../../src/utils/strings.js";
26
- import { STATUS_FILE, readArtifacts, writeArtifacts } from "./envelope.js";
27
+ import { STATUS_FILE, STEP_FILES, readArtifacts, writeArtifacts } from "./envelope.js";
27
28
  import { isFunctionExpression, takePwValue, toCodeceptWrapper } from "./pw-parser.js";
28
29
  import { readDescriptors, selectDescriptor } from "./pw-registry.js";
29
30
  import { latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from "./session-log.js";
30
- const TESTER_ONLY_TOOLS = ['learnExperience', 'askUser'];
31
+ const WITHHELD_TOOLS = ['learnExperience', 'askUser', 'research'];
31
32
  const ITERATIONS_PER_INSTRUCTION = 2;
32
33
  const MAX_INSTRUCTION_ITERATIONS = 24;
33
- const DEFAULT_RESEARCH_AFTER_VISITS = 3;
34
34
  const CONTEXT_HTML_CAP = 6000;
35
35
  const MAX_TOOL_ROUNDTRIPS = 5;
36
36
  const AI_AGENT_NAME = 'prima';
@@ -68,7 +68,6 @@ export class Prima {
68
68
  server = null;
69
69
  attached = null;
70
70
  session = null;
71
- artifacts;
72
71
  constructor(options = {}) {
73
72
  this.options = options;
74
73
  this.bot = new ExplorBot({
@@ -99,6 +98,7 @@ export class Prima {
99
98
  const config = await this.loadConfig();
100
99
  await this.resolveBrowser(config, discovery);
101
100
  await this.bot.start();
101
+ this.bot.agentResearcher().disable();
102
102
  if (!this.options.url)
103
103
  return;
104
104
  if (this.bot.getCurrentState())
@@ -296,11 +296,17 @@ export class Prima {
296
296
  settleError = error;
297
297
  return null;
298
298
  });
299
- if (settleError)
300
- trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: browserErrorMessage(settleError) });
299
+ const stillOpen = ledger.filter((entry) => entry.status === 'open').length;
301
300
  for (const execution of invoked?.toolExecutions || []) {
302
301
  this.applyLedgerReport(execution, ledger, trace);
303
302
  }
303
+ let unsettled = '';
304
+ if (settleError)
305
+ unsettled = browserErrorMessage(settleError);
306
+ if (invoked && ledger.filter((entry) => entry.status === 'open').length === stillOpen)
307
+ unsettled = 'the model was asked to report every remaining instruction and reported none';
308
+ if (unsettled)
309
+ trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: unsettled });
304
310
  }
305
311
  ledgerProgress(ledger) {
306
312
  return ledger
@@ -382,9 +388,11 @@ export class Prima {
382
388
  const guard = await this.aiGuard(command);
383
389
  if (guard)
384
390
  return guard;
391
+ const researcher = this.bot.agentResearcher();
392
+ researcher.enable();
385
393
  const previousState = this.bot.stateManager().getCurrentState();
386
394
  const result = await this.capturedResult(previousState);
387
- const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
395
+ const uiMap = await researcher.research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
388
396
  return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
389
397
  }
390
398
  async go(target) {
@@ -768,7 +776,7 @@ export class Prima {
768
776
  if (!researcher || !navigator)
769
777
  return {};
770
778
  const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
771
- for (const name of TESTER_ONLY_TOOLS)
779
+ for (const name of WITHHELD_TOOLS)
772
780
  delete tools[name];
773
781
  return tools;
774
782
  }
@@ -823,14 +831,15 @@ export class Prima {
823
831
  }
824
832
  async pageContext(result) {
825
833
  const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
826
- const map = this.researchMap(result);
834
+ const map = getPreviousResearch(result.baseHash);
835
+ let uiMap = '';
827
836
  if (map) {
828
- return dedent `
829
- <page_ui_map url="${result.url}" title="${result.title}">
837
+ uiMap = dedent `
838
+ <page_ui_map>
839
+ A map of this page recorded by an earlier research run. It names parts the accessibility
840
+ tree does not, and can be out of date — the tree is what the page holds now.
830
841
  ${map}
831
842
  </page_ui_map>
832
-
833
- ${experience}
834
843
  `;
835
844
  }
836
845
  return dedent `
@@ -838,20 +847,11 @@ export class Prima {
838
847
  ${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
839
848
  </page>
840
849
 
850
+ ${uiMap}
851
+
841
852
  ${experience}
842
853
  `;
843
854
  }
844
- researchMap(result) {
845
- if (this.bot.stateManager().getVisitCount(result.url) < this.researchAfterVisits())
846
- return '';
847
- return getPreviousResearch(result.getStateHash());
848
- }
849
- researchAfterVisits() {
850
- const configured = this.bot.getConfig?.()?.ai?.agents?.prima?.researchAfterVisits;
851
- if (typeof configured === 'number')
852
- return configured;
853
- return DEFAULT_RESEARCH_AFTER_VISITS;
854
- }
855
855
  offloadValue(value) {
856
856
  const dir = this.statusDir();
857
857
  const name = `value-${createHash('sha1').update(value).digest('hex').slice(0, 8)}.txt`;
@@ -865,7 +865,7 @@ export class Prima {
865
865
  return path.join(path.basename(dir), name);
866
866
  }
867
867
  async refAriaSnapshot(result) {
868
- const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.((page) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
868
+ const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(ariaRefSnapshot)).catch(() => null);
869
869
  return snapshot || result.ariaSnapshot;
870
870
  }
871
871
  executedCodes(code) {
@@ -923,16 +923,13 @@ export class Prima {
923
923
  }
924
924
  async successEnvelope(command, used, result, previousState) {
925
925
  const changes = await this.pageChanges(result, previousState, used[0]);
926
- const status = await this.saveStatus(result);
927
926
  return {
928
927
  ok: true,
929
928
  command,
930
929
  used,
931
930
  page: this.pageBlock(result, previousState),
932
931
  changes,
933
- instance: await this.instanceInfo(),
934
- status,
935
- artifacts: this.artifacts,
932
+ ...(await this.envelopeTail(result)),
936
933
  };
937
934
  }
938
935
  async failureEnvelope(command, error, previousState) {
@@ -940,29 +937,27 @@ export class Prima {
940
937
  const failure = { error: browserErrorMessage(error) };
941
938
  if (result.ariaSnapshot)
942
939
  failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
943
- const status = await this.saveStatus(result);
944
940
  return {
945
941
  ok: false,
946
942
  command,
947
943
  page: this.pageBlock(result, previousState),
948
944
  failure,
949
- instance: await this.instanceInfo(),
950
- status,
951
- artifacts: this.artifacts,
945
+ ...(await this.envelopeTail(result)),
952
946
  };
953
947
  }
954
948
  async reportEnvelope(command, result, previousState, outcome) {
955
- const status = await this.saveStatus(result);
956
949
  return {
957
950
  ok: true,
958
951
  command,
959
952
  page: this.pageBlock(result, previousState),
960
953
  ...outcome,
961
- instance: await this.instanceInfo(),
962
- status,
963
- artifacts: this.artifacts,
954
+ ...(await this.envelopeTail(result)),
964
955
  };
965
956
  }
957
+ async envelopeTail(result) {
958
+ const { hash, artifacts } = await this.saveStatus(result);
959
+ return { instance: await this.instanceInfo(), status: hash, artifacts };
960
+ }
966
961
  async capturedResult(previousState, opts = {}) {
967
962
  const captured = await this.bot
968
963
  .getExplorer()
@@ -1026,9 +1021,9 @@ export class Prima {
1026
1021
  }
1027
1022
  async saveStatus(result) {
1028
1023
  const hash = this.statusHash();
1029
- await this.writeSnapshot(result);
1024
+ const artifacts = await this.writeSnapshot(result);
1030
1025
  writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
1031
- return hash;
1026
+ return { hash, artifacts };
1032
1027
  }
1033
1028
  async writeStepFiles(index, label, diff) {
1034
1029
  const state = this.bot.stateManager().getCurrentState();
@@ -1038,17 +1033,17 @@ export class Prima {
1038
1033
  mkdirSync(dir, { recursive: true });
1039
1034
  const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
1040
1035
  const result = ActionResult.fromState(state);
1041
- writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8');
1042
- writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
1036
+ writeFileSync(`${stem}.${STEP_FILES.aria}`, result.ariaSnapshot ?? '', 'utf-8');
1037
+ writeFileSync(`${stem}.${STEP_FILES.html}`, await result.combinedHtml(), 'utf-8');
1043
1038
  if (diff)
1044
- writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
1039
+ writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8');
1045
1040
  }
1046
1041
  async writeSnapshot(result) {
1047
- this.artifacts = writeArtifacts(this.statusDir(), {
1042
+ return writeArtifacts(this.statusDir(), {
1048
1043
  aria: result.ariaSnapshot,
1049
1044
  html: await result.combinedHtml(),
1050
1045
  screenshot: result.screenshot,
1051
- requests: this.bot.requestStore().getMadeRequests(),
1046
+ requests: this.bot.requestStore().getCapturedRequests(),
1052
1047
  });
1053
1048
  }
1054
1049
  statusHash() {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -26,6 +26,7 @@ interface ActionResultData extends WebPageState {
26
26
  }>;
27
27
  ariaSnapshot?: string | null;
28
28
  ariaSnapshotFile?: string;
29
+ regionAria?: string | null;
29
30
  focusedElement?: FocusedElement | null;
30
31
  iframeURL?: string;
31
32
  links?: Link[];
@@ -84,6 +85,7 @@ export declare class ActionResult implements ActionResultData {
84
85
  links: Link[];
85
86
  verifications?: Record<string, boolean>;
86
87
  overlay: Region;
88
+ regionAria: string | null;
87
89
  _diffCache: {
88
90
  previousId: number | undefined;
89
91
  diff: Diff;
@@ -109,6 +111,7 @@ export declare class ActionResult implements ActionResultData {
109
111
  }): Promise<string>;
110
112
  textHtml(htmlConfig?: HtmlConfig): Promise<string>;
111
113
  getInteractiveARIA(): string;
114
+ getRegionARIA(): string;
112
115
  getCompactARIA(): string;
113
116
  normalizeHtmlConfig(htmlConfig?: HtmlConfig): HtmlConfig | undefined;
114
117
  static fromState(state: WebPageState): ActionResult;
@@ -40,6 +40,7 @@ export class ActionResult {
40
40
  links = [];
41
41
  verifications;
42
42
  overlay = new Region();
43
+ regionAria = null;
43
44
  _diffCache = null;
44
45
  constructor(data) {
45
46
  this.id = data.id;
@@ -55,6 +56,7 @@ export class ActionResult {
55
56
  this.iframeURL = data.iframeURL;
56
57
  this.notes = data.notes ?? [];
57
58
  this.verifications = data.verifications;
59
+ this.regionAria = data.regionAria ?? null;
58
60
  // Set readonly properties
59
61
  if (data.screenshotFile !== undefined) {
60
62
  this.screenshotFile = data.screenshotFile;
@@ -238,6 +240,9 @@ export class ActionResult {
238
240
  getInteractiveARIA() {
239
241
  return compactAriaSnapshot(this.ariaSnapshot, false);
240
242
  }
243
+ getRegionARIA() {
244
+ return compactAriaSnapshot(this.regionAria, false);
245
+ }
241
246
  getCompactARIA() {
242
247
  return compactAriaSnapshot(this.ariaSnapshot, true);
243
248
  }
@@ -165,8 +165,19 @@ class Action {
165
165
  focusedElement,
166
166
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
167
167
  });
168
- if (!frame)
168
+ if (!frame) {
169
169
  await this.detectRegion(result).catch((err) => debugLog('Region detection failed:', err.message));
170
+ const regionRoot = result.overlay.root;
171
+ if (result.overlay.isModal && regionRoot) {
172
+ result.regionAria = await this.playwrightHelper.page
173
+ .locator(regionRoot)
174
+ .ariaSnapshot()
175
+ .catch((err) => {
176
+ debugLog('Region ARIA snapshot failed:', err.message);
177
+ return null;
178
+ });
179
+ }
180
+ }
170
181
  this.stateManager.updateState(result, codeBlock);
171
182
  return result;
172
183
  }
@@ -3,8 +3,10 @@ import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { extractEndpointDefinition } from "../../api/spec-reader.js";
5
5
  import { tag } from "../../utils/logger.js";
6
+ import { truncate } from "../../utils/strings.js";
6
7
  import { isDynamicSegment } from "../../utils/url-matcher.js";
7
8
  const BODY_PREVIEW_LIMIT = 2000;
9
+ const READS_IN_ANSWER = 3;
8
10
  export function createFishermanTools(apiClient, requestStore, haul, opts) {
9
11
  const readOnly = opts.readOnly === true;
10
12
  let finished = false;
@@ -213,7 +215,7 @@ export function createAskApiTool(fisherman, task) {
213
215
  return { answered: false, reason: result.summary || 'The API could not answer this question' };
214
216
  }
215
217
  task.addNote(`Asked API: ${question} — ${result.summary}`);
216
- tag('success').log(`Ask API: ${result.summary}`);
218
+ tag('success').log(`Ask API: ${truncate(result.summary, 200)}`);
217
219
  return { answered: true, answer: result.summary };
218
220
  },
219
221
  }),
@@ -252,6 +254,10 @@ function synthesizeResult(haul, declaredDone, readOnly) {
252
254
  succeeded = haul.successfulReads();
253
255
  successLabel = 'successful reads';
254
256
  }
257
+ if (readOnly && succeeded.length > 0) {
258
+ const bodies = succeeded.slice(-READS_IN_ANSWER).map((read) => `${read.toEndpoint()} → ${read.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT)}`);
259
+ return { success: true, summary: bodies.join('\n\n'), created: [], failed: [] };
260
+ }
255
261
  let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
256
262
  const lastFailure = failures[failures.length - 1];
257
263
  if (lastFailure)
@@ -2,6 +2,7 @@ import dedent from 'dedent';
2
2
  import { isFailedRequest } from "../api/request-store.js";
3
3
  import { listAllEndpoints } from "../api/spec-reader.js";
4
4
  import { createDebug, tag } from "../utils/logger.js";
5
+ import { truncate } from "../utils/strings.js";
5
6
  const debugLog = createDebug('explorbot:fisherman');
6
7
  import { loop } from "../utils/loop.js";
7
8
  import { RequestHaul } from "./fisherman/request-haul.js";
@@ -106,7 +107,7 @@ export class Fisherman {
106
107
  `);
107
108
  await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman lookup: ${question.slice(0, 50)}` });
108
109
  const result = getResult();
109
- tag('info').log(`Fisherman answer: ${result.summary}`);
110
+ tag('info').log(`Fisherman answer: ${truncate(result.summary, 200)}`);
110
111
  return result;
111
112
  }
112
113
  async runSession(conversation, tools, opts) {
@@ -97,7 +97,6 @@ export declare class Pilot implements Agent {
97
97
  hasSuccessfulCheckEvidence(currentState: ActionResult, testerConversation: Conversation): boolean;
98
98
  formatSuccessfulAssertions(currentState: ActionResult, testerConversation: Conversation): string;
99
99
  formatActions(toolCalls: any[]): string;
100
- buildDeletionScope(task: Test): string;
101
100
  getSystemPrompt(task: Test, initialState: ActionResult): string;
102
101
  }
103
102
  export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
@@ -316,28 +316,28 @@ export class Pilot {
316
316
  return dedent `
317
317
  SCENARIO: ${task.scenario}
318
318
 
319
- ${this.buildDeletionScope(task)}
320
-
321
319
  EXPECTED RESULTS (milestones):
322
320
  ${task.expected.map((e) => `- ${e}`).join('\n')}
323
321
  `;
324
322
  }
325
323
  buildResetSystemPrompt(task) {
326
324
  return dedent `
327
- You are Pilot — decide whether a reset is legitimate. Reset is DESTRUCTIVE: it abandons this
328
- iteration's work, but server-side side effects (records created, forms submitted) persist.
329
- Unnecessary resets create duplicate data and infinite loops.
325
+ You are Pilot — decide whether a reset is legitimate. Reset only re-navigates to the start URL:
326
+ it writes nothing, though it abandons this iteration's work and server-side side effects persist.
327
+ The hazard is the tester REDOING a completed flow afterwards — duplicate data and infinite loops.
330
328
 
331
329
  ${this.buildSharedEvidenceRules()}
332
330
 
333
331
  DECISION:
334
- - "allow": current page cannot host the scenario, irrecoverable error, or no path back.
335
- - "continue": prior action already succeeded (URL changed, record visible, confirmation shown) — verify/finish instead. Or scenario goal may already be met; instruct tester to verify the actual outcome rather than redo. Provide guidance.
332
+ - "allow": current page cannot host the scenario, irrecoverable error, no path back, or an
333
+ expectation requires the outcome to survive a reload or a return to the start page and no
334
+ reset has been taken yet this run — there the reset IS the check, not a redo.
335
+ - "continue": the outcome the scenario needs is already observable on the CURRENT page — verify/finish instead. Provide guidance.
336
336
  - "fail": resetCount >= 2 and underlying situation hasn't changed; same flow tried twice with same failure mode.
337
337
  - "skipped": feature doesn't exist on this app or prerequisites can't be met.
338
338
 
339
339
  PRIORITY:
340
- 1) Successful side effects in session_log → almost never allow reset.
340
+ 1) Successful side effects in session_log → allow reset only to re-observe them, never to repeat them.
341
341
  2) resetCount — each prior reset raises the bar.
342
342
  3) Tester's stated reason — weigh against evidence, don't trust blindly.
343
343
 
@@ -999,22 +999,6 @@ export class Pilot {
999
999
  })
1000
1000
  .join('\n\n');
1001
1001
  }
1002
- buildDeletionScope(task) {
1003
- const deletableItems = task.plan
1004
- ? task.plan
1005
- .listTests()
1006
- .filter((t) => t.isSuccessful && t.sessionName)
1007
- .map((t) => t.sessionName)
1008
- : [];
1009
- const scenarioLower = task.scenario.toLowerCase();
1010
- if (deletableItems.length > 0) {
1011
- return `For deletion scenarios, items can only be deleted if their title contains: ${deletableItems.join(', ')}`;
1012
- }
1013
- if (scenarioLower.includes('delete') || scenarioLower.includes('remove')) {
1014
- return 'No items available for deletion — test should create an item first';
1015
- }
1016
- return '';
1017
- }
1018
1002
  getSystemPrompt(task, initialState) {
1019
1003
  const interactive = isInteractive();
1020
1004
  const stepsText = task.plannedSteps.length > 0 ? task.plannedSteps.map((s, i) => `${i + 1}. ${s}`).join('\n') : 'No planned steps';
@@ -9,6 +9,7 @@ import { Conversation } from './conversation.js';
9
9
  import type { Fisherman } from './fisherman.js';
10
10
  import type { Provider } from './provider.js';
11
11
  import { type Researcher } from './researcher.js';
12
+ import type { Scout } from './scout.js';
12
13
  declare const PlannerBase: {
13
14
  new (...args: any[]): {
14
15
  currentPlan: Plan | null;
@@ -59,9 +60,12 @@ export declare class Planner extends PlannerBase implements Agent {
59
60
  lastSuite: Suite | null;
60
61
  researcher: Researcher;
61
62
  fisherman: Fisherman | null;
63
+ scout: Scout | null;
62
64
  constructor(deps: AgentDeps, researcher: Researcher);
63
65
  setFisherman(fisherman: Fisherman): void;
66
+ setScout(scout: Scout): void;
64
67
  get sectionOrder(): string[];
68
+ get docsWeight(): number;
65
69
  getDefaultStartUrl(state: {
66
70
  url: string;
67
71
  fullUrl?: string;
@@ -50,6 +50,7 @@ export class Planner extends PlannerBase {
50
50
  lastSuite = null;
51
51
  researcher;
52
52
  fisherman = null;
53
+ scout = null;
53
54
  constructor(deps, researcher) {
54
55
  super();
55
56
  this.explorer = deps.explorer;
@@ -62,9 +63,16 @@ export class Planner extends PlannerBase {
62
63
  setFisherman(fisherman) {
63
64
  this.fisherman = fisherman;
64
65
  }
66
+ setScout(scout) {
67
+ this.scout = scout;
68
+ }
65
69
  get sectionOrder() {
66
70
  return ConfigParser.getInstance().getConfig().ai?.agents?.researcher?.sections || Object.keys(POSSIBLE_SECTIONS);
67
71
  }
72
+ get docsWeight() {
73
+ const value = ConfigParser.getInstance().getConfig().ai?.agents?.planner?.docsWeight ?? 70;
74
+ return Math.max(0, Math.min(100, value));
75
+ }
68
76
  getDefaultStartUrl(state) {
69
77
  return state.fullUrl || state.url;
70
78
  }
@@ -291,6 +299,7 @@ export class Planner extends PlannerBase {
291
299
  const conversation = new Conversation([], model);
292
300
  conversation.autoTrimTag('page_research', 20000);
293
301
  conversation.autoTrimTag('tested_scenarios', 10000);
302
+ conversation.autoTrimTag('docs_context', 8000);
294
303
  conversation.addUserText(this.getSystemMessage(feature));
295
304
  const planningPrompt = dedent `
296
305
  <task>
@@ -345,6 +354,10 @@ export class Planner extends PlannerBase {
345
354
  const research = await this.researcher.research(currentState || state, {
346
355
  deep: true,
347
356
  });
357
+ let docsPromise = null;
358
+ if (this.scout && this.docsWeight > 0) {
359
+ docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
360
+ }
348
361
  let plannerResearch = mdq(research).query('code').replace('');
349
362
  plannerResearch = mdq(plannerResearch)
350
363
  .query('table')
@@ -376,6 +389,21 @@ export class Planner extends PlannerBase {
376
389
  if (applicationContext) {
377
390
  conversation.addUserText(applicationContext);
378
391
  }
392
+ if (docsPromise) {
393
+ const docs = await docsPromise;
394
+ if (docs) {
395
+ conversation.addUserText(dedent `
396
+ <docs_context>
397
+ Documentation retrieved from the collected corpus by the Scout agent.
398
+ Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
399
+
400
+ Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
401
+
402
+ ${docs}
403
+ </docs_context>
404
+ `);
405
+ }
406
+ }
379
407
  conversation.addUserText(dedent `
380
408
  ${this.buildApproach(style)}
381
409
 
@@ -366,7 +366,9 @@ export class Provider {
366
366
  const modelName = getModelName(model);
367
367
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
368
368
  promptLog(`Using model: ${modelName}`);
369
- const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
369
+ let toolsWithCommentary = tools;
370
+ if (!tools?.commentary && options.toolChoice !== 'required')
371
+ toolsWithCommentary = { ...tools, commentary: createHarmonyChannelFallbackTool() };
370
372
  const toolNames = Object.keys(toolsWithCommentary || {});
371
373
  tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
372
374
  promptLog('Available tools:', toolNames);
@@ -1,5 +1,5 @@
1
1
  import { ActionResult, type Diff } from '../../action-result.js';
2
- import type { ExplorbotConfig } from '../../config.js';
2
+ import { type ExplorbotConfig } from '../../config.js';
3
3
  import type Explorer from '../../explorer.js';
4
4
  import type { StateManager } from '../../state-manager.js';
5
5
  import { WebPageState } from '../../state-manager.js';
@@ -1,5 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../../action-result.js';
3
+ import { agentSettings } from "../../config.js";
3
4
  import { executionController } from "../../execution-controller.js";
4
5
  import { diffAriaSnapshots } from "../../utils/aria.js";
5
6
  import { extractCodeBlocks } from "../../utils/code-extractor.js";
@@ -16,7 +17,7 @@ export function WithDeepAnalysis(Base) {
16
17
  async performDeepAnalysis(state, result) {
17
18
  tag('info').log('Starting deep analysis of expandable elements');
18
19
  await this.navigateTo(state.fullUrl || state.url);
19
- const maxClicks = this.config.ai?.agents?.researcher?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
20
+ const maxClicks = agentSettings(this.config, 'researcher').maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
20
21
  const expandedSections = [];
21
22
  const navigationLinks = [];
22
23
  let verifiedCodes = [];
@@ -62,6 +63,8 @@ export function WithDeepAnalysis(Base) {
62
63
  this._appendExtendedResearch(result, expandedSections, navigationLinks);
63
64
  }
64
65
  async researchOverlay(current, previous, pageStateHash) {
66
+ if (!this.isEnabled())
67
+ return null;
65
68
  const region = current.overlay;
66
69
  if (!region.isOpen || !region.name)
67
70
  return null;
@@ -1,5 +1,5 @@
1
1
  import type { ActionResult } from '../../action-result.js';
2
- import type { ExplorbotConfig } from '../../config.js';
2
+ import { type ExplorbotConfig } from '../../config.js';
3
3
  import type Explorer from '../../explorer.js';
4
4
  import type { StateManager } from '../../state-manager.js';
5
5
  import type { Provider } from '../provider.js';
@@ -1,4 +1,5 @@
1
1
  import dedent from 'dedent';
2
+ import { agentSettings } from "../../config.js";
2
3
  import { executionController } from "../../execution-controller.js";
3
4
  import { tag } from '../../utils/logger.js';
4
5
  import { RulesLoader } from "../../utils/rules-loader.js";
@@ -50,7 +51,7 @@ export function WithSections(Base) {
50
51
  return focused.text;
51
52
  }
52
53
  async _detectFocusCss() {
53
- const focusSections = this.config.ai?.agents?.researcher?.focusSections;
54
+ const focusSections = agentSettings(this.config, 'researcher').focusSections;
54
55
  if (!focusSections?.length)
55
56
  return null;
56
57
  for (const css of focusSections) {