explorbot 0.4.2 → 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 (34) hide show
  1. package/bin/explorbot-cli.ts +6 -1
  2. package/dist/bin/explorbot-cli.js +5 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/fisherman/request-haul.d.ts +1 -0
  5. package/dist/src/ai/fisherman/request-haul.js +3 -0
  6. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  7. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +78 -13
  8. package/dist/src/ai/fisherman.d.ts +12 -3
  9. package/dist/src/ai/fisherman.js +89 -13
  10. package/dist/src/ai/pilot.d.ts +13 -1
  11. package/dist/src/ai/pilot.js +20 -7
  12. package/dist/src/ai/rules.js +2 -0
  13. package/dist/src/api/request-result.js +3 -1
  14. package/dist/src/api/request-store.d.ts +6 -1
  15. package/dist/src/api/request-store.js +55 -17
  16. package/dist/src/api/xhr-capture.d.ts +2 -0
  17. package/dist/src/api/xhr-capture.js +35 -10
  18. package/dist/src/commands/help-json-command.d.ts +31 -0
  19. package/dist/src/commands/help-json-command.js +58 -0
  20. package/docs/reference/commands.md +1 -0
  21. package/docs/reference/configuration.md +4 -0
  22. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  23. package/docs/workflow/agentic-usage.md +12 -0
  24. package/package.json +1 -1
  25. package/src/ai/fisherman/request-haul.ts +4 -0
  26. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +93 -20
  27. package/src/ai/fisherman.ts +104 -15
  28. package/src/ai/pilot.ts +20 -7
  29. package/src/ai/rules.ts +2 -0
  30. package/src/api/request-result.ts +2 -1
  31. package/src/api/request-store.ts +58 -18
  32. package/src/api/xhr-capture.ts +39 -11
  33. package/src/commands/help-json-command.ts +74 -0
  34. package/dist/src/ai/fisherman-tools.d.ts +0 -147
@@ -8,6 +8,7 @@ import figureSet from 'figures';
8
8
  import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { flushTelemetry } from '../src/ai/provider.js';
11
+ import { HelpJsonCommand } from '../src/commands/help-json-command.js';
11
12
  import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
12
13
  import { App } from '../src/components/App.js';
13
14
  import { StatusPane } from '../src/components/StatusPane.js';
@@ -43,7 +44,9 @@ process.on('unhandledRejection', (reason) => {
43
44
  tag('error').log(`Unhandled rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason)}`);
44
45
  });
45
46
 
46
- if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima')) {
47
+ const printsJson = process.argv.includes('--json') || process.argv.includes('help-json');
48
+
49
+ if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !printsJson) {
47
50
  console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
48
51
  }
49
52
 
@@ -952,6 +955,8 @@ ${rows}
952
955
  `;
953
956
  };
954
957
 
958
+ program.addHelpText('after', `\nFor agents and tools:\n ${cli} help-json [command...] the same definitions as JSON — commands, arguments, options, defaults\n`);
955
959
  program.addHelpText('after', envHelp);
960
+ HelpJsonCommand.register(program);
956
961
 
957
962
  program.parse();
@@ -8,6 +8,7 @@ import figureSet from 'figures';
8
8
  import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { flushTelemetry } from '../src/ai/provider.js';
11
+ import { HelpJsonCommand } from '../src/commands/help-json-command.js';
11
12
  import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
12
13
  import { App } from '../src/components/App.js';
13
14
  import { StatusPane } from '../src/components/StatusPane.js';
@@ -37,7 +38,8 @@ process.on('uncaughtException', async (error) => {
37
38
  process.on('unhandledRejection', (reason) => {
38
39
  tag('error').log(`Unhandled rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason)}`);
39
40
  });
40
- if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima')) {
41
+ const printsJson = process.argv.includes('--json') || process.argv.includes('help-json');
42
+ if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !printsJson) {
41
43
  console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
42
44
  }
43
45
  function buildExplorBotOptions(from, options) {
@@ -867,5 +869,7 @@ ${rows}
867
869
  ${cli} explore /login --max-tests 3
868
870
  `;
869
871
  };
872
+ program.addHelpText('after', `\nFor agents and tools:\n ${cli} help-json [command...] the same definitions as JSON — commands, arguments, options, defaults\n`);
870
873
  program.addHelpText('after', envHelp);
874
+ HelpJsonCommand.register(program);
871
875
  program.parse();
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -7,5 +7,6 @@ export declare class RequestHaul {
7
7
  requests(): RequestResult[];
8
8
  failed(): RequestResult[];
9
9
  successfulWrites(): RequestResult[];
10
+ successfulReads(): RequestResult[];
10
11
  byId(): Map<string, RequestResult>;
11
12
  }
