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.
- package/bin/explorbot-cli.ts +6 -1
- package/boat/api-tester/src/apibot.ts +8 -13
- package/boat/api-tester/src/cli.ts +7 -3
- package/boat/api-tester/src/config.ts +45 -9
- package/boat/prima/src/cli.ts +33 -99
- package/boat/prima/src/envelope.ts +3 -1
- package/boat/prima/src/help.ts +72 -0
- package/boat/prima/src/prima.ts +33 -43
- package/dist/bin/explorbot-cli.js +5 -1
- package/dist/boat/api-tester/src/apibot.js +7 -6
- package/dist/boat/api-tester/src/cli.js +9 -3
- package/dist/boat/api-tester/src/config.js +32 -6
- package/dist/boat/prima/src/cli.js +30 -86
- package/dist/boat/prima/src/envelope.js +2 -1
- package/dist/boat/prima/src/help.js +63 -0
- package/dist/boat/prima/src/prima.js +29 -41
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +3 -0
- package/dist/src/action-result.js +5 -0
- package/dist/src/action.js +12 -1
- package/dist/src/ai/fisherman/request-haul.d.ts +1 -0
- package/dist/src/ai/fisherman/request-haul.js +3 -0
- package/dist/src/ai/fisherman/tools.d.ts +50 -0
- package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +78 -13
- package/dist/src/ai/fisherman.d.ts +12 -3
- package/dist/src/ai/fisherman.js +89 -13
- package/dist/src/ai/pilot.d.ts +13 -1
- package/dist/src/ai/pilot.js +20 -7
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +4 -1
- package/dist/src/ai/researcher/sections.d.ts +1 -1
- package/dist/src/ai/researcher/sections.js +2 -1
- package/dist/src/ai/researcher.js +25 -11
- package/dist/src/ai/rules.js +2 -0
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +5 -0
- package/dist/src/api/request-result.js +3 -1
- package/dist/src/api/request-store.d.ts +6 -1
- package/dist/src/api/request-store.js +55 -17
- package/dist/src/api/xhr-capture.d.ts +2 -0
- package/dist/src/api/xhr-capture.js +35 -10
- package/dist/src/commands/config-command.js +6 -2
- package/dist/src/commands/help-json-command.d.ts +31 -0
- package/dist/src/commands/help-json-command.js +58 -0
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +14 -0
- package/dist/src/state-manager.js +5 -1
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +2 -0
- package/docs/reference/configuration.md +4 -0
- package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
- package/docs/workflow/agentic-usage.md +15 -1
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- package/src/ai/fisherman/request-haul.ts +4 -0
- package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +93 -20
- package/src/ai/fisherman.ts +104 -15
- package/src/ai/pilot.ts +20 -7
- package/src/ai/researcher/deep-analysis.ts +4 -2
- package/src/ai/researcher/sections.ts +2 -2
- package/src/ai/researcher.ts +28 -11
- package/src/ai/rules.ts +2 -0
- package/src/ai/tester.ts +25 -30
- package/src/ai/tools.ts +6 -0
- package/src/api/request-result.ts +2 -1
- package/src/api/request-store.ts +58 -18
- package/src/api/xhr-capture.ts +39 -11
- package/src/commands/config-command.ts +4 -1
- package/src/commands/help-json-command.ts +74 -0
- package/src/config.ts +16 -0
- package/src/state-manager.ts +6 -1
- 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
|
|
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
package/src/action-result.ts
CHANGED
|
@@ -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)
|
|
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 '
|
|
5
|
-
import type { RequestResult } from '
|
|
6
|
-
import type { RequestStore } from '
|
|
7
|
-
import { extractEndpointDefinition } from '
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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] {
|
package/src/ai/fisherman.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
1172
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
69
|
+
const focusSections = agentSettings(this.config, 'researcher').focusSections;
|
|
70
70
|
if (!focusSections?.length) return null;
|
|
71
71
|
|
|
72
72
|
for (const css of focusSections) {
|