explorbot 0.4.1 → 0.4.3

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 (141) hide show
  1. package/bin/explorbot-cli.ts +14 -4
  2. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  5. package/boat/doc-collector/src/ai/tools.ts +1 -1
  6. package/boat/doc-collector/src/cli.ts +18 -2
  7. package/boat/doc-collector/src/config.ts +3 -0
  8. package/boat/doc-collector/src/docbot.ts +56 -11
  9. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  10. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  11. package/boat/doc-collector/src/screenshots.ts +106 -3
  12. package/boat/doc-collector/src/template-dedup.ts +31 -0
  13. package/boat/prima/src/cli.ts +3 -0
  14. package/boat/prima/src/prima.ts +3 -4
  15. package/dist/bin/explorbot-cli.js +14 -4
  16. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  17. package/dist/boat/api-tester/src/cli.js +2 -0
  18. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  19. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  20. package/dist/boat/doc-collector/src/cli.js +15 -2
  21. package/dist/boat/doc-collector/src/config.js +1 -0
  22. package/dist/boat/doc-collector/src/docbot.js +52 -10
  23. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  24. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  25. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  26. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  27. package/dist/boat/prima/src/cli.js +2 -0
  28. package/dist/boat/prima/src/prima.js +3 -4
  29. package/dist/package.json +1 -1
  30. package/dist/src/action-result.d.ts +2 -2
  31. package/dist/src/action-result.js +15 -17
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +15 -15
  34. package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
  35. package/dist/src/ai/fisherman/request-haul.js +31 -0
  36. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  37. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
  38. package/dist/src/ai/fisherman.d.ts +14 -4
  39. package/dist/src/ai/fisherman.js +96 -18
  40. package/dist/src/ai/pilot.d.ts +13 -1
  41. package/dist/src/ai/pilot.js +40 -22
  42. package/dist/src/ai/provider.js +8 -14
  43. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  44. package/dist/src/ai/researcher/coordinates.js +13 -1
  45. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  46. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  47. package/dist/src/ai/researcher.js +1 -1
  48. package/dist/src/ai/rules.js +10 -0
  49. package/dist/src/ai/tester.js +20 -20
  50. package/dist/src/ai/tools.d.ts +1 -1
  51. package/dist/src/ai/tools.js +18 -74
  52. package/dist/src/api/request-result.js +3 -1
  53. package/dist/src/api/request-store.d.ts +7 -6
  54. package/dist/src/api/request-store.js +65 -37
  55. package/dist/src/api/xhr-capture.d.ts +2 -0
  56. package/dist/src/api/xhr-capture.js +35 -10
  57. package/dist/src/commands/config-command.js +2 -9
  58. package/dist/src/commands/help-json-command.d.ts +31 -0
  59. package/dist/src/commands/help-json-command.js +58 -0
  60. package/dist/src/commands/index.js +2 -0
  61. package/dist/src/commands/init-command.js +3 -7
  62. package/dist/src/commands/plans-command.d.ts +4 -1
  63. package/dist/src/commands/plans-command.js +4 -4
  64. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  65. package/dist/src/commands/recommended-models-command.js +85 -0
  66. package/dist/src/config.d.ts +2 -0
  67. package/dist/src/config.js +7 -2
  68. package/dist/src/experience-tracker.js +1 -1
  69. package/dist/src/explorbot.js +3 -3
  70. package/dist/src/global-config.d.ts +2 -0
  71. package/dist/src/global-config.js +6 -0
  72. package/dist/src/state-manager.d.ts +3 -3
  73. package/dist/src/state-manager.js +8 -8
  74. package/dist/src/test-plan.d.ts +1 -0
  75. package/dist/src/test-plan.js +13 -9
  76. package/dist/src/utils/aria.d.ts +1 -1
  77. package/dist/src/utils/aria.js +23 -2
  78. package/dist/src/utils/cli-section.d.ts +1 -0
  79. package/dist/src/utils/cli-section.js +12 -0
  80. package/dist/src/utils/html-diff.d.ts +1 -0
  81. package/dist/src/utils/html-diff.js +5 -5
  82. package/dist/src/utils/html.d.ts +2 -0
  83. package/dist/src/utils/html.js +5 -4
  84. package/dist/src/utils/overlay.d.ts +19 -55
  85. package/dist/src/utils/overlay.js +94 -148
  86. package/dist/src/utils/region.d.ts +41 -0
  87. package/dist/src/utils/region.js +99 -0
  88. package/dist/src/utils/similarity.d.ts +1 -0
  89. package/dist/src/utils/similarity.js +11 -0
  90. package/dist/src/utils/url-matcher.d.ts +1 -1
  91. package/dist/src/utils/url-matcher.js +4 -4
  92. package/docs/doc-collection/basics.md +11 -5
  93. package/docs/doc-collection/crawling.md +21 -1
  94. package/docs/reference/commands.md +4 -0
  95. package/docs/reference/configuration.md +4 -0
  96. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  97. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  98. package/docs/workflow/agentic-usage.md +12 -0
  99. package/docs/workflow/application-spec.md +4 -0
  100. package/package.json +1 -1
  101. package/src/action-result.ts +15 -16
  102. package/src/action.ts +16 -15
  103. package/src/ai/fisherman/request-haul.ts +36 -0
  104. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
  105. package/src/ai/fisherman.ts +110 -20
  106. package/src/ai/pilot.ts +38 -20
  107. package/src/ai/provider.ts +8 -12
  108. package/src/ai/researcher/coordinates.ts +13 -1
  109. package/src/ai/researcher/deep-analysis.ts +9 -9
  110. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  111. package/src/ai/researcher.ts +1 -1
  112. package/src/ai/rules.ts +10 -0
  113. package/src/ai/tester.ts +18 -18
  114. package/src/ai/tools.ts +18 -86
  115. package/src/api/request-result.ts +2 -1
  116. package/src/api/request-store.ts +68 -42
  117. package/src/api/xhr-capture.ts +39 -11
  118. package/src/commands/config-command.ts +2 -7
  119. package/src/commands/help-json-command.ts +74 -0
  120. package/src/commands/index.ts +2 -0
  121. package/src/commands/init-command.ts +4 -11
  122. package/src/commands/plans-command.ts +5 -5
  123. package/src/commands/recommended-models-command.ts +92 -0
  124. package/src/config.ts +9 -2
  125. package/src/experience-tracker.ts +1 -1
  126. package/src/explorbot.ts +3 -3
  127. package/src/global-config.ts +8 -0
  128. package/src/state-manager.ts +10 -9
  129. package/src/test-plan.ts +12 -10
  130. package/src/utils/aria.ts +26 -3
  131. package/src/utils/cli-section.ts +12 -0
  132. package/src/utils/html-diff.ts +6 -6
  133. package/src/utils/html.ts +6 -4
  134. package/src/utils/overlay.ts +105 -192
  135. package/src/utils/region.ts +132 -0
  136. package/src/utils/similarity.ts +9 -0
  137. package/src/utils/url-matcher.ts +3 -3
  138. package/dist/src/ai/fisherman-tools.d.ts +0 -138
  139. package/dist/src/utils/request-map.d.ts +0 -7
  140. package/dist/src/utils/request-map.js +0 -16
  141. package/src/utils/request-map.ts +0 -19
