explorbot 0.4.2 → 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 (74) hide show
  1. package/bin/explorbot-cli.ts +6 -1
  2. package/boat/api-tester/src/apibot.ts +8 -13
  3. package/boat/api-tester/src/cli.ts +7 -3
  4. package/boat/api-tester/src/config.ts +45 -9
  5. package/boat/prima/src/cli.ts +33 -99
  6. package/boat/prima/src/envelope.ts +3 -1
  7. package/boat/prima/src/help.ts +72 -0
  8. package/boat/prima/src/prima.ts +33 -43
  9. package/dist/bin/explorbot-cli.js +5 -1
  10. package/dist/boat/api-tester/src/apibot.js +7 -6
  11. package/dist/boat/api-tester/src/cli.js +9 -3
  12. package/dist/boat/api-tester/src/config.js +32 -6
  13. package/dist/boat/prima/src/cli.js +30 -86
  14. package/dist/boat/prima/src/envelope.js +2 -1
  15. package/dist/boat/prima/src/help.js +63 -0
  16. package/dist/boat/prima/src/prima.js +29 -41
  17. package/dist/package.json +1 -1
  18. package/dist/src/action-result.d.ts +3 -0
  19. package/dist/src/action-result.js +5 -0
  20. package/dist/src/action.js +12 -1
  21. package/dist/src/ai/fisherman/request-haul.d.ts +1 -0
  22. package/dist/src/ai/fisherman/request-haul.js +3 -0
  23. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  24. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +78 -13
  25. package/dist/src/ai/fisherman.d.ts +12 -3
  26. package/dist/src/ai/fisherman.js +89 -13
  27. package/dist/src/ai/pilot.d.ts +13 -1
  28. package/dist/src/ai/pilot.js +20 -7
  29. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  30. package/dist/src/ai/researcher/deep-analysis.js +4 -1
  31. package/dist/src/ai/researcher/sections.d.ts +1 -1
  32. package/dist/src/ai/researcher/sections.js +2 -1
  33. package/dist/src/ai/researcher.js +25 -11
  34. package/dist/src/ai/rules.js +2 -0
  35. package/dist/src/ai/tester.d.ts +1 -0
  36. package/dist/src/ai/tester.js +27 -33
  37. package/dist/src/ai/tools.js +5 -0
  38. package/dist/src/api/request-result.js +3 -1
  39. package/dist/src/api/request-store.d.ts +6 -1
  40. package/dist/src/api/request-store.js +55 -17
  41. package/dist/src/api/xhr-capture.d.ts +2 -0
  42. package/dist/src/api/xhr-capture.js +35 -10
  43. package/dist/src/commands/config-command.js +6 -2
  44. package/dist/src/commands/help-json-command.d.ts +31 -0
  45. package/dist/src/commands/help-json-command.js +58 -0
  46. package/dist/src/config.d.ts +3 -0
  47. package/dist/src/config.js +14 -0
  48. package/dist/src/state-manager.js +5 -1
  49. package/docs/api-testing/basics.md +12 -4
  50. package/docs/reference/commands.md +2 -0
  51. package/docs/reference/configuration.md +4 -0
  52. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  53. package/docs/workflow/agentic-usage.md +15 -1
  54. package/package.json +1 -1
  55. package/src/action-result.ts +7 -0
  56. package/src/action.ts +14 -2
  57. package/src/ai/fisherman/request-haul.ts +4 -0
  58. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +93 -20
  59. package/src/ai/fisherman.ts +104 -15
  60. package/src/ai/pilot.ts +20 -7
  61. package/src/ai/researcher/deep-analysis.ts +4 -2
  62. package/src/ai/researcher/sections.ts +2 -2
  63. package/src/ai/researcher.ts +28 -11
  64. package/src/ai/rules.ts +2 -0
  65. package/src/ai/tester.ts +25 -30
  66. package/src/ai/tools.ts +6 -0
  67. package/src/api/request-result.ts +2 -1
  68. package/src/api/request-store.ts +58 -18
  69. package/src/api/xhr-capture.ts +39 -11
  70. package/src/commands/config-command.ts +4 -1
  71. package/src/commands/help-json-command.ts +74 -0
  72. package/src/config.ts +16 -0
  73. package/src/state-manager.ts +6 -1
  74. package/dist/src/ai/fisherman-tools.d.ts +0 -147
