explorbot 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +6 -1
- package/dist/bin/explorbot-cli.js +5 -1
- package/dist/package.json +1 -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/rules.js +2 -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/help-json-command.d.ts +31 -0
- package/dist/src/commands/help-json-command.js +58 -0
- package/docs/reference/commands.md +1 -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 +12 -0
- package/package.json +1 -1
- 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/rules.ts +2 -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/help-json-command.ts +74 -0
- package/dist/src/ai/fisherman-tools.d.ts +0 -147
package/bin/explorbot-cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import figureSet from 'figures';
|
|
|
8
8
|
import { render } from 'ink';
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { flushTelemetry } from '../src/ai/provider.js';
|
|
11
|
+
import { HelpJsonCommand } from '../src/commands/help-json-command.js';
|
|
11
12
|
import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
|
|
12
13
|
import { App } from '../src/components/App.js';
|
|
13
14
|
import { StatusPane } from '../src/components/StatusPane.js';
|
|
@@ -43,7 +44,9 @@ process.on('unhandledRejection', (reason) => {
|
|
|
43
44
|
tag('error').log(`Unhandled rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason)}`);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
const printsJson = process.argv.includes('--json') || process.argv.includes('help-json');
|
|
48
|
+
|
|
49
|
+
if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !printsJson) {
|
|
47
50
|
console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -952,6 +955,8 @@ ${rows}
|
|
|
952
955
|
`;
|
|
953
956
|
};
|
|
954
957
|
|
|
958
|
+
program.addHelpText('after', `\nFor agents and tools:\n ${cli} help-json [command...] the same definitions as JSON — commands, arguments, options, defaults\n`);
|
|
955
959
|
program.addHelpText('after', envHelp);
|
|
960
|
+
HelpJsonCommand.register(program);
|
|
956
961
|
|
|
957
962
|
program.parse();
|
|
@@ -8,6 +8,7 @@ import figureSet from 'figures';
|
|
|
8
8
|
import { render } from 'ink';
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { flushTelemetry } from '../src/ai/provider.js';
|
|
11
|
+
import { HelpJsonCommand } from '../src/commands/help-json-command.js';
|
|
11
12
|
import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
|
|
12
13
|
import { App } from '../src/components/App.js';
|
|
13
14
|
import { StatusPane } from '../src/components/StatusPane.js';
|
|
@@ -37,7 +38,8 @@ process.on('uncaughtException', async (error) => {
|
|
|
37
38
|
process.on('unhandledRejection', (reason) => {
|
|
38
39
|
tag('error').log(`Unhandled rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason)}`);
|
|
39
40
|
});
|
|
40
|
-
|
|
41
|
+
const printsJson = process.argv.includes('--json') || process.argv.includes('help-json');
|
|
42
|
+
if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !printsJson) {
|
|
41
43
|
console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
|
|
42
44
|
}
|
|
43
45
|
function buildExplorBotOptions(from, options) {
|
|
@@ -867,5 +869,7 @@ ${rows}
|
|
|
867
869
|
${cli} explore /login --max-tests 3
|
|
868
870
|
`;
|
|
869
871
|
};
|
|
872
|
+
program.addHelpText('after', `\nFor agents and tools:\n ${cli} help-json [command...] the same definitions as JSON — commands, arguments, options, defaults\n`);
|
|
870
873
|
program.addHelpText('after', envHelp);
|
|
874
|
+
HelpJsonCommand.register(program);
|
|
871
875
|
program.parse();
|
package/dist/package.json
CHANGED
|
@@ -15,6 +15,9 @@ export class RequestHaul {
|
|
|
15
15
|
successfulWrites() {
|
|
16
16
|
return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
17
17
|
}
|
|
18
|
+
successfulReads() {
|
|
19
|
+
return this.requests().filter((r) => !r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
20
|
+
}
|
|
18
21
|
byId() {
|
|
19
22
|
const map = new Map();
|
|
20
23
|
for (const request of this.successfulWrites()) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ApiClient } from '../../api/api-client.js';
|
|
2
|
+
import type { RequestStore } from '../../api/request-store.js';
|
|
3
|
+
import type { Test } from '../../test-plan.js';
|
|
4
|
+
import type { Fisherman } from '../fisherman.js';
|
|
5
|
+
import type { RequestHaul } from './request-haul.js';
|
|
6
|
+
export declare function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: {
|
|
7
|
+
spec?: any;
|
|
8
|
+
baseEndpoint?: string;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
}): {
|
|
11
|
+
tools: Record<string, any>;
|
|
12
|
+
getResult: () => FishermanResult;
|
|
13
|
+
isFinished: () => boolean;
|
|
14
|
+
finishFromText: (text?: string) => void;
|
|
15
|
+
};
|
|
16
|
+
export declare function createAskApiTool(fisherman: Fisherman | null, task: Test): {
|
|
17
|
+
askApi: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
|
|
18
|
+
question: any;
|
|
19
|
+
}, {
|
|
20
|
+
answered: boolean;
|
|
21
|
+
reason: string;
|
|
22
|
+
answer?: undefined;
|
|
23
|
+
} | {
|
|
24
|
+
answered: boolean;
|
|
25
|
+
answer: string;
|
|
26
|
+
reason?: undefined;
|
|
27
|
+
}, import("@ai-sdk/provider-utils").Context>>;
|
|
28
|
+
};
|
|
29
|
+
export declare function verifyFinish(haul: RequestHaul, input: {
|
|
30
|
+
summary: string;
|
|
31
|
+
created: FishermanResult['created'];
|
|
32
|
+
failed?: FishermanResult['failed'];
|
|
33
|
+
}): {
|
|
34
|
+
result: FishermanResult | null;
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
export interface FishermanResult {
|
|
38
|
+
success: boolean;
|
|
39
|
+
summary: string;
|
|
40
|
+
created: Array<{
|
|
41
|
+
type: string;
|
|
42
|
+
id?: string | number;
|
|
43
|
+
title?: string;
|
|
44
|
+
request?: string;
|
|
45
|
+
}>;
|
|
46
|
+
failed: Array<{
|
|
47
|
+
type: string;
|
|
48
|
+
reason: string;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { tool } from 'ai';
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { extractEndpointDefinition } from "
|
|
5
|
-
import { tag } from "
|
|
6
|
-
import { isDynamicSegment } from "
|
|
4
|
+
import { extractEndpointDefinition } from "../../api/spec-reader.js";
|
|
5
|
+
import { tag } from "../../utils/logger.js";
|
|
6
|
+
import { isDynamicSegment } from "../../utils/url-matcher.js";
|
|
7
|
+
const BODY_PREVIEW_LIMIT = 2000;
|
|
7
8
|
export function createFishermanTools(apiClient, requestStore, haul, opts) {
|
|
9
|
+
const readOnly = opts.readOnly === true;
|
|
8
10
|
let finished = false;
|
|
9
11
|
let result = null;
|
|
10
|
-
|
|
12
|
+
let allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
13
|
+
if (readOnly)
|
|
14
|
+
allowedMethods = ['GET'];
|
|
15
|
+
const getResult = () => result ?? synthesizeResult(haul, false, readOnly);
|
|
11
16
|
const isFinished = () => finished;
|
|
12
17
|
const finishFromText = (text) => {
|
|
13
18
|
finished = true;
|
|
14
|
-
const synthesized = synthesizeResult(haul, true);
|
|
19
|
+
const synthesized = synthesizeResult(haul, true, readOnly);
|
|
15
20
|
if (text && synthesized.success)
|
|
16
21
|
synthesized.summary = text;
|
|
17
22
|
result = synthesized;
|
|
@@ -24,12 +29,14 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
|
|
|
24
29
|
Call this before making a request to an endpoint you haven't used before.
|
|
25
30
|
`,
|
|
26
31
|
inputSchema: z.object({
|
|
27
|
-
method: z.enum(
|
|
32
|
+
method: z.enum(allowedMethods).describe('HTTP method'),
|
|
28
33
|
path: z.string().describe('Endpoint path, e.g. /suites'),
|
|
29
34
|
}),
|
|
30
35
|
execute: async ({ method, path }) => {
|
|
31
36
|
tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
|
|
32
|
-
|
|
37
|
+
let captured = requestStore.findCapturedRequest(method, path);
|
|
38
|
+
if (captured && !captured.requestBody && opts.spec && captured.status < 400)
|
|
39
|
+
captured = undefined;
|
|
33
40
|
if (captured) {
|
|
34
41
|
if (captured.status >= 400) {
|
|
35
42
|
const rejectedCapture = {
|
|
@@ -80,7 +87,7 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
|
|
|
80
87
|
Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
|
|
81
88
|
`,
|
|
82
89
|
inputSchema: z.object({
|
|
83
|
-
method: z.enum(
|
|
90
|
+
method: z.enum(allowedMethods).describe('HTTP method'),
|
|
84
91
|
path: z.string().describe('API path (e.g., /suites, /suites/1)'),
|
|
85
92
|
body: z.any().optional().describe('Request body (JSON object)'),
|
|
86
93
|
queryParams: z.record(z.string(), z.string()).optional().describe('Query parameters'),
|
|
@@ -111,11 +118,14 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
|
|
|
111
118
|
}
|
|
112
119
|
const extracted = extractKeyFields(reqResult.responseBody);
|
|
113
120
|
tag('success').log(`Fisherman: ${input.method} ${input.path} > ${statusLine}`);
|
|
114
|
-
|
|
121
|
+
const output = {
|
|
115
122
|
success: true,
|
|
116
123
|
status: reqResult.status,
|
|
117
124
|
extracted,
|
|
118
125
|
};
|
|
126
|
+
if (readOnly)
|
|
127
|
+
output.bodyPreview = reqResult.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT);
|
|
128
|
+
return output;
|
|
119
129
|
},
|
|
120
130
|
}),
|
|
121
131
|
finish: tool({
|
|
@@ -160,8 +170,55 @@ export function createFishermanTools(apiClient, requestStore, haul, opts) {
|
|
|
160
170
|
},
|
|
161
171
|
}),
|
|
162
172
|
};
|
|
173
|
+
if (readOnly) {
|
|
174
|
+
tools.finish = tool({
|
|
175
|
+
description: 'Report the answer to the question. Call when the requests have shown what exists.',
|
|
176
|
+
inputSchema: z.object({
|
|
177
|
+
answer: z.string().describe('What the data shows, quoting the concrete names, titles and ids that were returned'),
|
|
178
|
+
}),
|
|
179
|
+
execute: async ({ answer }) => {
|
|
180
|
+
if (haul.successfulReads().length === 0) {
|
|
181
|
+
tag('warning').log('Fisherman: finish rejected — no successful request in this run');
|
|
182
|
+
return { finished: false, error: 'No successful request was made in this run, so nothing was read. Keep working, or call stop if the question cannot be answered.' };
|
|
183
|
+
}
|
|
184
|
+
tag('success').log(`Fisherman answered: ${answer}`);
|
|
185
|
+
finished = true;
|
|
186
|
+
result = { success: true, summary: answer, created: [], failed: [] };
|
|
187
|
+
return { finished: true };
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
163
191
|
return { tools, getResult, isFinished, finishFromText };
|
|
164
192
|
}
|
|
193
|
+
export function createAskApiTool(fisherman, task) {
|
|
194
|
+
return {
|
|
195
|
+
askApi: tool({
|
|
196
|
+
description: dedent `
|
|
197
|
+
Ask what data already exists, changing nothing.
|
|
198
|
+
Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
|
|
199
|
+
Use it before precondition() to see whether suitable data is already available, and whenever a step needs the exact name or id of a record that is already there.
|
|
200
|
+
It never creates, edits or deletes anything — precondition() does that.
|
|
201
|
+
`,
|
|
202
|
+
inputSchema: z.object({
|
|
203
|
+
question: z.string().describe('What to find out about data that already exists'),
|
|
204
|
+
}),
|
|
205
|
+
execute: async ({ question }) => {
|
|
206
|
+
tag('info').log(`Ask API: ${question}`);
|
|
207
|
+
if (!fisherman?.isAvailable()) {
|
|
208
|
+
return { answered: false, reason: 'No API access is configured, so existing data cannot be queried. Judge from the page instead.' };
|
|
209
|
+
}
|
|
210
|
+
const result = await fisherman.lookupData(question, task.startUrl, task.sessionName);
|
|
211
|
+
if (!result.success) {
|
|
212
|
+
tag('warning').log(`Ask API unanswered: ${result.summary}`);
|
|
213
|
+
return { answered: false, reason: result.summary || 'The API could not answer this question' };
|
|
214
|
+
}
|
|
215
|
+
task.addNote(`Asked API: ${question} — ${result.summary}`);
|
|
216
|
+
tag('success').log(`Ask API: ${result.summary}`);
|
|
217
|
+
return { answered: true, answer: result.summary };
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
165
222
|
export function verifyFinish(haul, input) {
|
|
166
223
|
const writes = haul.successfulWrites();
|
|
167
224
|
if (writes.length === 0) {
|
|
@@ -186,15 +243,23 @@ export function verifyFinish(haul, input) {
|
|
|
186
243
|
verified.push(...writes.map(toCreatedItem));
|
|
187
244
|
return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
|
|
188
245
|
}
|
|
189
|
-
function synthesizeResult(haul, declaredDone) {
|
|
246
|
+
function synthesizeResult(haul, declaredDone, readOnly) {
|
|
190
247
|
const made = haul.requests();
|
|
191
|
-
const writes = haul.successfulWrites();
|
|
192
248
|
const failures = haul.failed();
|
|
193
|
-
let
|
|
249
|
+
let succeeded = haul.successfulWrites();
|
|
250
|
+
let successLabel = 'successful writes';
|
|
251
|
+
if (readOnly) {
|
|
252
|
+
succeeded = haul.successfulReads();
|
|
253
|
+
successLabel = 'successful reads';
|
|
254
|
+
}
|
|
255
|
+
let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
|
|
194
256
|
const lastFailure = failures[failures.length - 1];
|
|
195
257
|
if (lastFailure)
|
|
196
258
|
summary += `; last failure: ${lastFailure.toSummary()}`;
|
|
197
|
-
|
|
259
|
+
const result = { success: declaredDone && succeeded.length > 0, summary, created: [], failed: [] };
|
|
260
|
+
if (!readOnly)
|
|
261
|
+
result.created = succeeded.map(toCreatedItem);
|
|
262
|
+
return result;
|
|
198
263
|
}
|
|
199
264
|
function toCreatedItem(write) {
|
|
200
265
|
const { id, title } = write.extractIdAndTitle();
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ApiClient } from '../api/api-client.js';
|
|
2
|
-
import { type RequestStore } from '../api/request-store.js';
|
|
2
|
+
import { type EndpointFamily, type RequestStore } from '../api/request-store.js';
|
|
3
3
|
import type { Agent } from './agent.js';
|
|
4
|
-
import {
|
|
4
|
+
import type { Conversation } from './conversation.js';
|
|
5
5
|
import { RequestHaul } from './fisherman/request-haul.js';
|
|
6
|
+
import { type FishermanResult } from './fisherman/tools.js';
|
|
6
7
|
import type { Provider } from './provider.js';
|
|
7
8
|
export declare class Fisherman implements Agent {
|
|
8
9
|
emoji: string;
|
|
@@ -23,10 +24,18 @@ export declare class Fisherman implements Agent {
|
|
|
23
24
|
ensureReady(scopeUrl?: string): Promise<void>;
|
|
24
25
|
getEndpointList(scopeUrl?: string): string;
|
|
25
26
|
prepareData(instructions: string, scopeUrl?: string, sessionName?: string): Promise<FishermanResult>;
|
|
27
|
+
lookupData(question: string, scopeUrl?: string, sessionName?: string): Promise<FishermanResult>;
|
|
28
|
+
runSession(conversation: Conversation, tools: Record<string, any>, opts: {
|
|
29
|
+
haul: RequestHaul;
|
|
30
|
+
isFinished: () => boolean;
|
|
31
|
+
finishFromText: (text?: string) => void;
|
|
32
|
+
label: string;
|
|
33
|
+
}): Promise<void>;
|
|
26
34
|
detectMode(scopeUrl?: string): Promise<void>;
|
|
27
35
|
refreshAuth(): Promise<void>;
|
|
28
|
-
buildEndpointList(scopeUrl?: string): string;
|
|
36
|
+
buildEndpointList(scopeUrl?: string, family?: EndpointFamily): string;
|
|
29
37
|
buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
|
|
38
|
+
buildLookupSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
|
|
30
39
|
isStuckOnEndpoint(haul: RequestHaul): boolean;
|
|
31
40
|
buildTaskPrompt(instructions: string): string;
|
|
32
41
|
}
|
package/dist/src/ai/fisherman.js
CHANGED
|
@@ -4,8 +4,8 @@ import { listAllEndpoints } from "../api/spec-reader.js";
|
|
|
4
4
|
import { createDebug, tag } from "../utils/logger.js";
|
|
5
5
|
const debugLog = createDebug('explorbot:fisherman');
|
|
6
6
|
import { loop } from "../utils/loop.js";
|
|
7
|
-
import { createFishermanTools } from "./fisherman-tools.js";
|
|
8
7
|
import { RequestHaul } from "./fisherman/request-haul.js";
|
|
8
|
+
import { createFishermanTools } from "./fisherman/tools.js";
|
|
9
9
|
import { dataProtectionRules } from "./rules.js";
|
|
10
10
|
const MAX_ITERATIONS = 15;
|
|
11
11
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
@@ -58,7 +58,6 @@ export class Fisherman {
|
|
|
58
58
|
debugLog(`endpoints:\n${endpointList || '(none)'}`);
|
|
59
59
|
if (!endpointList) {
|
|
60
60
|
tag('warning').log('Fisherman: no endpoints available');
|
|
61
|
-
this.mode = 'disabled';
|
|
62
61
|
return { success: false, summary: 'No API endpoints available', created: [], failed: [] };
|
|
63
62
|
}
|
|
64
63
|
await this.refreshAuth();
|
|
@@ -70,6 +69,47 @@ export class Fisherman {
|
|
|
70
69
|
});
|
|
71
70
|
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
|
|
72
71
|
conversation.addUserText(this.buildTaskPrompt(instructions));
|
|
72
|
+
await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman: ${instructions.slice(0, 50)}` });
|
|
73
|
+
const result = getResult();
|
|
74
|
+
tag('info').log(`Fisherman result: ${result.summary}`);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
async lookupData(question, scopeUrl, sessionName) {
|
|
78
|
+
this.sessionName = sessionName;
|
|
79
|
+
tag('info').log(`Fisherman [read]: ${question}`);
|
|
80
|
+
await this.ensureReady(scopeUrl);
|
|
81
|
+
if (this.mode === 'disabled') {
|
|
82
|
+
debugLog('disabled — no data for scope');
|
|
83
|
+
return { success: false, summary: 'No API data available for this scope', created: [], failed: [] };
|
|
84
|
+
}
|
|
85
|
+
const endpointList = this.buildEndpointList(scopeUrl, 'read');
|
|
86
|
+
debugLog(`read endpoints:\n${endpointList || '(none)'}`);
|
|
87
|
+
if (!endpointList) {
|
|
88
|
+
tag('warning').log('Fisherman: no read endpoints available');
|
|
89
|
+
return { success: false, summary: 'No read endpoints are known for this scope', created: [], failed: [] };
|
|
90
|
+
}
|
|
91
|
+
await this.refreshAuth();
|
|
92
|
+
const haul = new RequestHaul(this.requestStore);
|
|
93
|
+
const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
|
|
94
|
+
spec: this.spec,
|
|
95
|
+
baseEndpoint: this.baseEndpoint,
|
|
96
|
+
readOnly: true,
|
|
97
|
+
});
|
|
98
|
+
const conversation = this.provider.startConversation(this.buildLookupSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
|
|
99
|
+
conversation.addUserText(dedent `
|
|
100
|
+
Answer this question about data that already exists:
|
|
101
|
+
|
|
102
|
+
${question}
|
|
103
|
+
|
|
104
|
+
Make the requests needed to answer it, then call finish with the answer.
|
|
105
|
+
If the available endpoints cannot answer it, call stop with the reason.
|
|
106
|
+
`);
|
|
107
|
+
await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman lookup: ${question.slice(0, 50)}` });
|
|
108
|
+
const result = getResult();
|
|
109
|
+
tag('info').log(`Fisherman answer: ${result.summary}`);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
async runSession(conversation, tools, opts) {
|
|
73
113
|
await loop(async ({ stop, iteration }) => {
|
|
74
114
|
debugLog(`iteration ${iteration}`);
|
|
75
115
|
const invokeResult = await this.provider.invokeConversation(conversation, tools, {
|
|
@@ -77,17 +117,17 @@ export class Fisherman {
|
|
|
77
117
|
agentName: 'fisherman',
|
|
78
118
|
});
|
|
79
119
|
debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
|
|
80
|
-
if (isFinished()) {
|
|
120
|
+
if (opts.isFinished()) {
|
|
81
121
|
stop();
|
|
82
122
|
return;
|
|
83
123
|
}
|
|
84
124
|
if (!invokeResult?.toolExecutions?.length) {
|
|
85
125
|
debugLog('no tool call in this turn — treating as finish');
|
|
86
|
-
finishFromText(invokeResult?.response?.text);
|
|
126
|
+
opts.finishFromText(invokeResult?.response?.text);
|
|
87
127
|
stop();
|
|
88
128
|
return;
|
|
89
129
|
}
|
|
90
|
-
if (this.isStuckOnEndpoint(haul)) {
|
|
130
|
+
if (this.isStuckOnEndpoint(opts.haul)) {
|
|
91
131
|
tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
|
|
92
132
|
stop();
|
|
93
133
|
return;
|
|
@@ -99,7 +139,7 @@ export class Fisherman {
|
|
|
99
139
|
}, {
|
|
100
140
|
maxAttempts: MAX_ITERATIONS,
|
|
101
141
|
observability: {
|
|
102
|
-
name:
|
|
142
|
+
name: opts.label,
|
|
103
143
|
agent: 'fisherman',
|
|
104
144
|
sessionId: this.sessionName,
|
|
105
145
|
},
|
|
@@ -109,9 +149,6 @@ export class Fisherman {
|
|
|
109
149
|
stop();
|
|
110
150
|
},
|
|
111
151
|
});
|
|
112
|
-
const result = getResult();
|
|
113
|
-
tag('info').log(`Fisherman result: ${result.summary}`);
|
|
114
|
-
return result;
|
|
115
152
|
}
|
|
116
153
|
async detectMode(scopeUrl) {
|
|
117
154
|
if (this.hasApiConfig) {
|
|
@@ -143,18 +180,20 @@ export class Fisherman {
|
|
|
143
180
|
this.apiClient.setHeaders(this.configHeaders);
|
|
144
181
|
}
|
|
145
182
|
}
|
|
146
|
-
buildEndpointList(scopeUrl) {
|
|
183
|
+
buildEndpointList(scopeUrl, family = 'write') {
|
|
147
184
|
this.scopeDegraded = false;
|
|
148
185
|
if (this.mode === 'achieve' && this.spec) {
|
|
149
|
-
|
|
186
|
+
let specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
|
|
187
|
+
if (family === 'read')
|
|
188
|
+
specEndpoints = keepReadLines(specEndpoints);
|
|
150
189
|
if (specEndpoints)
|
|
151
190
|
return specEndpoints;
|
|
152
191
|
}
|
|
153
|
-
const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
|
|
192
|
+
const scoped = this.requestStore.toEndpointList(scopeUrl || '/', family);
|
|
154
193
|
if (scoped)
|
|
155
194
|
return scoped;
|
|
156
195
|
this.scopeDegraded = true;
|
|
157
|
-
return this.requestStore.toEndpointList();
|
|
196
|
+
return this.requestStore.toEndpointList(undefined, family);
|
|
158
197
|
}
|
|
159
198
|
buildSystemPrompt(endpointList, toolNames, scopeUrl) {
|
|
160
199
|
let scopeBlock = '';
|
|
@@ -192,6 +231,37 @@ export class Fisherman {
|
|
|
192
231
|
${dataProtectionRules}
|
|
193
232
|
`;
|
|
194
233
|
}
|
|
234
|
+
buildLookupSystemPrompt(endpointList, toolNames, scopeUrl) {
|
|
235
|
+
let scopeBlock = '';
|
|
236
|
+
if (scopeUrl) {
|
|
237
|
+
scopeBlock = `\n\nSCOPE: You are answering about ${scopeUrl}.`;
|
|
238
|
+
if (this.scopeDegraded)
|
|
239
|
+
scopeBlock += '\nThe endpoint list could not be narrowed to this scope and may include endpoints belonging to other scopes. Prefer the endpoint whose path belongs to this scope.';
|
|
240
|
+
}
|
|
241
|
+
return dedent `
|
|
242
|
+
You are Fisherman — reading the API to report what data already exists. You change nothing.
|
|
243
|
+
|
|
244
|
+
AVAILABLE ENDPOINTS:
|
|
245
|
+
${endpointList}
|
|
246
|
+
${scopeBlock}
|
|
247
|
+
|
|
248
|
+
AVAILABLE TOOLS:
|
|
249
|
+
${toolNames.join(', ')}.
|
|
250
|
+
Use tool names exactly as listed. Do not invent aliases or combined names.
|
|
251
|
+
Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
|
|
252
|
+
|
|
253
|
+
WORKFLOW:
|
|
254
|
+
1. Pick the endpoint that lists the kind of item the question is about
|
|
255
|
+
2. Request it, and when the answer needs a parent resource, request the parent first and use its id
|
|
256
|
+
3. Call finish with the answer, quoting the concrete names, titles and ids the responses returned
|
|
257
|
+
|
|
258
|
+
RULES:
|
|
259
|
+
- Report only what a response actually returned. Never describe data you did not read
|
|
260
|
+
- Report an empty collection as empty. An absent item must not be reported as present
|
|
261
|
+
- Answer the question that was asked and stop. Do not survey unrelated endpoints
|
|
262
|
+
- Use the response category and error text to correct a failed request. Retry a temporary or server failure once
|
|
263
|
+
`;
|
|
264
|
+
}
|
|
195
265
|
isStuckOnEndpoint(haul) {
|
|
196
266
|
const made = haul.requests();
|
|
197
267
|
if (made.length < REPEATED_FAILURE_LIMIT)
|
|
@@ -213,3 +283,9 @@ export class Fisherman {
|
|
|
213
283
|
`;
|
|
214
284
|
}
|
|
215
285
|
}
|
|
286
|
+
function keepReadLines(endpointList) {
|
|
287
|
+
return endpointList
|
|
288
|
+
.split('\n')
|
|
289
|
+
.filter((line) => line.startsWith('GET '))
|
|
290
|
+
.join('\n');
|
|
291
|
+
}
|
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";
|
|
@@ -398,7 +399,8 @@ export class Pilot {
|
|
|
398
399
|
|
|
399
400
|
Plan the test execution for this scenario.
|
|
400
401
|
|
|
401
|
-
FIRST: Decide if precondition() is needed.
|
|
402
|
+
FIRST: Decide if precondition() is needed. When the page does not settle whether suitable data
|
|
403
|
+
already exists, call askApi() to find out before creating any.
|
|
402
404
|
|
|
403
405
|
Call precondition() WHEN:
|
|
404
406
|
- The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
|
|
@@ -603,7 +605,7 @@ export class Pilot {
|
|
|
603
605
|
finalUserText = `${tocBlock}\n\n${userText}`;
|
|
604
606
|
}
|
|
605
607
|
this.conversation.addUserText(finalUserText);
|
|
606
|
-
const tools = { ...this.pickPlanningTools(), ...this.
|
|
608
|
+
const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
|
|
607
609
|
const result = await this.provider.invokeConversation(this.conversation, tools, {
|
|
608
610
|
maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
|
|
609
611
|
toolChoice: opts.tools ? 'auto' : 'none',
|
|
@@ -655,7 +657,12 @@ export class Pilot {
|
|
|
655
657
|
withdrawVisionTools(planning);
|
|
656
658
|
return planning;
|
|
657
659
|
}
|
|
658
|
-
|
|
660
|
+
fishermanStatus() {
|
|
661
|
+
if (this.fisherman?.isAvailable())
|
|
662
|
+
return 'available';
|
|
663
|
+
return 'none';
|
|
664
|
+
}
|
|
665
|
+
buildFishermanTools(task) {
|
|
659
666
|
const unavailable = 'Data was not created and cannot be created automatically. Do not call precondition again for this test — continue with what the page already shows.';
|
|
660
667
|
return {
|
|
661
668
|
precondition: tool({
|
|
@@ -666,7 +673,7 @@ export class Pilot {
|
|
|
666
673
|
execute: async ({ description }) => {
|
|
667
674
|
task.addNote(`Precondition: ${description}`);
|
|
668
675
|
tag('info').log(`Precondition: ${description}`);
|
|
669
|
-
debugLog(`precondition: ${description}, fisherman: ${this.
|
|
676
|
+
debugLog(`precondition: ${description}, fisherman: ${this.fishermanStatus()}`);
|
|
670
677
|
if (!this.fisherman || !this.fisherman.isAvailable()) {
|
|
671
678
|
const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
|
|
672
679
|
if (skipReason)
|
|
@@ -698,6 +705,7 @@ export class Pilot {
|
|
|
698
705
|
return { noted: true, prepared: true, created: result.created };
|
|
699
706
|
},
|
|
700
707
|
}),
|
|
708
|
+
...createAskApiTool(this.fisherman, task),
|
|
701
709
|
};
|
|
702
710
|
}
|
|
703
711
|
async checkDataAvailability(task, requestedData, fishermanReason) {
|
|
@@ -1047,7 +1055,7 @@ export class Pilot {
|
|
|
1047
1055
|
- Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
|
|
1048
1056
|
- form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
|
|
1049
1057
|
- ariaDiff shows 5+ added/removed → page entered new mode (editor/modal); call context() before guessing selectors.
|
|
1050
|
-
- Empty dropdown/list when items expected → wait explicitly, then check the state changed: ariaDiff and any GET that loaded data. If still nothing loaded, confirm the empty state with verify().
|
|
1058
|
+
- Empty dropdown/list when items expected → wait explicitly, then check the state changed: ariaDiff and any GET that loaded data. If still nothing loaded, confirm the empty state with verify(), or askApi() for whether the data exists at all.
|
|
1051
1059
|
- Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
|
|
1052
1060
|
- Multi-action explanation in one tool call → instruct Tester to split.
|
|
1053
1061
|
|
|
@@ -1063,8 +1071,13 @@ export class Pilot {
|
|
|
1063
1071
|
|
|
1064
1072
|
${capabilityGroundingRule}
|
|
1065
1073
|
|
|
1066
|
-
YOUR Pilot-only
|
|
1067
|
-
|
|
1074
|
+
YOUR Pilot-only tools, both over the API:
|
|
1075
|
+
|
|
1076
|
+
askApi(question) — ask what data already exists. It changes nothing. Use it to check whether
|
|
1077
|
+
suitable data is already there before creating any, and to get the exact name or id of an existing
|
|
1078
|
+
record a step must act on.
|
|
1079
|
+
|
|
1080
|
+
precondition(description) — create FRESH disposable test data. Never request users. Use when:
|
|
1068
1081
|
|
|
1069
1082
|
- Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
|
|
1070
1083
|
- Scenario needs auxiliary data (labels, categories, statuses for filtering).
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -163,6 +163,8 @@ export const dataProtectionRules = dedent `
|
|
|
163
163
|
Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
|
|
164
164
|
filter, tab, or list-inspection constraint. Use visible existing data when it is available.
|
|
165
165
|
If no suitable data exists, report the missing precondition instead of creating data.
|
|
166
|
+
Reading through the API to establish what already exists is not a mutation and stays allowed
|
|
167
|
+
under a read-only constraint.
|
|
166
168
|
|
|
167
169
|
Destructive actions are allowed only against data created by the current scenario
|
|
168
170
|
or prepared for that scenario by Fisherman/API preconditions. Existing application data must
|
|
@@ -96,7 +96,9 @@ export class RequestResult {
|
|
|
96
96
|
yaml += body;
|
|
97
97
|
}
|
|
98
98
|
writeFileSync(this.requestFile, yaml, 'utf8');
|
|
99
|
-
|
|
99
|
+
if (!this._rawResponseBody)
|
|
100
|
+
return;
|
|
101
|
+
writeFileSync(this.responseFile, this._rawResponseBody, 'utf8');
|
|
100
102
|
}
|
|
101
103
|
static load(requestFile) {
|
|
102
104
|
const content = readFileSync(requestFile, 'utf8');
|
|
@@ -6,8 +6,10 @@ export declare class RequestStore {
|
|
|
6
6
|
onFailedListeners: Array<(r: RequestResult) => void>;
|
|
7
7
|
outputDir: string;
|
|
8
8
|
sessionStartedAt: Date;
|
|
9
|
+
readEndpointKeys: Set<string>;
|
|
9
10
|
constructor(outputDir: string);
|
|
10
11
|
addCapturedRequest(result: RequestResult): void;
|
|
12
|
+
addReadRequest(result: RequestResult): void;
|
|
11
13
|
addFailedRequest(result: RequestResult): void;
|
|
12
14
|
getFailedRequests(): RequestResult[];
|
|
13
15
|
onFailedRequest(cb: (r: RequestResult) => void): () => void;
|
|
@@ -15,12 +17,15 @@ export declare class RequestStore {
|
|
|
15
17
|
getCapturedRequests(): RequestResult[];
|
|
16
18
|
getMadeRequests(): RequestResult[];
|
|
17
19
|
getLastRequest(): RequestResult | undefined;
|
|
18
|
-
toEndpointList(scopePath?: string): string;
|
|
20
|
+
toEndpointList(scopePath?: string, methods?: EndpointFamily): string;
|
|
19
21
|
extractAuthHeaders(): Record<string, string>;
|
|
20
22
|
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined;
|
|
21
23
|
toLog(): string;
|
|
22
24
|
loadFromDisk(): void;
|
|
23
25
|
getWriteRequestsForScope(scopePath: string): RequestResult[];
|
|
26
|
+
getReadRequestsForScope(scopePath: string): RequestResult[];
|
|
24
27
|
clear(): void;
|
|
28
|
+
getRequestsForScope(scopePath: string, methods: EndpointFamily): RequestResult[];
|
|
25
29
|
}
|
|
26
30
|
export declare function isFailedRequest(request: RequestResult): boolean;
|
|
31
|
+
export type EndpointFamily = 'read' | 'write';
|