@@ -0,0 +1,50 @@
1
+ import type { ApiClient } from '../../api/api-client.js';
2
+ import type { RequestStore } from '../../api/request-store.js';
3
+ import type { Test } from '../../test-plan.js';
4
+ import type { Fisherman } from '../fisherman.js';
5
+ import type { RequestHaul } from './request-haul.js';
6
+ export declare function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: {
7
+ spec?: any;
8
+ baseEndpoint?: string;
9
+ readOnly?: boolean;
10
+ }): {
11
+ tools: Record<string, any>;
12
+ getResult: () => FishermanResult;
13
+ isFinished: () => boolean;
14
+ finishFromText: (text?: string) => void;
15
+ };
16
+ export declare function createAskApiTool(fisherman: Fisherman | null, task: Test): {
17
+ askApi: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
18
+ question: any;
19
+ }, {
20
+ answered: boolean;
21
+ reason: string;
22
+ answer?: undefined;
23
+ } | {
24
+ answered: boolean;
25
+ answer: string;
26
+ reason?: undefined;
27
+ }, import("@ai-sdk/provider-utils").Context>>;
28
+ };
29
+ export declare function verifyFinish(haul: RequestHaul, input: {
30
+ summary: string;
31
+ created: FishermanResult['created'];
32
+ failed?: FishermanResult['failed'];
33
+ }): {
34
+ result: FishermanResult | null;
35
+ error?: string;
36
+ };
37
+ export interface FishermanResult {
38
+ success: boolean;
39
+ summary: string;
40
+ created: Array<{
41
+ type: string;
42
+ id?: string | number;
43
+ title?: string;
44
+ request?: string;
45
+ }>;
46
+ failed: Array<{
47
+ type: string;
48
+ reason: string;
49
+ }>;
50
+ }
@@ -1,21 +1,22 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
- import { extractEndpointDefinition } from "../api/spec-reader.js";
5
- import { tag } from "../utils/logger.js";
6
- import { RequestMap } from "../utils/request-map.js";
7
- import { isDynamicSegment } from "../utils/url-matcher.js";
8
- export function createFishermanTools(apiClient, requestStore, opts) {
4
+ import { extractEndpointDefinition } from "../../api/spec-reader.js";
5
+ import { tag } from "../../utils/logger.js";
6
+ import { isDynamicSegment } from "../../utils/url-matcher.js";
7
+ const BODY_PREVIEW_LIMIT = 2000;
8
+ export function createFishermanTools(apiClient, requestStore, haul, opts) {
9
+ const readOnly = opts.readOnly === true;
9
10
  let finished = false;
10
11
  let result = null;
11
- const ledgerStart = requestStore.getMadeRequests().length;
12
- const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
13
- const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
14
- const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
12
+ let allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
13
+ if (readOnly)
14
+ allowedMethods = ['GET'];
15
+ const getResult = () => result ?? synthesizeResult(haul, false, readOnly);
15
16
  const isFinished = () => finished;
16
17
  const finishFromText = (text) => {
17
18
  finished = true;
18
- const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
19
+ const synthesized = synthesizeResult(haul, true, readOnly);
19
20
  if (text && synthesized.success)
20
21
  synthesized.summary = text;
21
22
  result = synthesized;
@@ -28,12 +29,14 @@ export function createFishermanTools(apiClient, requestStore, opts) {
28
29
  Call this before making a request to an endpoint you haven't used before.
29
30
  `,
30
31
  inputSchema: z.object({
31
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
32
+ method: z.enum(allowedMethods).describe('HTTP method'),
32
33
  path: z.string().describe('Endpoint path, e.g. /suites'),
33
34
  }),
34
35
  execute: async ({ method, path }) => {
35
36
  tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
36
- const captured = requestStore.findCapturedRequest(method, path);
37
+ let captured = requestStore.findCapturedRequest(method, path);
38
+ if (captured && !captured.requestBody && opts.spec && captured.status < 400)
39
+ captured = undefined;
37
40
  if (captured) {
38
41
  if (captured.status >= 400) {
39
42
  const rejectedCapture = {
@@ -84,7 +87,7 @@ export function createFishermanTools(apiClient, requestStore, opts) {
84
87
  Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
85
88
  `,
86
89
  inputSchema: z.object({
87
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
90
+ method: z.enum(allowedMethods).describe('HTTP method'),
88
91
  path: z.string().describe('API path (e.g., /suites, /suites/1)'),
89
92
  body: z.any().optional().describe('Request body (JSON object)'),
90
93
  queryParams: z.record(z.string(), z.string()).optional().describe('Query parameters'),
@@ -115,11 +118,14 @@ export function createFishermanTools(apiClient, requestStore, opts) {
115
118
  }
116
119
  const extracted = extractKeyFields(reqResult.responseBody);
117
120
  tag('success').log(`Fisherman: ${input.method} ${input.path} > ${statusLine}`);
118
- return {
121
+ const output = {
119
122
  success: true,
120
123
  status: reqResult.status,
121
124
  extracted,
122
125
  };
126
+ if (readOnly)
127
+ output.bodyPreview = reqResult.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT);
128
+ return output;
123
129
  },
124
130
  }),
125
131
  finish: tool({
@@ -142,30 +148,12 @@ export function createFishermanTools(apiClient, requestStore, opts) {
142
148
  .describe('List of items that could not be created'),
143
149
  }),
144
150
  execute: async ({ summary, created, failed }) => {
145
- const writes = successfulWrites();
146
- if (writes.length === 0) {
147
- tag('warning').log('Fisherman: finish rejected no successful write request in this run');
148
- return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
149
- }
150
- const createdRequests = new RequestMap(writes);
151
- const verified = [];
152
- for (const item of created) {
153
- if (item.id === undefined) {
154
- verified.push(item);
155
- continue;
156
- }
157
- const request = createdRequests.get(item.id);
158
- if (!request) {
159
- tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
160
- continue;
161
- }
162
- verified.push({ ...item, request: request.toEndpoint() });
163
- }
164
- if (verified.length === 0)
165
- verified.push(...writes.map(toCreatedItem));
151
+ const { result: verified, error } = verifyFinish(haul, { summary, created, failed });
152
+ if (!verified)
153
+ return { finished: false, error };
166
154
  tag('success').log(`Fisherman done: ${summary}`);
167
155
  finished = true;
168
- result = { success: true, summary, created: verified, failed: failed || [] };
156
+ result = verified;
169
157
  return { finished: true };
170
158
  },
171
159
  }),
@@ -182,15 +170,96 @@ export function createFishermanTools(apiClient, requestStore, opts) {
182
170
  },
183
171
  }),
