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.
- package/bin/explorbot-cli.ts +14 -4
- package/boat/api-tester/src/ai/curler-tools.ts +1 -1
- package/boat/api-tester/src/cli.ts +3 -0
- package/boat/doc-collector/src/ai/documentarian.ts +57 -5
- package/boat/doc-collector/src/ai/tools.ts +1 -1
- package/boat/doc-collector/src/cli.ts +18 -2
- package/boat/doc-collector/src/config.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +56 -11
- package/boat/doc-collector/src/docs-renderer.ts +79 -55
- package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
- package/boat/doc-collector/src/screenshots.ts +106 -3
- package/boat/doc-collector/src/template-dedup.ts +31 -0
- package/boat/prima/src/cli.ts +3 -0
- package/boat/prima/src/prima.ts +3 -4
- package/dist/bin/explorbot-cli.js +14 -4
- package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
- package/dist/boat/doc-collector/src/ai/tools.js +1 -1
- package/dist/boat/doc-collector/src/cli.js +15 -2
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +52 -10
- package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
- package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
- package/dist/boat/doc-collector/src/screenshots.js +104 -1
- package/dist/boat/doc-collector/src/template-dedup.js +27 -0
- package/dist/boat/prima/src/cli.js +2 -0
- package/dist/boat/prima/src/prima.js +3 -4
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +2 -2
- package/dist/src/action-result.js +15 -17
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +15 -15
- package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
- package/dist/src/ai/fisherman/request-haul.js +31 -0
- package/dist/src/ai/fisherman/tools.d.ts +50 -0
- package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
- package/dist/src/ai/fisherman.d.ts +14 -4
- package/dist/src/ai/fisherman.js +96 -18
- package/dist/src/ai/pilot.d.ts +13 -1
- package/dist/src/ai/pilot.js +40 -22
- package/dist/src/ai/provider.js +8 -14
- package/dist/src/ai/researcher/coordinates.d.ts +2 -0
- package/dist/src/ai/researcher/coordinates.js +13 -1
- package/dist/src/ai/researcher/deep-analysis.js +9 -9
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/rules.js +10 -0
- package/dist/src/ai/tester.js +20 -20
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +18 -74
- package/dist/src/api/request-result.js +3 -1
- package/dist/src/api/request-store.d.ts +7 -6
- package/dist/src/api/request-store.js +65 -37
- 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 +2 -9
- package/dist/src/commands/help-json-command.d.ts +31 -0
- package/dist/src/commands/help-json-command.js +58 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +3 -7
- package/dist/src/commands/plans-command.d.ts +4 -1
- package/dist/src/commands/plans-command.js +4 -4
- package/dist/src/commands/recommended-models-command.d.ts +9 -0
- package/dist/src/commands/recommended-models-command.js +85 -0
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +7 -2
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +3 -3
- package/dist/src/global-config.d.ts +2 -0
- package/dist/src/global-config.js +6 -0
- package/dist/src/state-manager.d.ts +3 -3
- package/dist/src/state-manager.js +8 -8
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +13 -9
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +23 -2
- package/dist/src/utils/cli-section.d.ts +1 -0
- package/dist/src/utils/cli-section.js +12 -0
- package/dist/src/utils/html-diff.d.ts +1 -0
- package/dist/src/utils/html-diff.js +5 -5
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +5 -4
- package/dist/src/utils/overlay.d.ts +19 -55
- package/dist/src/utils/overlay.js +94 -148
- package/dist/src/utils/region.d.ts +41 -0
- package/dist/src/utils/region.js +99 -0
- package/dist/src/utils/similarity.d.ts +1 -0
- package/dist/src/utils/similarity.js +11 -0
- package/dist/src/utils/url-matcher.d.ts +1 -1
- package/dist/src/utils/url-matcher.js +4 -4
- package/docs/doc-collection/basics.md +11 -5
- package/docs/doc-collection/crawling.md +21 -1
- package/docs/reference/commands.md +4 -0
- package/docs/reference/configuration.md +4 -0
- package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
- package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
- package/docs/workflow/agentic-usage.md +12 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +15 -16
- package/src/action.ts +16 -15
- package/src/ai/fisherman/request-haul.ts +36 -0
- package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
- package/src/ai/fisherman.ts +110 -20
- package/src/ai/pilot.ts +38 -20
- package/src/ai/provider.ts +8 -12
- package/src/ai/researcher/coordinates.ts +13 -1
- package/src/ai/researcher/deep-analysis.ts +9 -9
- package/src/ai/researcher/fingerprint-worker.ts +1 -10
- package/src/ai/researcher.ts +1 -1
- package/src/ai/rules.ts +10 -0
- package/src/ai/tester.ts +18 -18
- package/src/ai/tools.ts +18 -86
- package/src/api/request-result.ts +2 -1
- package/src/api/request-store.ts +68 -42
- package/src/api/xhr-capture.ts +39 -11
- package/src/commands/config-command.ts +2 -7
- package/src/commands/help-json-command.ts +74 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +4 -11
- package/src/commands/plans-command.ts +5 -5
- package/src/commands/recommended-models-command.ts +92 -0
- package/src/config.ts +9 -2
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +3 -3
- package/src/global-config.ts +8 -0
- package/src/state-manager.ts +10 -9
- package/src/test-plan.ts +12 -10
- package/src/utils/aria.ts +26 -3
- package/src/utils/cli-section.ts +12 -0
- package/src/utils/html-diff.ts +6 -6
- package/src/utils/html.ts +6 -4
- package/src/utils/overlay.ts +105 -192
- package/src/utils/region.ts +132 -0
- package/src/utils/similarity.ts +9 -0
- package/src/utils/url-matcher.ts +3 -3
- package/dist/src/ai/fisherman-tools.d.ts +0 -138
- package/dist/src/utils/request-map.d.ts +0 -7
- package/dist/src/utils/request-map.js +0 -16
- 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 "
|
|
5
|
-
import { tag } from "
|
|
6
|
-
import {
|
|
7
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const getResult = () => result ?? synthesizeResult(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
146
|
-
if (
|
|
147
|
-
|
|
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 =
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
2
|
+
import { type EndpointFamily, type RequestStore } from '../api/request-store.js';
|
|
3
3
|
import type { Agent } from './agent.js';
|
|
4
|
-
import {
|
|
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
|
-
|
|
38
|
+
buildLookupSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
|
|
39
|
+
isStuckOnEndpoint(haul: RequestHaul): boolean;
|
|
30
40
|
buildTaskPrompt(instructions: string): string;
|
|
31
41
|
}
|
package/dist/src/ai/fisherman.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/src/ai/pilot.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}, {
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
745
|
-
if (
|
|
746
|
-
let line = `
|
|
747
|
-
if (
|
|
748
|
-
line += ` (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 (
|
|
752
|
-
let line = `region: ${
|
|
753
|
-
if (
|
|
754
|
-
line += `, 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('
|
|
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
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
- "
|
|
1050
|
+
- "overlay: none" but Tester targets an overlay → overlay 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 →
|
|
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 →
|
|
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
|
|
1062
|
-
|
|
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).
|