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
@@ -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 -->
@@ -66,6 +67,18 @@ No `init`, no config file, no project directory, no model IDs to look up. These
66
67
 
67
68
  This table is generated from the registry in `src/config.ts`, which also feeds `explorbot --help` — so `npx explorbot --help` lists the same variables on any command, and an agent can discover them without reading these docs.
68
69
 
70
+ ### Discovering commands
71
+
72
+ An agent that has to build a command line does not have to scrape help text. `help-json` prints the whole command tree — every command with its description, aliases, arguments, options and defaults, plus the version and the variables above:
73
+
74
+ ```bash
75
+ npx explorbot help-json | jq -r '.commands[] | "\(.name): \(.description)"'
76
+ npx explorbot help-json explore | jq '.options' # one command
77
+ npx explorbot help-json api config # nested boat commands
78
+ ```
79
+
80
+ It prints nothing but JSON, so the output pipes straight into `jq`. `npx explorbot config --json` does the same for the resolved configuration.
81
+
69
82
  ### Naming models
70
83
 
71
84
  Set `EXPLORBOT_AI_PROVIDER` to a provider name and Explorbot uses that provider's recommended model for every role — the same IDs listed in [Providers](../basics/providers.md), maintained in [`models.json`](../../models.json):
@@ -219,11 +232,12 @@ The same variables drive API testing and doc collection.
219
232
  ```bash
220
233
  EXPLORBOT_URL=https://api.example.com \
221
234
  EXPLORBOT_API_SPEC=./openapi.yaml \
235
+ EXPLORBOT_API_HEADERS="Authorization: Bearer $TOKEN" \
222
236
  EXPLORBOT_AI_PROVIDER=openrouter \
223
237
  npx explorbot api explore /users
224
238
  ```
225
239
 
226
- 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`.
227
241
 
228
242
  ```bash
