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
package/src/action.ts
CHANGED
|
@@ -14,6 +14,7 @@ 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';
|
|
17
18
|
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
|
|
18
19
|
import { safeFilename } from './utils/strings.ts';
|
|
19
20
|
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
|
|
@@ -181,7 +182,7 @@ class Action {
|
|
|
181
182
|
focusedElement,
|
|
182
183
|
iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
|
|
183
184
|
});
|
|
184
|
-
if (!frame) await this.
|
|
185
|
+
if (!frame) await this.detectRegion(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
|
|
185
186
|
this.stateManager.updateState(result, codeBlock);
|
|
186
187
|
return result;
|
|
187
188
|
} catch (err) {
|
|
@@ -193,36 +194,36 @@ class Action {
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
private async
|
|
197
|
+
private async detectRegion(result: ActionResult): Promise<void> {
|
|
197
198
|
const previousState = this.stateManager.getCurrentState();
|
|
198
199
|
if (!previousState) return;
|
|
199
200
|
const previous = ActionResult.fromState(previousState);
|
|
200
|
-
const
|
|
201
|
+
const previousRegion = previous.overlay;
|
|
201
202
|
const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
|
|
202
203
|
const overlayPage = new OverlayPage(this.playwrightHelper.page);
|
|
203
204
|
|
|
204
|
-
if (result.overlay.
|
|
205
|
-
result.overlay =
|
|
205
|
+
if (result.overlay.isModal && previousRegion.isModal && previousRegion.root && previousRegion.type === result.overlay.type && previousRegion.name === result.overlay.name) {
|
|
206
|
+
result.overlay = previousRegion;
|
|
206
207
|
return;
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
if (!previous.html) return;
|
|
210
211
|
|
|
211
212
|
if (previous.html === result.html) {
|
|
212
|
-
if (sameUrl &&
|
|
213
|
+
if (sameUrl && previousRegion.isOpen && previousRegion.xpath && !result.overlay.isModal) result.overlay = previousRegion;
|
|
213
214
|
return;
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
let carried:
|
|
217
|
-
if (sameUrl &&
|
|
218
|
-
if (await overlayPage.isStillOpen(
|
|
219
|
-
carried =
|
|
217
|
+
let carried: Region | null = null;
|
|
218
|
+
if (sameUrl && previousRegion.isOpen && previousRegion.xpath) {
|
|
219
|
+
if (await overlayPage.isStillOpen(previousRegion)) {
|
|
220
|
+
carried = previousRegion;
|
|
220
221
|
} else {
|
|
221
|
-
debugLog(`Region closed: ${
|
|
222
|
-
if (!result.overlay.
|
|
223
|
-
const parent =
|
|
222
|
+
debugLog(`Region closed: ${previousRegion.name || previousRegion.type}`);
|
|
223
|
+
if (!result.overlay.isModal) {
|
|
224
|
+
const parent = previousRegion.parent;
|
|
224
225
|
if (parent?.xpath) {
|
|
225
|
-
const restored =
|
|
226
|
+
const restored = Overlay.resolve({ overlay: parent });
|
|
226
227
|
if (await overlayPage.isStillOpen(restored)) result.overlay = restored;
|
|
227
228
|
}
|
|
228
229
|
return;
|
|
@@ -239,7 +240,7 @@ class Action {
|
|
|
239
240
|
previousHtml: previous.html,
|
|
240
241
|
});
|
|
241
242
|
|
|
242
|
-
if (result.overlay
|
|
243
|
+
if (result.overlay instanceof Overlay) {
|
|
243
244
|
if (detected) result.overlay = result.overlay.withGeometry(detected);
|
|
244
245
|
return;
|
|
245
246
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RequestResult } from '../../api/request-result.ts';
|
|
2
|
+
import { type RequestStore, isFailedRequest } from '../../api/request-store.ts';
|
|
3
|
+
|
|
4
|
+
export class RequestHaul {
|
|
5
|
+
private start: number;
|
|
6
|
+
|
|
7
|
+
constructor(private store: RequestStore) {
|
|
8
|
+
this.start = store.getMadeRequests().length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
requests(): RequestResult[] {
|
|
12
|
+
return this.store.getMadeRequests().slice(this.start);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
failed(): RequestResult[] {
|
|
16
|
+
return this.requests().filter(isFailedRequest);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
successfulWrites(): RequestResult[] {
|
|
20
|
+
return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
successfulReads(): RequestResult[] {
|
|
24
|
+
return this.requests().filter((r) => !r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
byId(): Map<string, RequestResult> {
|
|
28
|
+
const map = new Map<string, RequestResult>();
|
|
29
|
+
for (const request of this.successfulWrites()) {
|
|
30
|
+
const { id } = request.extractIdAndTitle();
|
|
31
|
+
if (id === undefined) continue;
|
|
32
|
+
map.set(String(id), request);
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,31 +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 { isDynamicSegment } from '
|
|
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
|
-
const ledgerStart = requestStore.getMadeRequests().length;
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
let allowedMethods: string[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
22
|
+
if (readOnly) allowedMethods = ['GET'];
|
|
23
|
+
|
|
24
|
+
const getResult = () => result ?? synthesizeResult(haul, false, readOnly);
|
|
20
25
|
const isFinished = () => finished;
|
|
21
26
|
const finishFromText = (text?: string) => {
|
|
22
27
|
finished = true;
|
|
23
|
-
const synthesized = synthesizeResult(
|
|
28
|
+
const synthesized = synthesizeResult(haul, true, readOnly);
|
|
24
29
|
if (text && synthesized.success) synthesized.summary = text;
|
|
25
30
|
result = synthesized;
|
|
26
31
|
};
|
|
27
32
|
|
|
28
|
-
const tools = {
|
|
33
|
+
const tools: Record<string, any> = {
|
|
29
34
|
getEndpointSpec: tool({
|
|
30
35
|
description: dedent`
|
|
31
36
|
Get the request specification for an endpoint.
|
|
@@ -33,13 +38,14 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
33
38
|
Call this before making a request to an endpoint you haven't used before.
|
|
34
39
|
`,
|
|
35
40
|
inputSchema: z.object({
|
|
36
|
-
method: z.enum(
|
|
41
|
+
method: z.enum(allowedMethods as [string, ...string[]]).describe('HTTP method'),
|
|
37
42
|
path: z.string().describe('Endpoint path, e.g. /suites'),
|
|
38
43
|
}),
|
|
39
44
|
execute: async ({ method, path }) => {
|
|
40
45
|
tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
let captured = requestStore.findCapturedRequest(method, path);
|
|
48
|
+
if (captured && !captured.requestBody && opts.spec && captured.status < 400) captured = undefined;
|
|
43
49
|
if (captured) {
|
|
44
50
|
if (captured.status >= 400) {
|
|
45
51
|
const rejectedCapture = {
|
|
@@ -91,7 +97,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
91
97
|
Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
|
|
92
98
|
`,
|
|
93
99
|
inputSchema: z.object({
|
|
94
|
-
method: z.enum(
|
|
100
|
+
method: z.enum(allowedMethods as [string, ...string[]]).describe('HTTP method'),
|
|
95
101
|
path: z.string().describe('API path (e.g., /suites, /suites/1)'),
|
|
96
102
|
body: z.any().optional().describe('Request body (JSON object)'),
|
|
97
103
|
queryParams: z.record(z.string(), z.string()).optional().describe('Query parameters'),
|
|
@@ -128,11 +134,13 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
128
134
|
|
|
129
135
|
const extracted = extractKeyFields(reqResult.responseBody);
|
|
130
136
|
tag('success').log(`Fisherman: ${input.method} ${input.path} > ${statusLine}`);
|
|
131
|
-
|
|
137
|
+
const output: Record<string, any> = {
|
|
132
138
|
success: true,
|
|
133
139
|
status: reqResult.status,
|
|
134
140
|
extracted,
|
|
135
141
|
};
|
|
142
|
+
if (readOnly) output.bodyPreview = reqResult.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT);
|
|
143
|
+
return output;
|
|
136
144
|
},
|
|
137
145
|
}),
|
|
138
146
|
|
|
@@ -160,32 +168,12 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
160
168
|
.describe('List of items that could not be created'),
|
|
161
169
|
}),
|
|
162
170
|
execute: async ({ summary, created, failed }) => {
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
|
|
166
|
-
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.' };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const createdRequests = new RequestMap(writes);
|
|
170
|
-
|
|
171
|
-
const verified: FishermanResult['created'] = [];
|
|
172
|
-
for (const item of created) {
|
|
173
|
-
if (item.id === undefined) {
|
|
174
|
-
verified.push(item);
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
const request = createdRequests.get(item.id);
|
|
178
|
-
if (!request) {
|
|
179
|
-
tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
verified.push({ ...item, request: request.toEndpoint() });
|
|
183
|
-
}
|
|
184
|
-
if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
|
|
171
|
+
const { result: verified, error } = verifyFinish(haul, { summary, created, failed });
|
|
172
|
+
if (!verified) return { finished: false, error };
|
|
185
173
|
|
|
186
174
|
tag('success').log(`Fisherman done: ${summary}`);
|
|
187
175
|
finished = true;
|
|
188
|
-
result =
|
|
176
|
+
result = verified;
|
|
189
177
|
return { finished: true };
|
|
190
178
|
},
|
|
191
179
|
}),
|
|
@@ -204,15 +192,106 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
204
192
|
}),
|
|
205
193
|
};
|
|
206
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
|
+
|
|
207
215
|
return { tools, getResult, isFinished, finishFromText };
|
|
208
216
|
}
|
|
209
217
|
|
|
210
|
-
function
|
|
211
|
-
|
|
212
|
-
|
|
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
|
+
|
|
252
|
+
export function verifyFinish(haul: RequestHaul, input: { summary: string; created: FishermanResult['created']; failed?: FishermanResult['failed'] }): { result: FishermanResult | null; error?: string } {
|
|
253
|
+
const writes = haul.successfulWrites();
|
|
254
|
+
if (writes.length === 0) {
|
|
255
|
+
tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
|
|
256
|
+
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.' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const createdRequests = haul.byId();
|
|
260
|
+
|
|
261
|
+
const verified: FishermanResult['created'] = [];
|
|
262
|
+
for (const item of input.created) {
|
|
263
|
+
if (item.id === undefined) {
|
|
264
|
+
verified.push(item);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const request = createdRequests.get(String(item.id));
|
|
268
|
+
if (!request) {
|
|
269
|
+
tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
verified.push({ ...item, request: request.toEndpoint() });
|
|
273
|
+
}
|
|
274
|
+
if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
|
|
275
|
+
|
|
276
|
+
return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function synthesizeResult(haul: RequestHaul, declaredDone: boolean, readOnly: boolean): FishermanResult {
|
|
280
|
+
const made = haul.requests();
|
|
281
|
+
const failures = haul.failed();
|
|
282
|
+
let succeeded = haul.successfulWrites();
|
|
283
|
+
let successLabel = 'successful writes';
|
|
284
|
+
if (readOnly) {
|
|
285
|
+
succeeded = haul.successfulReads();
|
|
286
|
+
successLabel = 'successful reads';
|
|
287
|
+
}
|
|
288
|
+
let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
|
|
213
289
|
const lastFailure = failures[failures.length - 1];
|
|
214
290
|
if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
|
|
215
|
-
|
|
291
|
+
|
|
292
|
+
const result: FishermanResult = { success: declaredDone && succeeded.length > 0, summary, created: [], failed: [] };
|
|
293
|
+
if (!readOnly) result.created = succeeded.map(toCreatedItem);
|
|
294
|
+
return result;
|
|
216
295
|
}
|
|
217
296
|
|
|
218
297
|
function toCreatedItem(write: RequestResult): FishermanResult['created'][number] {
|
package/src/ai/fisherman.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import type { ApiClient } from '../api/api-client.ts';
|
|
3
|
-
import type
|
|
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
|
+
import { RequestHaul } from './fisherman/request-haul.ts';
|
|
12
|
+
import { type FishermanResult, createFishermanTools } from './fisherman/tools.ts';
|
|
11
13
|
import type { Provider } from './provider.ts';
|
|
12
14
|
import { dataProtectionRules } from './rules.ts';
|
|
13
15
|
|
|
@@ -72,22 +74,74 @@ export class Fisherman implements Agent {
|
|
|
72
74
|
|
|
73
75
|
if (!endpointList) {
|
|
74
76
|
tag('warning').log('Fisherman: no endpoints available');
|
|
75
|
-
this.mode = 'disabled';
|
|
76
77
|
return { success: false, summary: 'No API endpoints available', created: [], failed: [] };
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
await this.refreshAuth();
|
|
80
81
|
debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
|
|
81
82
|
|
|
82
|
-
const
|
|
83
|
+
const haul = new RequestHaul(this.requestStore);
|
|
84
|
+
const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
|
|
83
85
|
spec: this.spec,
|
|
84
86
|
baseEndpoint: this.baseEndpoint,
|
|
85
87
|
});
|
|
86
|
-
const ledgerStart = this.requestStore.getMadeRequests().length;
|
|
87
88
|
|
|
88
89
|
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
|
|
89
90
|
conversation.addUserText(this.buildTaskPrompt(instructions));
|
|
90
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> {
|
|
91
145
|
await loop(
|
|
92
146
|
async ({ stop, iteration }) => {
|
|
93
147
|
debugLog(`iteration ${iteration}`);
|
|
@@ -97,19 +151,19 @@ export class Fisherman implements Agent {
|
|
|
97
151
|
});
|
|
98
152
|
debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
|
|
99
153
|
|
|
100
|
-
if (isFinished()) {
|
|
154
|
+
if (opts.isFinished()) {
|
|
101
155
|
stop();
|
|
102
156
|
return;
|
|
103
157
|
}
|
|
104
158
|
|
|
105
159
|
if (!invokeResult?.toolExecutions?.length) {
|
|
106
160
|
debugLog('no tool call in this turn — treating as finish');
|
|
107
|
-
finishFromText(invokeResult?.response?.text);
|
|
161
|
+
opts.finishFromText(invokeResult?.response?.text);
|
|
108
162
|
stop();
|
|
109
163
|
return;
|
|
110
164
|
}
|
|
111
165
|
|
|
112
|
-
if (this.isStuckOnEndpoint(
|
|
166
|
+
if (this.isStuckOnEndpoint(opts.haul)) {
|
|
113
167
|
tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
|
|
114
168
|
stop();
|
|
115
169
|
return;
|
|
@@ -123,7 +177,7 @@ export class Fisherman implements Agent {
|
|
|
123
177
|
{
|
|
124
178
|
maxAttempts: MAX_ITERATIONS,
|
|
125
179
|
observability: {
|
|
126
|
-
name:
|
|
180
|
+
name: opts.label,
|
|
127
181
|
agent: 'fisherman',
|
|
128
182
|
sessionId: this.sessionName,
|
|
129
183
|
},
|
|
@@ -134,10 +188,6 @@ export class Fisherman implements Agent {
|
|
|
134
188
|
},
|
|
135
189
|
}
|
|
136
190
|
);
|
|
137
|
-
|
|
138
|
-
const result = getResult();
|
|
139
|
-
tag('info').log(`Fisherman result: ${result.summary}`);
|
|
140
|
-
return result;
|
|
141
191
|
}
|
|
142
192
|
|
|
143
193
|
private async detectMode(scopeUrl?: string): Promise<void> {
|
|
@@ -177,18 +227,19 @@ export class Fisherman implements Agent {
|
|
|
177
227
|
}
|
|
178
228
|
}
|
|
179
229
|
|
|
180
|
-
private buildEndpointList(scopeUrl?: string): string {
|
|
230
|
+
private buildEndpointList(scopeUrl?: string, family: EndpointFamily = 'write'): string {
|
|
181
231
|
this.scopeDegraded = false;
|
|
182
232
|
if (this.mode === 'achieve' && this.spec) {
|
|
183
|
-
|
|
233
|
+
let specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
|
|
234
|
+
if (family === 'read') specEndpoints = keepReadLines(specEndpoints);
|
|
184
235
|
if (specEndpoints) return specEndpoints;
|
|
185
236
|
}
|
|
186
237
|
|
|
187
|
-
const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
|
|
238
|
+
const scoped = this.requestStore.toEndpointList(scopeUrl || '/', family);
|
|
188
239
|
if (scoped) return scoped;
|
|
189
240
|
|
|
190
241
|
this.scopeDegraded = true;
|
|
191
|
-
return this.requestStore.toEndpointList();
|
|
242
|
+
return this.requestStore.toEndpointList(undefined, family);
|
|
192
243
|
}
|
|
193
244
|
|
|
194
245
|
private buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
|
|
@@ -228,12 +279,44 @@ export class Fisherman implements Agent {
|
|
|
228
279
|
`;
|
|
229
280
|
}
|
|
230
281
|
|
|
231
|
-
private
|
|
232
|
-
|
|
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
|
+
|
|
314
|
+
private isStuckOnEndpoint(haul: RequestHaul): boolean {
|
|
315
|
+
const made = haul.requests();
|
|
233
316
|
if (made.length < REPEATED_FAILURE_LIMIT) return false;
|
|
234
317
|
const recent = made.slice(-REPEATED_FAILURE_LIMIT);
|
|
235
318
|
const first = recent[0];
|
|
236
|
-
return recent.every((r) => (r
|
|
319
|
+
return recent.every((r) => isFailedRequest(r) && r.method === first.method && r.path === first.path);
|
|
237
320
|
}
|
|
238
321
|
|
|
239
322
|
private buildTaskPrompt(instructions: string): string {
|
|
@@ -249,3 +332,10 @@ export class Fisherman implements Agent {
|
|
|
249
332
|
`;
|
|
250
333
|
}
|
|
251
334
|
}
|
|
335
|
+
|
|
336
|
+
function keepReadLines(endpointList: string): string {
|
|
337
|
+
return endpointList
|
|
338
|
+
.split('\n')
|
|
339
|
+
.filter((line) => line.startsWith('GET '))
|
|
340
|
+
.join('\n');
|
|
341
|
+
}
|