@@ -15,6 +15,9 @@ export class RequestHaul {
15
15
  successfulWrites() {
16
16
  return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
17
17
  }
18
+ successfulReads() {
19
+ return this.requests().filter((r) => !r.isWrite && !r.error && r.status >= 200 && r.status < 400);
20
+ }
18
21
  byId() {
19
22
  const map = new Map();
20
23
  for (const request of this.successfulWrites()) {
@@ -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,17 +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 { isDynamicSegment } from "../utils/url-matcher.js";
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;
7
8
  export function createFishermanTools(apiClient, requestStore, haul, opts) {
9
+ const readOnly = opts.readOnly === true;
8
10
  let finished = false;
9
11
  let result = null;
10
- const getResult = () => result ?? synthesizeResult(haul, false);
12
+ let allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
13
+ if (readOnly)
14
+ allowedMethods = ['GET'];
15
+ const getResult = () => result ?? synthesizeResult(haul, false, readOnly);
11
16
  const isFinished = () => finished;
12
17
  const finishFromText = (text) => {
13
18
  finished = true;
14
- const synthesized = synthesizeResult(haul, true);
19
+ const synthesized = synthesizeResult(haul, true, readOnly);
15
20
  if (text && synthesized.success)
16
21
  synthesized.summary = text;
17
22
  result = synthesized;
@@ -24,12 +29,14 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
24
29
  Call this before making a request to an endpoint you haven't used before.
25
30
  `,
26
31
  inputSchema: z.object({
27
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
32
+ method: z.enum(allowedMethods).describe('HTTP method'),
28
33
  path: z.string().describe('Endpoint path, e.g. /suites'),
29
34
  }),
30
35
  execute: async ({ method, path }) => {
31
36
  tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
32
- 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;
33
40
  if (captured) {
34
41
  if (captured.status >= 400) {
35
42
  const rejectedCapture = {
@@ -80,7 +87,7 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
80
87
  Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
81
88
  `,
82
89
  inputSchema: z.object({
83
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
90
+ method: z.enum(allowedMethods).describe('HTTP method'),
84
91
  path: z.string().describe('API path (e.g., /suites, /suites/1)'),
85
92
  body: z.any().optional().describe('Request body (JSON object)'),
86
93
  queryParams: z.record(z.string(), z.string()).optional().describe('Query parameters'),
@@ -111,11 +118,14 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
111
118
  }
112
119
  const extracted = extractKeyFields(reqResult.responseBody);
113
120
  tag('success').log(`Fisherman: ${input.method} ${input.path} > ${statusLine}`);
114
- return {
121
+ const output = {
115
122
  success: true,
116
123
  status: reqResult.status,
117
124
  extracted,
118
125
  };
126
+ if (readOnly)
127
+ output.bodyPreview = reqResult.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT);
128
+ return output;
119
129
  },
120
130
  }),
121
131
  finish: tool({
@@ -160,8 +170,55 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
160
170
  },
161
171
  }),
162
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
+ }
163
191
  return { tools, getResult, isFinished, finishFromText };
164
192
  }
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
+ }
165
222
  export function verifyFinish(haul, input) {
166
223
  const writes = haul.successfulWrites();
167
224
  if (writes.length === 0) {
@@ -186,15 +243,23 @@ export function verifyFinish(haul, input) {
186
243
  verified.push(...writes.map(toCreatedItem));
187
244
  return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
188
245
  }
189
- function synthesizeResult(haul, declaredDone) {
246
+ function synthesizeResult(haul, declaredDone, readOnly) {
190
247
  const made = haul.requests();
191
- const writes = haul.successfulWrites();
192
248
  const failures = haul.failed();
193
- let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} 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`;
194
256
  const lastFailure = failures[failures.length - 1];
195
257
  if (lastFailure)
196
258
  summary += `; last failure: ${lastFailure.toSummary()}`;
197
- 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;
198
263
  }
199
264
  function toCreatedItem(write) {
200
265
  const { id, title } = write.extractIdAndTitle();
@@ -1,8 +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
5
  import { RequestHaul } from './fisherman/request-haul.js';
6
+ import { type FishermanResult } from './fisherman/tools.js';
6
7
  import type { Provider } from './provider.js';
7
8
  export declare class Fisherman implements Agent {
8
9
  emoji: string;
@@ -23,10 +24,18 @@ export declare class Fisherman implements Agent {
23
24
  ensureReady(scopeUrl?: string): Promise<void>;
24
25
  getEndpointList(scopeUrl?: string): string;
25
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>;
26
34
  detectMode(scopeUrl?: string): Promise<void>;
27
35
  refreshAuth(): Promise<void>;
28
- buildEndpointList(scopeUrl?: string): string;
36
+ buildEndpointList(scopeUrl?: string, family?: EndpointFamily): string;
29
37
  buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
38
+ buildLookupSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
30
39
  isStuckOnEndpoint(haul: RequestHaul): boolean;
31
40
  buildTaskPrompt(instructions: string): string;
32
41
  }
@@ -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).
@@ -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
@@ -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';