229
243
  EXPLORBOT_AI_PROVIDER=openrouter \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.2",
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) {
@@ -20,6 +20,10 @@ export class RequestHaul {
20
20
  return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
21
21
  }
22
22
 
23
+ successfulReads(): RequestResult[] {
24
+ return this.requests().filter((r) => !r.isWrite && !r.error && r.status >= 200 && r.status < 400);
25
+ }
26
+
23
27
  byId(): Map<string, RequestResult> {
24
28
  const map = new Map<string, RequestResult>();
25
29
  for (const request of this.successfulWrites()) {
@@ -1,28 +1,36 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
- import type { ApiClient } from '../api/api-client.ts';
5
- import type { RequestResult } from '../api/request-result.ts';
6
- import type { RequestStore } from '../api/request-store.ts';
7
- import { extractEndpointDefinition } from '../api/spec-reader.ts';
8
- import { tag } from '../utils/logger.ts';
9
- import { isDynamicSegment } from '../utils/url-matcher.ts';
10
- import type { RequestHaul } from './fisherman/request-haul.ts';
11
-
12
- export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: { spec?: any; baseEndpoint?: string }) {
4
+ import type { ApiClient } from '../../api/api-client.ts';
5
+ import type { RequestResult } from '../../api/request-result.ts';
6
+ import type { RequestStore } from '../../api/request-store.ts';
7
+ import { extractEndpointDefinition } from '../../api/spec-reader.ts';
8
+ import type { Test } from '../../test-plan.ts';
9
+ import { tag } from '../../utils/logger.ts';
10
+ import { isDynamicSegment } from '../../utils/url-matcher.ts';
11
+ import type { Fisherman } from '../fisherman.ts';
12
+ import type { RequestHaul } from './request-haul.ts';
13
+
14
+ const BODY_PREVIEW_LIMIT = 2000;
15
+
16
+ export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: { spec?: any; baseEndpoint?: string; readOnly?: boolean }) {
17
+ const readOnly = opts.readOnly === true;
13
18
  let finished = false;
14
19
  let result: FishermanResult | null = null;
15
20
 
16
- const getResult = () => result ?? synthesizeResult(haul, false);
21
+ let allowedMethods: string[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
22
+ if (readOnly) allowedMethods = ['GET'];
23
+
24
+ const getResult = () => result ?? synthesizeResult(haul, false, readOnly);
17
25
  const isFinished = () => finished;
18
26
  const finishFromText = (text?: string) => {
19
27
  finished = true;
20
- const synthesized = synthesizeResult(haul, true);
28
+ const synthesized = synthesizeResult(haul, true, readOnly);
21
29
  if (text && synthesized.success) synthesized.summary = text;
22
30
  result = synthesized;
23
31
  };
24
32
 
25
- const tools = {
33
+ const tools: Record<string, any> = {
26
34
  getEndpointSpec: tool({
27
35
  description: dedent`
28
36
  Get the request specification for an endpoint.
@@ -30,13 +38,14 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
30
38
  Call this before making a request to an endpoint you haven't used before.
31
39
  `,
32
40
  inputSchema: z.object({
33
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
41
+ method: z.enum(allowedMethods as [string, ...string[]]).describe('HTTP method'),
34
42
  path: z.string().describe('Endpoint path, e.g. /suites'),
35
43
  }),
36
44
  execute: async ({ method, path }) => {
37
45
  tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
38
46
 
39
- const captured = requestStore.findCapturedRequest(method, path);
47
+ let captured = requestStore.findCapturedRequest(method, path);
48
+ if (captured && !captured.requestBody && opts.spec && captured.status < 400) captured = undefined;
40
49
  if (captured) {
41
50
  if (captured.status >= 400) {
42
51
  const rejectedCapture = {
@@ -88,7 +97,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
88
97
  Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
89
98
  `,
90
99
  inputSchema: z.object({
91
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
100
+ method: z.enum(allowedMethods as [string, ...string[]]).describe('HTTP method'),
92
101
  path: z.string().describe('API path (e.g., /suites, /suites/1)'),
93
102
  body: z.any().optional().describe('Request body (JSON object)'),
94
103
  queryParams: z.record(z.string(), z.string()).optional().describe('Query parameters'),
@@ -125,11 +134,13 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
125
134
 
126
135
  const extracted = extractKeyFields(reqResult.responseBody);
127
136
  tag('success').log(`Fisherman: ${input.method} ${input.path} > ${statusLine}`);
128
- return {
137
+ const output: Record<string, any> = {
129
138
  success: true,
130
139
  status: reqResult.status,
131
140
  extracted,
132
141
  };
142
+ if (readOnly) output.bodyPreview = reqResult.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT);
143
+ return output;
133
144
  },
134
145
  }),
135
146
 
@@ -181,9 +192,63 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
181
192
  }),
182
193
  };
183
194
 
195
+ if (readOnly) {
196
+ tools.finish = tool({
197
+ description: 'Report the answer to the question. Call when the requests have shown what exists.',
198
+ inputSchema: z.object({
199
+ answer: z.string().describe('What the data shows, quoting the concrete names, titles and ids that were returned'),
200
+ }),
201
+ execute: async ({ answer }) => {
202
+ if (haul.successfulReads().length === 0) {
203
+ tag('warning').log('Fisherman: finish rejected — no successful request in this run');
204
+ return { finished: false, error: 'No successful request was made in this run, so nothing was read. Keep working, or call stop if the question cannot be answered.' };
205
+ }
206
+
207
+ tag('success').log(`Fisherman answered: ${answer}`);
208
+ finished = true;
209
+ result = { success: true, summary: answer, created: [], failed: [] };
210
+ return { finished: true };
211
+ },
212
+ });
213
+ }
214
+
184
215
  return { tools, getResult, isFinished, finishFromText };
185
216
  }
186
217
 
218
+ export function createAskApiTool(fisherman: Fisherman | null, task: Test) {
219
+ return {
220
+ askApi: tool({
221
+ description: dedent`
222
+ Ask what data already exists, changing nothing.
223
+ Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
224
+ Use it before precondition() to see whether suitable data is already available, and whenever a step needs the exact name or id of a record that is already there.
225
+ It never creates, edits or deletes anything — precondition() does that.
226
+ `,
227
+ inputSchema: z.object({
228
+ question: z.string().describe('What to find out about data that already exists'),
229
+ }),
230
+ execute: async ({ question }) => {
231
+ tag('info').log(`Ask API: ${question}`);
232
+
233
+ if (!fisherman?.isAvailable()) {
234
+ return { answered: false, reason: 'No API access is configured, so existing data cannot be queried. Judge from the page instead.' };
235
+ }
236
+
237
+ const result = await fisherman.lookupData(question, task.startUrl, task.sessionName);
238
+
239
+ if (!result.success) {
240
+ tag('warning').log(`Ask API unanswered: ${result.summary}`);
241
+ return { answered: false, reason: result.summary || 'The API could not answer this question' };
242
+ }
243
+
244
+ task.addNote(`Asked API: ${question} — ${result.summary}`);
245
+ tag('success').log(`Ask API: ${result.summary}`);
246
+ return { answered: true, answer: result.summary };
247
+ },
248
+ }),
249
+ };
250
+ }
251
+
187
252
  export function verifyFinish(haul: RequestHaul, input: { summary: string; created: FishermanResult['created']; failed?: FishermanResult['failed'] }): { result: FishermanResult | null; error?: string } {
188
253
  const writes = haul.successfulWrites();
189
254
  if (writes.length === 0) {
@@ -211,14 +276,22 @@ export function verifyFinish(haul: RequestHaul, input: { summary: string; create
211
276
  return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
212
277
  }
213
278
 
214
- function synthesizeResult(haul: RequestHaul, declaredDone: boolean): FishermanResult {
279
+ function synthesizeResult(haul: RequestHaul, declaredDone: boolean, readOnly: boolean): FishermanResult {
215
280
  const made = haul.requests();
216
- const writes = haul.successfulWrites();
217
281
  const failures = haul.failed();
218
- let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
282
+ let succeeded = haul.successfulWrites();
283
+ let successLabel = 'successful writes';
284
+ if (readOnly) {
285
+ succeeded = haul.successfulReads();
286
+ successLabel = 'successful reads';
287
+ }
288
+ let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
219
289
  const lastFailure = failures[failures.length - 1];
220
290
  if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
221
- return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
291
+
292
+ const result: FishermanResult = { success: declaredDone && succeeded.length > 0, summary, created: [], failed: [] };
293
+ if (!readOnly) result.created = succeeded.map(toCreatedItem);
294
+ return result;
222
295
  }
223
296
 
224
297
  function toCreatedItem(write: RequestResult): FishermanResult['created'][number] {
@@ -1,14 +1,15 @@
1
1
  import dedent from 'dedent';
2
2
  import type { ApiClient } from '../api/api-client.ts';
3
- import { type RequestStore, isFailedRequest } from '../api/request-store.ts';
3
+ import { type EndpointFamily, type RequestStore, isFailedRequest } from '../api/request-store.ts';
4
4
  import { listAllEndpoints } from '../api/spec-reader.ts';
5
5
  import { createDebug, tag } from '../utils/logger.ts';
6
6
 
7
7
  const debugLog = createDebug('explorbot:fisherman');
8
8
  import { loop } from '../utils/loop.ts';
9
9
  import type { Agent } from './agent.ts';
10
- import { type FishermanResult, createFishermanTools } from './fisherman-tools.ts';
10
+ import type { Conversation } from './conversation.ts';
11
11
  import { RequestHaul } from './fisherman/request-haul.ts';
12
+ import { type FishermanResult, createFishermanTools } from './fisherman/tools.ts';
12
13
  import type { Provider } from './provider.ts';
13
14
  import { dataProtectionRules } from './rules.ts';
14
15
 
@@ -73,7 +74,6 @@ export class Fisherman implements Agent {
73
74
 
74
75
  if (!endpointList) {
75
76
  tag('warning').log('Fisherman: no endpoints available');
76
- this.mode = 'disabled';
77
77
  return { success: false, summary: 'No API endpoints available', created: [], failed: [] };
78
78
  }
79
79
 
@@ -89,6 +89,59 @@ export class Fisherman implements Agent {
89
89
  const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
90
90
  conversation.addUserText(this.buildTaskPrompt(instructions));
91
91
 
92
+ await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman: ${instructions.slice(0, 50)}` });
93
+
94
+ const result = getResult();
95
+ tag('info').log(`Fisherman result: ${result.summary}`);
96
+ return result;
97
+ }
98
+
99
+ async lookupData(question: string, scopeUrl?: string, sessionName?: string): Promise<FishermanResult> {
100
+ this.sessionName = sessionName;
101
+ tag('info').log(`Fisherman [read]: ${question}`);
102
+
103
+ await this.ensureReady(scopeUrl);
104
+
105
+ if (this.mode === 'disabled') {
106
+ debugLog('disabled — no data for scope');
107
+ return { success: false, summary: 'No API data available for this scope', created: [], failed: [] };
108
+ }
109
+
110
+ const endpointList = this.buildEndpointList(scopeUrl, 'read');
111
+ debugLog(`read endpoints:\n${endpointList || '(none)'}`);
112
+
113
+ if (!endpointList) {
114
+ tag('warning').log('Fisherman: no read endpoints available');
115
+ return { success: false, summary: 'No read endpoints are known for this scope', created: [], failed: [] };
116
+ }
117
+
118
+ await this.refreshAuth();
119
+
120
+ const haul = new RequestHaul(this.requestStore);
121
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
122
+ spec: this.spec,
123
+ baseEndpoint: this.baseEndpoint,
124
+ readOnly: true,
125
+ });
126
+
127
+ const conversation = this.provider.startConversation(this.buildLookupSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
128
+ conversation.addUserText(dedent`
129
+ Answer this question about data that already exists:
130
+
131
+ ${question}
132
+
133
+ Make the requests needed to answer it, then call finish with the answer.
134
+ If the available endpoints cannot answer it, call stop with the reason.
135
+ `);
136
+
137
+ await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman lookup: ${question.slice(0, 50)}` });
138
+
139
+ const result = getResult();
140
+ tag('info').log(`Fisherman answer: ${result.summary}`);
141
+ return result;
142
+ }
143
+
144
+ private async runSession(conversation: Conversation, tools: Record<string, any>, opts: { haul: RequestHaul; isFinished: () => boolean; finishFromText: (text?: string) => void; label: string }): Promise<void> {
92
145
  await loop(
93
146
  async ({ stop, iteration }) => {
94
147
  debugLog(`iteration ${iteration}`);
@@ -98,19 +151,19 @@ export class Fisherman implements Agent {
98
151
  });
99
152
  debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
100
153
 
101
- if (isFinished()) {
154
+ if (opts.isFinished()) {
102
155
  stop();
103
156
  return;
104
157
  }
105
158
 
106
159
  if (!invokeResult?.toolExecutions?.length) {
107
160
  debugLog('no tool call in this turn — treating as finish');
108
- finishFromText(invokeResult?.response?.text);
161
+ opts.finishFromText(invokeResult?.response?.text);
109
162
  stop();
110
163
  return;
111
164
  }
112
165
 
113
- if (this.isStuckOnEndpoint(haul)) {
166
+ if (this.isStuckOnEndpoint(opts.haul)) {
114
167
  tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
115
168
  stop();
116
169
  return;
@@ -124,7 +177,7 @@ export class Fisherman implements Agent {
124
177
  {
125
178
  maxAttempts: MAX_ITERATIONS,
126
179
  observability: {
127
- name: `fisherman: ${instructions.slice(0, 50)}`,
180
+ name: opts.label,
128
181
  agent: 'fisherman',
129
182
  sessionId: this.sessionName,
130
183
  },
@@ -135,10 +188,6 @@ export class Fisherman implements Agent {
135
188
  },
136
189
  }
137
190
  );
138
-
139
- const result = getResult();
140
- tag('info').log(`Fisherman result: ${result.summary}`);
141
- return result;
142
191
  }
143
192
 
144
193
  private async detectMode(scopeUrl?: string): Promise<void> {
@@ -178,18 +227,19 @@ export class Fisherman implements Agent {
178
227
  }
179
228
  }
180
229
 
181
- private buildEndpointList(scopeUrl?: string): string {
230
+ private buildEndpointList(scopeUrl?: string, family: EndpointFamily = 'write'): string {
182
231
  this.scopeDegraded = false;
183
232
  if (this.mode === 'achieve' && this.spec) {
184
- const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
233
+ let specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
234
+ if (family === 'read') specEndpoints = keepReadLines(specEndpoints);
185
235
  if (specEndpoints) return specEndpoints;
186
236
  }
187
237
 
188
- const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
238
+ const scoped = this.requestStore.toEndpointList(scopeUrl || '/', family);
189
239
  if (scoped) return scoped;
190
240
 
191
241
  this.scopeDegraded = true;
192
- return this.requestStore.toEndpointList();
242
+ return this.requestStore.toEndpointList(undefined, family);
193
243
  }
194
244
 
195
245
  private buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
@@ -229,6 +279,38 @@ export class Fisherman implements Agent {
229
279
  `;
230
280
  }
231
281
 
282
+ private buildLookupSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
283
+ let scopeBlock = '';
284
+ if (scopeUrl) {
285
+ scopeBlock = `\n\nSCOPE: You are answering about ${scopeUrl}.`;
286
+ if (this.scopeDegraded) 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.';
287
+ }
288
+
289
+ return dedent`
290
+ You are Fisherman — reading the API to report what data already exists. You change nothing.
291
+
292
+ AVAILABLE ENDPOINTS:
293
+ ${endpointList}
294
+ ${scopeBlock}
295
+
296
+ AVAILABLE TOOLS:
297
+ ${toolNames.join(', ')}.
298
+ Use tool names exactly as listed. Do not invent aliases or combined names.
299
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
300
+
301
+ WORKFLOW:
302
+ 1. Pick the endpoint that lists the kind of item the question is about
303
+ 2. Request it, and when the answer needs a parent resource, request the parent first and use its id
304
+ 3. Call finish with the answer, quoting the concrete names, titles and ids the responses returned
305
+
306
+ RULES:
307
+ - Report only what a response actually returned. Never describe data you did not read
308
+ - Report an empty collection as empty. An absent item must not be reported as present
309
+ - Answer the question that was asked and stop. Do not survey unrelated endpoints
310
+ - Use the response category and error text to correct a failed request. Retry a temporary or server failure once
311
+ `;
312
+ }
313
+
232
314
  private isStuckOnEndpoint(haul: RequestHaul): boolean {
233
315
  const made = haul.requests();
234
316
  if (made.length < REPEATED_FAILURE_LIMIT) return false;
@@ -250,3 +332,10 @@ export class Fisherman implements Agent {
250
332
  `;
251
333
  }
252
334
  }
335
+
336
+ function keepReadLines(endpointList: string): string {
337
+ return endpointList
338
+ .split('\n')
339
+ .filter((line) => line.startsWith('GET '))
340
+ .join('\n');
341
+ }
package/src/ai/pilot.ts CHANGED
@@ -18,6 +18,7 @@ import { truncateJson } from '../utils/strings.ts';
18
18
  import type { Agent, AgentDeps } from './agent.ts';
19
19
  import type { Conversation } from './conversation.ts';
20
20
  import type { Fisherman } from './fisherman.ts';
21
+ import { createAskApiTool } from './fisherman/tools.ts';
21
22
  import type { Navigator } from './navigator.ts';
22
23
  import type { Provider } from './provider.ts';
23
24
  import type { Researcher } from './researcher.ts';
@@ -452,7 +453,8 @@ export class Pilot implements Agent {
452
453
 
453
454
  Plan the test execution for this scenario.
454
455
 
455
- FIRST: Decide if precondition() is needed.
456
+ FIRST: Decide if precondition() is needed. When the page does not settle whether suitable data
457
+ already exists, call askApi() to find out before creating any.
456
458
 
457
459
  Call precondition() WHEN:
458
460
  - The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
@@ -688,7 +690,7 @@ export class Pilot implements Agent {
688
690
  }
689
691
  this.conversation!.addUserText(finalUserText);
690
692
 
691
- const tools = { ...this.pickPlanningTools(), ...this.buildPreconditionTool(opts.task) };
693
+ const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
692
694
 
693
695
  const result = await this.provider.invokeConversation(this.conversation!, tools, {
694
696
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
@@ -734,7 +736,12 @@ export class Pilot implements Agent {
734
736
  return planning;
735
737
  }
736
738
 
737
- private buildPreconditionTool(task: Test) {
739
+ private fishermanStatus(): string {
740
+ if (this.fisherman?.isAvailable()) return 'available';
741
+ return 'none';
742
+ }
743
+
744
+ private buildFishermanTools(task: Test) {
738
745
  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.';
739
746
  return {
740
747
  precondition: tool({
@@ -745,7 +752,7 @@ export class Pilot implements Agent {
745
752
  execute: async ({ description }) => {
746
753
  task.addNote(`Precondition: ${description}`);
747
754
  tag('info').log(`Precondition: ${description}`);
748
- debugLog(`precondition: ${description}, fisherman: ${this.fisherman?.isAvailable() ? 'available' : 'none'}`);
755
+ debugLog(`precondition: ${description}, fisherman: ${this.fishermanStatus()}`);
749
756
 
750
757
  if (!this.fisherman || !this.fisherman.isAvailable()) {
751
758
  const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
@@ -776,6 +783,7 @@ export class Pilot implements Agent {
776
783
  return { noted: true, prepared: true, created: result.created };
777
784
  },
778
785
  }),
786
+ ...createAskApiTool(this.fisherman, task),
779
787
  };
780
788
  }
781
789
 
@@ -1152,7 +1160,7 @@ export class Pilot implements Agent {
1152
1160
  - Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
1153
1161
  - form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
1154
1162
  - ariaDiff shows 5+ added/removed → page entered new mode (editor/modal); call context() before guessing selectors.
1155
- - 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().
1163
+ - 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.
1156
1164
  - Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
1157
1165
  - Multi-action explanation in one tool call → instruct Tester to split.
1158
1166
 
@@ -1168,8 +1176,13 @@ export class Pilot implements Agent {
1168
1176
 
1169
1177
  ${capabilityGroundingRule}
1170
1178
 
1171
- YOUR Pilot-only tool: precondition(description) create FRESH disposable test data via API. Never
1172
- request users. Use when:
1179
+ YOUR Pilot-only tools, both over the API:
1180
+
1181
+ askApi(question) — ask what data already exists. It changes nothing. Use it to check whether
1182
+ suitable data is already there before creating any, and to get the exact name or id of an existing
1183
+ record a step must act on.
1184
+
1185
+ precondition(description) — create FRESH disposable test data. Never request users. Use when:
1173
1186
 
1174
1187
  - Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
1175
1188
  - Scenario needs auxiliary data (labels, categories, statuses for filtering).
@@ -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) {