@@ -4,8 +4,8 @@ import { listAllEndpoints } from "../api/spec-reader.js";
4
4
  import { createDebug, tag } from "../utils/logger.js";
5
5
  const debugLog = createDebug('explorbot:fisherman');
6
6
  import { loop } from "../utils/loop.js";
7
- import { createFishermanTools } from "./fisherman-tools.js";
8
7
  import { RequestHaul } from "./fisherman/request-haul.js";
8
+ import { createFishermanTools } from "./fisherman/tools.js";
9
9
  import { dataProtectionRules } from "./rules.js";
10
10
  const MAX_ITERATIONS = 15;
11
11
  const MAX_TOOL_ROUNDTRIPS = 5;
@@ -58,7 +58,6 @@ export class Fisherman {
58
58
  debugLog(`endpoints:\n${endpointList || '(none)'}`);
59
59
  if (!endpointList) {
60
60
  tag('warning').log('Fisherman: no endpoints available');
61
- this.mode = 'disabled';
62
61
  return { success: false, summary: 'No API endpoints available', created: [], failed: [] };
63
62
  }
64
63
  await this.refreshAuth();
@@ -70,6 +69,47 @@ export class Fisherman {
70
69
  });
71
70
  const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
72
71
  conversation.addUserText(this.buildTaskPrompt(instructions));