184
172
  };
173
+ if (readOnly) {
174
+ tools.finish = tool({
175
+ description: 'Report the answer to the question. Call when the requests have shown what exists.',
176
+ inputSchema: z.object({
177
+ answer: z.string().describe('What the data shows, quoting the concrete names, titles and ids that were returned'),
178
+ }),
179
+ execute: async ({ answer }) => {
180
+ if (haul.successfulReads().length === 0) {
181
+ tag('warning').log('Fisherman: finish rejected — no successful request in this run');
182
+ 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.' };
183
+ }
184
+ tag('success').log(`Fisherman answered: ${answer}`);
185
+ finished = true;
186
+ result = { success: true, summary: answer, created: [], failed: [] };
187
+ return { finished: true };
188
+ },
189
+ });
190
+ }
185
191
  return { tools, getResult, isFinished, finishFromText };
186
192
  }
187
- function synthesizeResult(made, writes, declaredDone) {
188
- const failures = made.filter((r) => r.status >= 400 || r.error);
189
- let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
193
+ export function createAskApiTool(fisherman, task) {
194
+ return {
195
+ askApi: tool({
196
+ description: dedent `
197
+ Ask what data already exists, changing nothing.
198
+ Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
199
+ 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.
200
+ It never creates, edits or deletes anything — precondition() does that.
201
+ `,
202
+ inputSchema: z.object({
203
+ question: z.string().describe('What to find out about data that already exists'),
204
+ }),
205
+ execute: async ({ question }) => {
206
+ tag('info').log(`Ask API: ${question}`);
207
+ if (!fisherman?.isAvailable()) {
208
+ return { answered: false, reason: 'No API access is configured, so existing data cannot be queried. Judge from the page instead.' };
209
+ }
210
+ const result = await fisherman.lookupData(question, task.startUrl, task.sessionName);
211
+ if (!result.success) {
212
+ tag('warning').log(`Ask API unanswered: ${result.summary}`);
213
+ return { answered: false, reason: result.summary || 'The API could not answer this question' };
214
+ }
215
+ task.addNote(`Asked API: ${question} — ${result.summary}`);
216
+ tag('success').log(`Ask API: ${result.summary}`);
217
+ return { answered: true, answer: result.summary };
218
+ },
219
+ }),
220
+ };
221
+ }
222
+ export function verifyFinish(haul, input) {
223
+ const writes = haul.successfulWrites();
224
+ if (writes.length === 0) {
225
+ tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
226
+ return { result: null, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
227
+ }
228
+ const createdRequests = haul.byId();
229
+ const verified = [];
230
+ for (const item of input.created) {
231
+ if (item.id === undefined) {
232
+ verified.push(item);
233
+ continue;
234
+ }
235
+ const request = createdRequests.get(String(item.id));
236
+ if (!request) {
237
+ tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
238
+ continue;
239
+ }
240
+ verified.push({ ...item, request: request.toEndpoint() });
241
+ }
242
+ if (verified.length === 0)
243
+ verified.push(...writes.map(toCreatedItem));
244
+ return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
245
+ }
246
+ function synthesizeResult(haul, declaredDone, readOnly) {
247
+ const made = haul.requests();
248
+ const failures = haul.failed();
249
+ let succeeded = haul.successfulWrites();
250
+ let successLabel = 'successful writes';
251
+ if (readOnly) {
252
+ succeeded = haul.successfulReads();
253
+ successLabel = 'successful reads';
254
+ }
255
+ let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
190
256
  const lastFailure = failures[failures.length - 1];
191
257
  if (lastFailure)
192
258
  summary += `; last failure: ${lastFailure.toSummary()}`;
193
- return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
259
+ const result = { success: declaredDone && succeeded.length > 0, summary, created: [], failed: [] };
260
+ if (!readOnly)
261
+ result.created = succeeded.map(toCreatedItem);
262
+ return result;
194
263
  }
195
264
  function toCreatedItem(write) {
196
265
  const { id, title } = write.extractIdAndTitle();
@@ -1,7 +1,9 @@
1
1
  import type { ApiClient } from '../api/api-client.js';
2
- import type { RequestStore } from '../api/request-store.js';
2
+ import { type EndpointFamily, type RequestStore } from '../api/request-store.js';
3
3
  import type { Agent } from './agent.js';
4
- import { type FishermanResult } from './fisherman-tools.js';
4
+ import type { Conversation } from './conversation.js';
5
+ import { RequestHaul } from './fisherman/request-haul.js';
6
+ import { type FishermanResult } from './fisherman/tools.js';
5
7
  import type { Provider } from './provider.js';
6
8
  export declare class Fisherman implements Agent {
7
9
  emoji: string;
@@ -22,10 +24,18 @@ export declare class Fisherman implements Agent {
22
24
  ensureReady(scopeUrl?: string): Promise<void>;
23
25
  getEndpointList(scopeUrl?: string): string;
24
26
  prepareData(instructions: string, scopeUrl?: string, sessionName?: string): Promise<FishermanResult>;
27
+ lookupData(question: string, scopeUrl?: string, sessionName?: string): Promise<FishermanResult>;
28
+ runSession(conversation: Conversation, tools: Record<string, any>, opts: {
29
+ haul: RequestHaul;
30
+ isFinished: () => boolean;
31
+ finishFromText: (text?: string) => void;
32
+ label: string;
33
+ }): Promise<void>;
25
34
  detectMode(scopeUrl?: string): Promise<void>;
26
35
  refreshAuth(): Promise<void>;
27
- buildEndpointList(scopeUrl?: string): string;
36
+ buildEndpointList(scopeUrl?: string, family?: EndpointFamily): string;
28
37
  buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
29
- isStuckOnEndpoint(ledgerStart: number): boolean;
38
+ buildLookupSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
39
+ isStuckOnEndpoint(haul: RequestHaul): boolean;
30
40
  buildTaskPrompt(instructions: string): string;
31
41
  }
@@ -1,9 +1,11 @@
1
1
  import dedent from 'dedent';
2
+ import { isFailedRequest } from "../api/request-store.js";
2
3
  import { listAllEndpoints } from "../api/spec-reader.js";
3
4
  import { createDebug, tag } from "../utils/logger.js";
4
5
  const debugLog = createDebug('explorbot:fisherman');
5
6
  import { loop } from "../utils/loop.js";
6
- import { createFishermanTools } from "./fisherman-tools.js";
7
+ import { RequestHaul } from "./fisherman/request-haul.js";
8
+ import { createFishermanTools } from "./fisherman/tools.js";
7
9
  import { dataProtectionRules } from "./rules.js";
8
10
  const MAX_ITERATIONS = 15;
9
11
  const MAX_TOOL_ROUNDTRIPS = 5;
@@ -56,18 +58,58 @@ export class Fisherman {
56
58
  debugLog(`endpoints:\n${endpointList || '(none)'}`);
57
59
  if (!endpointList) {
58
60
  tag('warning').log('Fisherman: no endpoints available');
59
- this.mode = 'disabled';
60
61
  return { success: false, summary: 'No API endpoints available', created: [], failed: [] };
61
62
  }
62
63
  await this.refreshAuth();
63
64
  debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
64
- const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, {
65
+ const haul = new RequestHaul(this.requestStore);
66
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
65
67
  spec: this.spec,
66
68
  baseEndpoint: this.baseEndpoint,
67
69
  });
68
- const ledgerStart = this.requestStore.getMadeRequests().length;
69
70
  const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
70
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) {
71
113
  await loop(async ({ stop, iteration }) => {
72
114
  debugLog(`iteration ${iteration}`);
73
115
  const invokeResult = await this.provider.invokeConversation(conversation, tools, {
@@ -75,17 +117,17 @@ export class Fisherman {
75
117
  agentName: 'fisherman',
76
118
  });
77
119
  debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
78
- if (isFinished()) {
120
+ if (opts.isFinished()) {
79
121
  stop();
80
122
  return;
81
123
  }
82
124
  if (!invokeResult?.toolExecutions?.length) {
83
125
  debugLog('no tool call in this turn — treating as finish');
84
- finishFromText(invokeResult?.response?.text);
126
+ opts.finishFromText(invokeResult?.response?.text);
85
127
  stop();
86
128
  return;
87
129
  }
88
- if (this.isStuckOnEndpoint(ledgerStart)) {
130
+ if (this.isStuckOnEndpoint(opts.haul)) {
89
131
  tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
90
132
  stop();
91
133
  return;
@@ -97,7 +139,7 @@ export class Fisherman {
97
139
  }, {
98
140
  maxAttempts: MAX_ITERATIONS,
99
141
  observability: {
100
- name: `fisherman: ${instructions.slice(0, 50)}`,
142
+ name: opts.label,
101
143
  agent: 'fisherman',
102
144
  sessionId: this.sessionName,
103
145
  },
@@ -107,9 +149,6 @@ export class Fisherman {
107
149
  stop();
108
150
  },
109
151
  });
110
- const result = getResult();
111
- tag('info').log(`Fisherman result: ${result.summary}`);
112
- return result;
113
152
  }
114
153
  async detectMode(scopeUrl) {
115
154
  if (this.hasApiConfig) {
@@ -141,18 +180,20 @@ export class Fisherman {
141
180
  this.apiClient.setHeaders(this.configHeaders);
142
181
  }
143
182
  }
144
- buildEndpointList(scopeUrl) {
183
+ buildEndpointList(scopeUrl, family = 'write') {
145
184
  this.scopeDegraded = false;
146
185
  if (this.mode === 'achieve' && this.spec) {
147
- const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
186
+ let specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
187
+ if (family === 'read')
188
+ specEndpoints = keepReadLines(specEndpoints);
148
189
  if (specEndpoints)
149
190
  return specEndpoints;
150
191
  }
151
- const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
192
+ const scoped = this.requestStore.toEndpointList(scopeUrl || '/', family);
152
193
  if (scoped)
153
194
  return scoped;
154
195
  this.scopeDegraded = true;
155
- return this.requestStore.toEndpointList();
196
+ return this.requestStore.toEndpointList(undefined, family);
156
197
  }
157
198
  buildSystemPrompt(endpointList, toolNames, scopeUrl) {
158
199
  let scopeBlock = '';
@@ -190,13 +231,44 @@ export class Fisherman {
190
231
  ${dataProtectionRules}
191
232
  `;
192
233
  }
193
- isStuckOnEndpoint(ledgerStart) {
194
- const made = this.requestStore.getMadeRequests().slice(ledgerStart);
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
+ }
265
+ isStuckOnEndpoint(haul) {
266
+ const made = haul.requests();
195
267
  if (made.length < REPEATED_FAILURE_LIMIT)
196
268
  return false;
197
269
  const recent = made.slice(-REPEATED_FAILURE_LIMIT);
198
270
  const first = recent[0];
199
- return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
271
+ return recent.every((r) => isFailedRequest(r) && r.method === first.method && r.path === first.path);
200
272
  }
201
273
  buildTaskPrompt(instructions) {
202
274
  return dedent `
@@ -211,3 +283,9 @@ export class Fisherman {
211
283
  `;
212
284
  }
213
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";
@@ -19,6 +20,7 @@ const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
19
20
  const PILOT_REASONING_LIMIT = 500;
20
21
  const PILOT_MESSAGE_LIMIT = 2;
21
22
  const PILOT_MESSAGE_MAX_LENGTH = 160;
23
+ const PILOT_REQUEST_LIMIT = 5;
22
24
  export class Pilot {
23
25
  emoji = '🧭';
24
26
  provider;
@@ -397,7 +399,8 @@ export class Pilot {
397
399
 
398
400
  Plan the test execution for this scenario.
399
401
 
400
- 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.
401
404
 
402
405
  Call precondition() WHEN:
403
406
  - The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
@@ -602,7 +605,7 @@ export class Pilot {
602
605
  finalUserText = `${tocBlock}\n\n${userText}`;
603
606
  }
604
607
  this.conversation.addUserText(finalUserText);
605
- const tools = { ...this.pickPlanningTools(), ...this.buildPreconditionTool(opts.task) };
608
+ const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
606
609
  const result = await this.provider.invokeConversation(this.conversation, tools, {
607
610
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
608
611
  toolChoice: opts.tools ? 'auto' : 'none',
@@ -654,7 +657,12 @@ export class Pilot {
654
657
  withdrawVisionTools(planning);
655
658
  return planning;
656
659
  }
657
- buildPreconditionTool(task) {
660
+ fishermanStatus() {
661
+ if (this.fisherman?.isAvailable())
662
+ return 'available';
663
+ return 'none';
664
+ }
665
+ buildFishermanTools(task) {
658
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.';
659
667
  return {
660
668
  precondition: tool({
@@ -665,7 +673,7 @@ export class Pilot {
665
673
  execute: async ({ description }) => {
666
674
  task.addNote(`Precondition: ${description}`);
667
675
  tag('info').log(`Precondition: ${description}`);
668
- debugLog(`precondition: ${description}, fisherman: ${this.fisherman?.isAvailable() ? 'available' : 'none'}`);
676
+ debugLog(`precondition: ${description}, fisherman: ${this.fishermanStatus()}`);
669
677
  if (!this.fisherman || !this.fisherman.isAvailable()) {
670
678
  const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
671
679
  if (skipReason)
@@ -697,6 +705,7 @@ export class Pilot {
697
705
  return { noted: true, prepared: true, created: result.created };
698
706
  },
699
707
  }),
708
+ ...createAskApiTool(this.fisherman, task),
700
709
  };
701
710
  }
702
711
  async checkDataAvailability(task, requestedData, fishermanReason) {
@@ -741,21 +750,21 @@ export class Pilot {
741
750
  lines.push(`h2: ${state.h2 || ''}`);
742
751
  lines.push(`h3: ${state.h3 || ''}`);
743
752
  lines.push(`h4: ${state.h4 || ''}`);
744
- const focusArea = state.overlay;
745
- if (focusArea.detected) {
746
- let line = `modal: ${focusArea.name || focusArea.type}`;
747
- if (focusArea.root)
748
- line += ` (root: ${focusArea.root})`;
753
+ const region = state.overlay;
754
+ if (region.isModal) {
755
+ let line = `overlay: ${region.name || region.type}`;
756
+ if (region.root)
757
+ line += ` (root: ${region.root})`;
749
758
  lines.push(line);
750
759
  }
751
- else if (focusArea.present) {
752
- let line = `region: ${focusArea.name || 'unnamed'} (inline`;
753
- if (focusArea.root)
754
- line += `, root: ${focusArea.root}`;
760
+ else if (region.isOpen) {
761
+ let line = `region: ${region.name || 'unnamed'} (inline`;
762
+ if (region.root)
763
+ line += `, root: ${region.root}`;
755
764
  lines.push(`${line})`);
756
765
  }
757
766
  else {
758
- lines.push('modal: none');
767
+ lines.push('overlay: none');
759
768
  }
760
769
  const tabs = this.stateManager.otherTabs;
761
770
  if (tabs.length > 0) {
@@ -971,9 +980,13 @@ export class Pilot {
971
980
  line += `\n ${ariaDiff}`;
972
981
  if (t.output?.pageDiff?.urlChanged)
973
982
  line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
974
- const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r) => r.status >= 400);
975
- if (failedRequests.length > 0) {
976
- line += `\n requests: ${failedRequests.map((r) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
983
+ const pageRequests = t.output?.pageDiff?.requests ?? [];
984
+ const requests = pageRequests
985
+ .filter((r) => r.status >= 400)
986
+ .concat(pageRequests.filter((r) => r.status < 400))
987
+ .slice(0, PILOT_REQUEST_LIMIT);
988
+ if (requests.length > 0) {
989
+ line += `\n requests: ${requests.map((r) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
977
990
  }
978
991
  const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
979
992
  if (messages.length > 0) {
@@ -1034,15 +1047,15 @@ export class Pilot {
1034
1047
 
1035
1048
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1036
1049
  - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1037
- - "modal: none" but Tester targets a modalmodal closed; re-trigger.
1050
+ - "overlay: none" but Tester targets an overlayoverlay closed; re-trigger.
1038
1051
  - "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
1039
1052
  - Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
1040
- - MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
1053
+ - MultipleElementsFound → nothing was clicked. Tell Tester to reuse the same locator with step.opts({ elementIndex: N }) from the numbered elements list.
1041
1054
  - Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
1042
1055
  - Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
1043
1056
  - form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
1044
1057
  - ariaDiff shows 5+ added/removed → page entered new mode (editor/modal); call context() before guessing selectors.
1045
- - Empty dropdown/list when items expected → missing data; call precondition() to create it.
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.
1046
1059
  - Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
1047
1060
  - Multi-action explanation in one tool call → instruct Tester to split.
1048
1061
 
@@ -1058,8 +1071,13 @@ export class Pilot {
1058
1071
 
1059
1072
  ${capabilityGroundingRule}
1060
1073
 
1061
- YOUR Pilot-only tool: precondition(description) create FRESH disposable test data via API. Never
1062
- 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:
1063
1081
 
1064
1082
  - Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
1065
1083
  - Scenario needs auxiliary data (labels, categories, statuses for filtering).