72
+ await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman: ${instructions.slice(0, 50)}` });
73
+ const result = getResult();
74
+ tag('info').log(`Fisherman result: ${result.summary}`);
75
+ return result;
76
+ }
77
+ async lookupData(question, scopeUrl, sessionName) {
78
+ this.sessionName = sessionName;
79
+ tag('info').log(`Fisherman [read]: ${question}`);
80
+ await this.ensureReady(scopeUrl);
81
+ if (this.mode === 'disabled') {
82
+ debugLog('disabled — no data for scope');
83
+ return { success: false, summary: 'No API data available for this scope', created: [], failed: [] };
84
+ }
85
+ const endpointList = this.buildEndpointList(scopeUrl, 'read');
86
+ debugLog(`read endpoints:\n${endpointList || '(none)'}`);
87
+ if (!endpointList) {
88
+ tag('warning').log('Fisherman: no read endpoints available');
89
+ return { success: false, summary: 'No read endpoints are known for this scope', created: [], failed: [] };
90
+ }
91
+ await this.refreshAuth();
92
+ const haul = new RequestHaul(this.requestStore);
93
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
94
+ spec: this.spec,
95
+ baseEndpoint: this.baseEndpoint,
96
+ readOnly: true,
97
+ });
98
+ const conversation = this.provider.startConversation(this.buildLookupSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
99
+ conversation.addUserText(dedent `
100
+ Answer this question about data that already exists:
101
+
102
+ ${question}
103
+
104
+ Make the requests needed to answer it, then call finish with the answer.
105
+ If the available endpoints cannot answer it, call stop with the reason.
106
+ `);
107
+ await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman lookup: ${question.slice(0, 50)}` });
108
+ const result = getResult();
109
+ tag('info').log(`Fisherman answer: ${result.summary}`);
110
+ return result;
111
+ }
112
+ async runSession(conversation, tools, opts) {
73
113
  await loop(async ({ stop, iteration }) => {
74
114
  debugLog(`iteration ${iteration}`);
75
115
  const invokeResult = await this.provider.invokeConversation(conversation, tools, {
@@ -77,17 +117,17 @@ export class Fisherman {
77
117
  agentName: 'fisherman',
78
118
  });
79
119
  debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
80
- if (isFinished()) {
120
+ if (opts.isFinished()) {
81
121
  stop();
82
122
  return;
83
123
  }
84
124
  if (!invokeResult?.toolExecutions?.length) {
85
125
  debugLog('no tool call in this turn — treating as finish');
86
- finishFromText(invokeResult?.response?.text);
126
+ opts.finishFromText(invokeResult?.response?.text);
87
127
  stop();
88
128
  return;
89
129
  }
90
- if (this.isStuckOnEndpoint(haul)) {
130
+ if (this.isStuckOnEndpoint(opts.haul)) {
91
131
  tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
92
132
  stop();
93
133
  return;
@@ -99,7 +139,7 @@ export class Fisherman {
99
139
  }, {
100
140
  maxAttempts: MAX_ITERATIONS,
101
141
  observability: {
102
- name: `fisherman: ${instructions.slice(0, 50)}`,
142
+ name: opts.label,
103
143
  agent: 'fisherman',
104
144
  sessionId: this.sessionName,
105
145
  },
@@ -109,9 +149,6 @@ export class Fisherman {
109
149
  stop();
110
150
  },
111
151
  });
112
- const result = getResult();
113
- tag('info').log(`Fisherman result: ${result.summary}`);
114
- return result;
115
152
  }
116
153
  async detectMode(scopeUrl) {
117
154
  if (this.hasApiConfig) {
@@ -143,18 +180,20 @@ export class Fisherman {
143
180
  this.apiClient.setHeaders(this.configHeaders);
144
181
  }
145
182
  }
146
- buildEndpointList(scopeUrl) {
183
+ buildEndpointList(scopeUrl, family = 'write') {
147
184
  this.scopeDegraded = false;
148
185
  if (this.mode === 'achieve' && this.spec) {
149
- const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
186
+ let specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
187
+ if (family === 'read')
188
+ specEndpoints = keepReadLines(specEndpoints);
150
189
  if (specEndpoints)
151
190
  return specEndpoints;
152
191
  }
153
- const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
192
+ const scoped = this.requestStore.toEndpointList(scopeUrl || '/', family);
154
193
  if (scoped)
155
194
  return scoped;
156
195
  this.scopeDegraded = true;
157
- return this.requestStore.toEndpointList();
196
+ return this.requestStore.toEndpointList(undefined, family);
158
197
  }
159
198
  buildSystemPrompt(endpointList, toolNames, scopeUrl) {
160
199
  let scopeBlock = '';
@@ -192,6 +231,37 @@ export class Fisherman {
192
231
  ${dataProtectionRules}
193
232
  `;
194
233
  }
234
+ buildLookupSystemPrompt(endpointList, toolNames, scopeUrl) {
235
+ let scopeBlock = '';
236
+ if (scopeUrl) {
237
+ scopeBlock = `\n\nSCOPE: You are answering about ${scopeUrl}.`;
238
+ if (this.scopeDegraded)
239
+ scopeBlock += '\nThe endpoint list could not be narrowed to this scope and may include endpoints belonging to other scopes. Prefer the endpoint whose path belongs to this scope.';
240
+ }
241
+ return dedent `
242
+ You are Fisherman — reading the API to report what data already exists. You change nothing.
243
+
244
+ AVAILABLE ENDPOINTS:
245
+ ${endpointList}
246
+ ${scopeBlock}
247
+
248
+ AVAILABLE TOOLS:
249
+ ${toolNames.join(', ')}.
250
+ Use tool names exactly as listed. Do not invent aliases or combined names.
251
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
252
+
253
+ WORKFLOW:
254
+ 1. Pick the endpoint that lists the kind of item the question is about
255
+ 2. Request it, and when the answer needs a parent resource, request the parent first and use its id
256
+ 3. Call finish with the answer, quoting the concrete names, titles and ids the responses returned
257
+
258
+ RULES:
259
+ - Report only what a response actually returned. Never describe data you did not read
260
+ - Report an empty collection as empty. An absent item must not be reported as present
261
+ - Answer the question that was asked and stop. Do not survey unrelated endpoints
262
+ - Use the response category and error text to correct a failed request. Retry a temporary or server failure once
263
+ `;
264
+ }
195
265
  isStuckOnEndpoint(haul) {
196
266
  const made = haul.requests();
197
267
  if (made.length < REPEATED_FAILURE_LIMIT)
@@ -213,3 +283,9 @@ export class Fisherman {
213
283
  `;
214
284
  }
215
285
  }
286
+ function keepReadLines(endpointList) {
287
+ return endpointList
288
+ .split('\n')
289
+ .filter((line) => line.startsWith('GET '))
290
+ .join('\n');
291
+ }
@@ -49,7 +49,19 @@ export declare class Pilot implements Agent {
49
49
  }): Promise<string>;
50
50
  getExperienceToc(): string;
51
51
  pickPlanningTools(): Record<string, unknown>;
52
- buildPreconditionTool(task: Test): {
52
+ fishermanStatus(): string;
53
+ buildFishermanTools(task: Test): {
54
+ askApi: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
55
+ question: any;
56
+ }, {
57
+ answered: boolean;
58
+ reason: string;
59
+ answer?: undefined;
60
+ } | {
61
+ answered: boolean;
62
+ answer: string;
63
+ reason?: undefined;
64
+ }, import("@ai-sdk/provider-utils").Context>>;
53
65
  precondition: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
54
66
  description: any;
55
67
  }, {
@@ -10,6 +10,7 @@ import { ErrorPageError } from "../utils/error-page.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  const debugLog = createDebug('explorbot:pilot');
12
12
  import { truncateJson } from "../utils/strings.js";
13
+ import { createAskApiTool } from "./fisherman/tools.js";
13
14
  import { capabilityGroundingRule, dataProtectionRules } from "./rules.js";
14
15
  import { isInteractive } from "./task-agent.js";
15
16
  import { withdrawVisionTools } from "./tools.js";
@@ -398,7 +399,8 @@ export class Pilot {
398
399
 
399
400
  Plan the test execution for this scenario.
400
401
 
401
- FIRST: Decide if precondition() is needed.
402
+ FIRST: Decide if precondition() is needed. When the page does not settle whether suitable data
403
+ already exists, call askApi() to find out before creating any.
402
404
 
403
405
  Call precondition() WHEN:
404
406
  - The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
@@ -603,7 +605,7 @@ export class Pilot {
603
605
  finalUserText = `${tocBlock}\n\n${userText}`;
604
606
  }
605
607
  this.conversation.addUserText(finalUserText);
606
- const tools = { ...this.pickPlanningTools(), ...this.buildPreconditionTool(opts.task) };
608
+ const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
607
609
  const result = await this.provider.invokeConversation(this.conversation, tools, {
608
610
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
609
611
  toolChoice: opts.tools ? 'auto' : 'none',
@@ -655,7 +657,12 @@ export class Pilot {
655
657
  withdrawVisionTools(planning);
656
658
  return planning;
657
659
  }
658
- buildPreconditionTool(task) {
660
+ fishermanStatus() {
661
+ if (this.fisherman?.isAvailable())
662
+ return 'available';
663
+ return 'none';
664
+ }
665
+ buildFishermanTools(task) {
659
666
  const unavailable = 'Data was not created and cannot be created automatically. Do not call precondition again for this test — continue with what the page already shows.';
660
667
  return {
661
668
  precondition: tool({
@@ -666,7 +673,7 @@ export class Pilot {
666
673
  execute: async ({ description }) => {
667
674
  task.addNote(`Precondition: ${description}`);
668
675
  tag('info').log(`Precondition: ${description}`);
669
- debugLog(`precondition: ${description}, fisherman: ${this.fisherman?.isAvailable() ? 'available' : 'none'}`);
676
+ debugLog(`precondition: ${description}, fisherman: ${this.fishermanStatus()}`);
670
677
  if (!this.fisherman || !this.fisherman.isAvailable()) {
671
678
  const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
672
679
  if (skipReason)
@@ -698,6 +705,7 @@ export class Pilot {
698
705
  return { noted: true, prepared: true, created: result.created };
699
706
  },
700
707
  }),
708
+ ...createAskApiTool(this.fisherman, task),
701
709
  };
702
710
  }
703
711
  async checkDataAvailability(task, requestedData, fishermanReason) {
@@ -1047,7 +1055,7 @@ export class Pilot {
1047
1055
  - Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
1048
1056
  - form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
1049
1057
  - ariaDiff shows 5+ added/removed → page entered new mode (editor/modal); call context() before guessing selectors.
1050
- - Empty dropdown/list when items expected → wait explicitly, then check the state changed: ariaDiff and any GET that loaded data. If still nothing loaded, confirm the empty state with verify().
1058
+ - Empty dropdown/list when items expected → wait explicitly, then check the state changed: ariaDiff and any GET that loaded data. If still nothing loaded, confirm the empty state with verify(), or askApi() for whether the data exists at all.
1051
1059
  - Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
1052
1060
  - Multi-action explanation in one tool call → instruct Tester to split.
1053
1061
 
@@ -1063,8 +1071,13 @@ export class Pilot {
1063
1071
 
1064
1072
  ${capabilityGroundingRule}
1065
1073
 
1066
- YOUR Pilot-only tool: precondition(description) create FRESH disposable test data via API. Never
1067
- request users. Use when:
1074
+ YOUR Pilot-only tools, both over the API:
1075
+
1076
+ askApi(question) — ask what data already exists. It changes nothing. Use it to check whether
1077
+ suitable data is already there before creating any, and to get the exact name or id of an existing
1078
+ record a step must act on.
1079
+
1080
+ precondition(description) — create FRESH disposable test data. Never request users. Use when:
1068
1081
 
1069
1082
  - Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
1070
1083
  - Scenario needs auxiliary data (labels, categories, statuses for filtering).
@@ -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) {
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../action-result.js';
3
3
  import { setActivity } from "../activity.js";
4
- import { outputPath } from "../config.js";
4
+ import { agentSettings, outputPath } from "../config.js";
5
5
  import { executionController } from "../execution-controller.js";
6
6
  import { Observability } from "../observability.js";
7
7
  import { Stats } from "../stats.js";
@@ -13,7 +13,7 @@ import { mdq } from "../utils/markdown-query.js";
13
13
  import { RulesLoader } from "../utils/rules-loader.js";
14
14
  import { annotatePageElements } from "../utils/web-annotate.js";
15
15
  import { ContextLengthError } from './provider.js';
16
- import { findSimilarResearch, getCachedResearch, reportResearch, saveResearch } from "./researcher/cache.js";
16
+ import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from "./researcher/cache.js";
17
17
  import { WithCoordinates } from "./researcher/coordinates.js";
18
18
  import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
19
19
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
@@ -42,12 +42,19 @@ export class Researcher extends ResearcherBase {
42
42
  constructor(deps) {
43
43
  super(deps);
44
44
  this.experienceTracker = deps.stateManager.getExperienceTracker();
45
- const ai = deps.config.ai;
46
- if (ai) {
47
- ai.agents ??= {};
48
- ai.agents.researcher ??= {};
49
- ai.agents.researcher.reasoning ??= 'low';
50
- }
45
+ this.settings.reasoning ??= 'low';
46
+ }
47
+ get settings() {
48
+ return agentSettings(this.config, 'researcher');
49
+ }
50
+ isEnabled() {
51
+ return this.settings.enabled !== false;
52
+ }
53
+ enable() {
54
+ this.settings.enabled = true;
55
+ }
56
+ disable() {
57
+ this.settings.enabled = false;
51
58
  }
52
59
  getNavigator() {
53
60
  throw new Error('not implemented');
@@ -70,7 +77,7 @@ export class Researcher extends ResearcherBase {
70
77
  }
71
78
  async research(state, opts = {}) {
72
79
  const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
73
- const maxRetries = this.config.ai?.agents?.researcher?.retries ?? 2;
80
+ const maxRetries = this.settings.retries ?? 2;
74
81
  let retriesLeft = opts._retriesLeft ?? maxRetries;
75
82
  this.actionResult = ActionResult.fromState(state);
76
83
  const stateHash = this.actionResult.baseHash;
@@ -83,6 +90,13 @@ export class Researcher extends ResearcherBase {
83
90
  return cached;
84
91
  }
85
92
  }
93
+ if (!this.isEnabled()) {
94
+ debugLog('Researcher is disabled, answering with the recorded map');
95
+ const recorded = getPreviousResearch(stateHash);
96
+ if (recorded)
97
+ reportResearch(stateHash, recorded);
98
+ return recorded;
99
+ }
86
100
  Stats.researches++;
87
101
  const sessionName = `researcher: ${state.url}`;
88
102
  return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
@@ -286,7 +300,7 @@ export class Researcher extends ResearcherBase {
286
300
  this.actionResult = await this.explorer.visit(url, { screenshot: screenshot ?? false });
287
301
  }
288
302
  async waitUntilSettled(screenshot) {
289
- const errorPageTimeout = this.config.ai?.agents?.researcher?.errorPageTimeout ?? 10;
303
+ const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
290
304
  if (errorPageTimeout <= 0)
291
305
  return false;
292
306
  const includeScreenshot = screenshot && this.provider.hasVision();
@@ -316,7 +330,7 @@ export class Researcher extends ResearcherBase {
316
330
  return false;
317
331
  }
318
332
  getConfiguredSections() {
319
- const configSections = this.config.ai?.agents?.researcher?.sections;
333
+ const configSections = this.settings.sections;
320
334
  if (!configSections?.length)
321
335
  return POSSIBLE_SECTIONS;
322
336
  const filtered = {};
@@ -163,6 +163,8 @@ export const dataProtectionRules = dedent `
163
163
  Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
164
164
  filter, tab, or list-inspection constraint. Use visible existing data when it is available.
165
165
  If no suitable data exists, report the missing precondition instead of creating data.
166
+ Reading through the API to establish what already exists is not a mutation and stays allowed
167
+ under a read-only constraint.
166
168
 
167
169
  Destructive actions are allowed only against data created by the current scenario
168
170
  or prepared for that scenario by Fisherman/API preconditions. Existing application data must
@@ -22,6 +22,7 @@ export declare class Tester extends TaskAgent implements Agent {
22
22
  MAX_ITERATIONS: number;
23
23
  MAX_EXTENSIONS: number;
24
24
  ASSERTION_TOOLS: string[];
25
+ pendingReview: string;
25
26
  researcher: Researcher;
26
27
  navigator: Navigator;
27
28
  agentTools: any;
@@ -40,6 +40,7 @@ export class Tester extends TaskAgent {
40
40
  MAX_ITERATIONS = 30;
41
41
  MAX_EXTENSIONS = 2;
42
42
  ASSERTION_TOOLS = ['verify'];
43
+ pendingReview = '';
43
44
  researcher;
44
45
  navigator;
45
46
  agentTools;
@@ -97,6 +98,7 @@ export class Tester extends TaskAgent {
97
98
  this.seenUiMapUrls.clear();
98
99
  this.lastAnalyzedStateHash = null;
99
100
  this.stalledIterations = 0;
101
+ this.pendingReview = '';
100
102
  this.previousRegionPresent = null;
101
103
  this.regionTransitioned = false;
102
104
  this.stateManager.clearHistory();
@@ -293,7 +295,7 @@ export class Tester extends TaskAgent {
293
295
  const result = await this.provider.invokeConversation(conversation, tools, {
294
296
  maxToolRoundtrips: 3,
295
297
  toolChoice: 'required',
296
- stopWhen: () => task.hasFinished,
298
+ stopWhen: () => task.hasFinished || !!this.pendingReview,
297
299
  });
298
300
  if (!result)
299
301
  throw new Error('Failed to get response from provider');
@@ -339,6 +341,15 @@ export class Tester extends TaskAgent {
339
341
  `);
340
342
  }
341
343
  }
344
+ if (this.pendingReview && this.pilot) {
345
+ const reviewed = this.pendingReview;
346
+ this.pendingReview = '';
347
+ const reviewState = this.getCurrentState();
348
+ if (reviewed === 'finish')
349
+ await this.pilot.reviewFinish(task, reviewState, conversation, this.navigator);
350
+ if (reviewed === 'stop')
351
+ await this.pilot.reviewStop(task, reviewState, conversation);
352
+ }
342
353
  if (task.hasFinished) {
343
354
  stop();
344
355
  return;
@@ -513,17 +524,20 @@ export class Tester extends TaskAgent {
513
524
  }
514
525
  if (region.isModal) {
515
526
  const areaName = region.name ? ` "${region.name}"` : '';
516
- let rootHint = '';
517
- if (region.root)
518
- rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
527
+ let scoping = 'Use <page_aria> to confirm the element you target is actually inside the overlay.';
528
+ if (region.root) {
529
+ 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}')`;
530
+ }
519
531
  context += dedent `
520
532
  <overlay>
521
- An overlay${areaName} is currently open above the page.${rootHint}
522
- Scope all interactions to elements inside this overlay.
523
- 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.
524
- Use <page_aria> to confirm the element you target is actually inside the overlay.
525
- </overlay>
533
+ You are inside an overlay${areaName} opened above the page.
534
+ ${scoping}
535
+ 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.
526
536
  `;
537
+ const regionAria = currentState.getRegionARIA();
538
+ if (regionAria)
539
+ context += `\nIt holds exactly these elements:\n<overlay_aria>\n${regionAria}\n</overlay_aria>`;
540
+ context += '\n</overlay>\n';
527
541
  }
528
542
  if (!region.isModal && region.isOpen && isNewState) {
529
543
  let rootHint = '';
@@ -920,18 +934,8 @@ export class Tester extends TaskAgent {
920
934
  }),
921
935
  execute: async ({ reason }) => {
922
936
  task.addNote(`Stop requested: ${reason}`);
923
- if (this.pilot) {
924
- const currentState = this.getCurrentState();
925
- await this.pilot.reviewStop(task, currentState, conversation);
926
- if (!task.hasFinished) {
927
- return {
928
- success: false,
929
- action: 'stop',
930
- message: 'Stop rejected; Continue execution',
931
- };
932
- }
933
- }
934
- else {
937
+ this.pendingReview = 'stop';
938
+ if (!this.pilot) {
935
939
  task.addNote(reason, TestResult.FAILED);
936
940
  task.finish(TestResult.FAILED);
937
941
  }
@@ -966,18 +970,8 @@ export class Tester extends TaskAgent {
966
970
  return { success: true, action: 'finish', message: 'already finished' };
967
971
  }
968
972
  task.addNote(`Finish requested: ${verify}`);
969
- if (this.pilot) {
970
- const currentState = this.getCurrentState();
971
- await this.pilot.reviewFinish(task, currentState, conversation, this.navigator);
972
- if (!task.hasFinished) {
973
- return {
974
- success: false,
975
- action: 'finish',
976
- message: 'Finishing rejected; Continue execution',
977
- };
978
- }
979
- }
980
- else {
973
+ this.pendingReview = 'finish';
974
+ if (!this.pilot) {
981
975
  task.addNote('Test finished successfully', TestResult.PASSED);
982
976
  task.finish(TestResult.PASSED);
983
977
  }
@@ -634,6 +634,11 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
634
634
  return failedToolResult('research', 'No current page state available. Navigate to a page first.');
635
635
  }
636
636
  const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
637
+ if (!researchResult) {
638
+ return failedToolResult('research', 'No UI map is available for this page.', {
639
+ suggestion: 'Use context() to read the page structure and act on the elements it lists.',
640
+ });
641
+ }
637
642
  return successToolResult('research', {
638
643
  analysis: researchResult,
639
644
  aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
@@ -96,7 +96,9 @@ export class RequestResult {
96
96
  yaml += body;
97
97
  }
98
98
  writeFileSync(this.requestFile, yaml, 'utf8');
99
- writeFileSync(this.responseFile, this._rawResponseBody || '', 'utf8');
99
+ if (!this._rawResponseBody)
100
+ return;
101
+ writeFileSync(this.responseFile, this._rawResponseBody, 'utf8');
100
102
  }
101
103
  static load(requestFile) {
102
104
  const content = readFileSync(requestFile, 'utf8');
@@ -6,8 +6,10 @@ export declare class RequestStore {
6
6
  onFailedListeners: Array<(r: RequestResult) => void>;
7
7
  outputDir: string;
8
8
  sessionStartedAt: Date;
9
+ readEndpointKeys: Set<string>;
9
10
  constructor(outputDir: string);
10
11
  addCapturedRequest(result: RequestResult): void;
12
+ addReadRequest(result: RequestResult): void;
11
13
  addFailedRequest(result: RequestResult): void;
12
14
  getFailedRequests(): RequestResult[];
13
15
  onFailedRequest(cb: (r: RequestResult) => void): () => void;
@@ -15,12 +17,15 @@ export declare class RequestStore {
15
17
  getCapturedRequests(): RequestResult[];
16
18
  getMadeRequests(): RequestResult[];
17
19
  getLastRequest(): RequestResult | undefined;
18
- toEndpointList(scopePath?: string): string;
20
+ toEndpointList(scopePath?: string, methods?: EndpointFamily): string;
19
21
  extractAuthHeaders(): Record<string, string>;
20
22
  findCapturedRequest(method: string, searchPath: string): RequestResult | undefined;
21
23
  toLog(): string;
22
24
  loadFromDisk(): void;
23
25
  getWriteRequestsForScope(scopePath: string): RequestResult[];
26
+ getReadRequestsForScope(scopePath: string): RequestResult[];
24
27
  clear(): void;
28
+ getRequestsForScope(scopePath: string, methods: EndpointFamily): RequestResult[];
25
29
  }
26
30
  export declare function isFailedRequest(request: RequestResult): boolean;
31
+ export type EndpointFamily = 'read' | 'write';