explorbot 0.2.5 → 0.3.0
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/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +5 -0
- package/boat/prima/src/prima.ts +17 -4
- package/dist/boat/prima/src/cli.js +7 -0
- package/dist/boat/prima/src/prima.js +18 -4
- package/dist/models.json +4 -4
- package/dist/package.json +6 -2
- package/dist/src/action-result.d.ts +13 -0
- package/dist/src/action-result.js +46 -15
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +48 -17
- package/dist/src/ai/captain/web-mode.js +1 -2
- package/dist/src/ai/captain.d.ts +20 -0
- package/dist/src/ai/captain.js +10 -1
- package/dist/src/ai/driller.js +6 -2
- package/dist/src/ai/fisherman-tools.d.ts +40 -1
- package/dist/src/ai/fisherman-tools.js +39 -0
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/navigator.d.ts +2 -1
- package/dist/src/ai/navigator.js +5 -9
- package/dist/src/ai/pilot.js +39 -22
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +16 -1
- package/dist/src/ai/researcher/cache.d.ts +8 -3
- package/dist/src/ai/researcher/cache.js +13 -8
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
- package/dist/src/ai/researcher.js +4 -3
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/tester.d.ts +1 -1
- package/dist/src/ai/tester.js +13 -22
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +79 -56
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.js +3 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +1 -1
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/test-plan.d.ts +8 -0
- package/dist/src/test-plan.js +11 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +32 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +31 -2
- package/docs/basics/getting-started.md +33 -10
- package/docs/basics/providers.md +6 -4
- package/docs/contributing/npm-package.md +73 -4
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +51 -17
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/navigator.ts +6 -10
- package/src/ai/pilot.ts +41 -24
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +17 -1
- package/src/ai/researcher/cache.ts +17 -9
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +1 -5
- package/src/ai/tester.ts +13 -22
- package/src/ai/tools.ts +84 -60
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +2 -1
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +1 -1
- package/src/playwright-recorder.ts +6 -11
- package/src/test-plan.ts +18 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/strings.ts +36 -0
- package/src/utils/url-matcher.ts +27 -2
package/dist/src/action.js
CHANGED
|
@@ -17,6 +17,8 @@ const debugLog = createDebug('explorbot:action');
|
|
|
17
17
|
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
|
|
18
18
|
const DEFAULT_ACTION_TIMEOUT = 3000;
|
|
19
19
|
const DEFAULT_PAGE_TIMEOUT = 3000;
|
|
20
|
+
const MAX_NETWORK_CALLS = 10;
|
|
21
|
+
const IMPORTANT_LOG_LEVELS = new Set(['info', 'error', 'warning', 'warn']);
|
|
20
22
|
class Action {
|
|
21
23
|
actor;
|
|
22
24
|
stateManager;
|
|
@@ -32,6 +34,8 @@ class Action {
|
|
|
32
34
|
recorder;
|
|
33
35
|
recovery;
|
|
34
36
|
mainDocumentStatus = undefined;
|
|
37
|
+
networkRequests = [];
|
|
38
|
+
baseOrigin;
|
|
35
39
|
constructor(actor, stateManager, recorder, recovery) {
|
|
36
40
|
this.actor = actor;
|
|
37
41
|
this.stateManager = stateManager;
|
|
@@ -39,6 +43,7 @@ class Action {
|
|
|
39
43
|
this.playwrightHelper = container.helpers('Playwright');
|
|
40
44
|
this.recorder = recorder;
|
|
41
45
|
this.recovery = recovery || ((fn) => fn());
|
|
46
|
+
this.baseOrigin = URL.parse(this.config.playwright?.url || '')?.origin || '';
|
|
42
47
|
}
|
|
43
48
|
async saveScreenshot() {
|
|
44
49
|
const currentState = this.stateManager.getCurrentState();
|
|
@@ -117,9 +122,7 @@ class Action {
|
|
|
117
122
|
const logPath = join(statesDir, logFile);
|
|
118
123
|
const formattedLogs = browserLogs.map((log) => {
|
|
119
124
|
const logTimestamp = new Date().toISOString();
|
|
120
|
-
|
|
121
|
-
const message = log.text || log.message || String(log);
|
|
122
|
-
return `[${logTimestamp}] ${level}: ${message}`;
|
|
125
|
+
return `[${logTimestamp}] ${log.type.toUpperCase()}: ${log.text}`;
|
|
123
126
|
});
|
|
124
127
|
fs.writeFileSync(logPath, `${formattedLogs.join('\n')}\n`, 'utf8');
|
|
125
128
|
debugLog('Page:', { url, title, size: html.length, html: html.substring(0, 100) });
|
|
@@ -142,12 +145,15 @@ class Action {
|
|
|
142
145
|
fs.writeFileSync(ariaPath, ariaSnapshot, 'utf8');
|
|
143
146
|
ariaSnapshotFile = ariaFileName;
|
|
144
147
|
}
|
|
148
|
+
const networkRequests = this.networkRequests;
|
|
149
|
+
this.networkRequests = [];
|
|
145
150
|
const result = new ActionResult({
|
|
146
151
|
html,
|
|
147
152
|
title,
|
|
148
153
|
httpStatus: await this.captureMainDocumentStatus(),
|
|
149
154
|
url,
|
|
150
155
|
browserLogs,
|
|
156
|
+
networkRequests,
|
|
151
157
|
htmlFile,
|
|
152
158
|
logFile,
|
|
153
159
|
screenshotFile,
|
|
@@ -192,27 +198,52 @@ class Action {
|
|
|
192
198
|
return undefined;
|
|
193
199
|
}
|
|
194
200
|
}
|
|
195
|
-
|
|
201
|
+
captureResponses() {
|
|
196
202
|
const page = this.playwrightHelper.page;
|
|
197
203
|
if (!page?.on || !page?.off)
|
|
198
204
|
return () => { };
|
|
199
205
|
this.mainDocumentStatus = undefined;
|
|
206
|
+
this.networkRequests = [];
|
|
200
207
|
const handler = (response) => {
|
|
201
208
|
const request = response.request();
|
|
202
|
-
if (request.resourceType() !== 'document')
|
|
203
|
-
return;
|
|
204
|
-
if (response.frame() !== page.mainFrame())
|
|
205
|
-
return;
|
|
206
209
|
const status = response.status();
|
|
207
210
|
if (typeof status !== 'number')
|
|
208
211
|
return;
|
|
209
212
|
if (status <= 0)
|
|
210
213
|
return;
|
|
211
|
-
|
|
214
|
+
if (request.resourceType() === 'document') {
|
|
215
|
+
if (response.frame() !== page.mainFrame())
|
|
216
|
+
return;
|
|
217
|
+
this.mainDocumentStatus = status;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.recordNetworkCall(request, status);
|
|
212
221
|
};
|
|
213
222
|
page.on('response', handler);
|
|
214
223
|
return () => page.off('response', handler);
|
|
215
224
|
}
|
|
225
|
+
recordNetworkCall(request, status) {
|
|
226
|
+
const resourceType = request.resourceType();
|
|
227
|
+
if (resourceType !== 'xhr' && resourceType !== 'fetch')
|
|
228
|
+
return;
|
|
229
|
+
const url = URL.parse(request.url());
|
|
230
|
+
if (!url)
|
|
231
|
+
return;
|
|
232
|
+
if (url.origin !== this.baseOrigin)
|
|
233
|
+
return;
|
|
234
|
+
const call = { method: request.method(), path: url.pathname, status };
|
|
235
|
+
if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status))
|
|
236
|
+
return;
|
|
237
|
+
if (this.networkRequests.length >= MAX_NETWORK_CALLS) {
|
|
238
|
+
if (status < 400)
|
|
239
|
+
return;
|
|
240
|
+
const succeeded = this.networkRequests.findIndex((r) => r.status < 400);
|
|
241
|
+
if (succeeded === -1)
|
|
242
|
+
return;
|
|
243
|
+
this.networkRequests.splice(succeeded, 1);
|
|
244
|
+
}
|
|
245
|
+
this.networkRequests.push(call);
|
|
246
|
+
}
|
|
216
247
|
/**
|
|
217
248
|
* Capture HTML snapshots of all iframes on the page
|
|
218
249
|
*/
|
|
@@ -249,12 +280,7 @@ class Action {
|
|
|
249
280
|
async captureBrowserLogs() {
|
|
250
281
|
try {
|
|
251
282
|
const logs = await this.actor.grabBrowserLogs();
|
|
252
|
-
|
|
253
|
-
const importantLogs = logs.filter((log) => {
|
|
254
|
-
const level = log.type || log.level;
|
|
255
|
-
return ['info', 'error', 'warning', 'warn'].includes(level);
|
|
256
|
-
});
|
|
257
|
-
return importantLogs;
|
|
283
|
+
return logs.map(toBrowserLog).filter((log) => IMPORTANT_LOG_LEVELS.has(log.type));
|
|
258
284
|
}
|
|
259
285
|
catch (error) {
|
|
260
286
|
debugLog('Failed to capture browser logs:', error);
|
|
@@ -270,7 +296,7 @@ class Action {
|
|
|
270
296
|
const stepListener = attachStepLogger(executedSteps, assertionSteps);
|
|
271
297
|
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
272
298
|
this.playwrightGroupId = groupId;
|
|
273
|
-
const
|
|
299
|
+
const detachResponses = this.captureResponses();
|
|
274
300
|
const activeSpan = Observability.getSpan();
|
|
275
301
|
const tracer = trace.getTracer('ai');
|
|
276
302
|
const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
|
|
@@ -314,7 +340,7 @@ class Action {
|
|
|
314
340
|
}
|
|
315
341
|
finally {
|
|
316
342
|
this.restorePageTimeout();
|
|
317
|
-
|
|
343
|
+
detachResponses();
|
|
318
344
|
if (groupId)
|
|
319
345
|
await this.recorder.endAction();
|
|
320
346
|
detachStepLogger(stepListener);
|
|
@@ -393,6 +419,11 @@ async function captureHtml(page, frame, actor) {
|
|
|
393
419
|
return actor.grabSource();
|
|
394
420
|
throw new Error('Playwright page is unavailable for HTML capture');
|
|
395
421
|
}
|
|
422
|
+
function toBrowserLog(log) {
|
|
423
|
+
const type = typeof log.type === 'function' ? log.type() : log.type || log.level || 'log';
|
|
424
|
+
const text = typeof log.text === 'function' ? log.text() : log.text || log.message || String(log);
|
|
425
|
+
return { type, text: text.replace(/\s+/g, ' ').trim() };
|
|
426
|
+
}
|
|
396
427
|
async function captureTitle(page, actor) {
|
|
397
428
|
if (page?.title)
|
|
398
429
|
return page.title();
|
|
@@ -15,7 +15,7 @@ export function WithWebMode(Base) {
|
|
|
15
15
|
researcher: ctx.explorBot.agentResearcher(),
|
|
16
16
|
navigator: ctx.explorBot.agentNavigator(),
|
|
17
17
|
});
|
|
18
|
-
const { see, context, visualClick
|
|
18
|
+
const { see, context, visualClick } = agentTools;
|
|
19
19
|
const tools = {
|
|
20
20
|
navigate: tool({
|
|
21
21
|
description: 'Navigate to a URL or page description using AI-powered navigation.',
|
|
@@ -115,7 +115,6 @@ export function WithWebMode(Base) {
|
|
|
115
115
|
}),
|
|
116
116
|
...codeceptTools,
|
|
117
117
|
context,
|
|
118
|
-
learnExperience,
|
|
119
118
|
};
|
|
120
119
|
if (see)
|
|
121
120
|
tools.see = see;
|
package/dist/src/ai/captain.d.ts
CHANGED
|
@@ -59,6 +59,16 @@ export declare class Captain extends CaptainBase implements Agent {
|
|
|
59
59
|
planSummary(): string;
|
|
60
60
|
reinjectContextIfNeeded(conversation: Conversation, currentState: WebPageState): Promise<void>;
|
|
61
61
|
coreTools(task: Task, onDone: (summary: string) => void): {
|
|
62
|
+
learnExperience: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
|
|
63
|
+
fileTag: any;
|
|
64
|
+
sectionIndex: any;
|
|
65
|
+
}, {
|
|
66
|
+
title: string;
|
|
67
|
+
url: string;
|
|
68
|
+
content: string;
|
|
69
|
+
} | {
|
|
70
|
+
error: string;
|
|
71
|
+
}, import("@ai-sdk/provider-utils").Context>>;
|
|
62
72
|
done: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
|
|
63
73
|
summary: string;
|
|
64
74
|
details?: string;
|
|
@@ -91,6 +101,16 @@ export declare class Captain extends CaptainBase implements Agent {
|
|
|
91
101
|
}, import("@ai-sdk/provider-utils").Context>>;
|
|
92
102
|
};
|
|
93
103
|
tools(task: Task, onDone: (summary: string) => void): Promise<{
|
|
104
|
+
learnExperience: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
|
|
105
|
+
fileTag: any;
|
|
106
|
+
sectionIndex: any;
|
|
107
|
+
}, {
|
|
108
|
+
title: string;
|
|
109
|
+
url: string;
|
|
110
|
+
content: string;
|
|
111
|
+
} | {
|
|
112
|
+
error: string;
|
|
113
|
+
}, import("@ai-sdk/provider-utils").Context>>;
|
|
94
114
|
done: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
|
|
95
115
|
summary: string;
|
|
96
116
|
details?: string;
|
package/dist/src/ai/captain.js
CHANGED
|
@@ -15,7 +15,7 @@ import { WithWebMode } from "./captain/web-mode.js";
|
|
|
15
15
|
import { toolExecutionLabel } from './conversation.js';
|
|
16
16
|
import { Researcher } from "./researcher.js";
|
|
17
17
|
import { TaskAgent } from "./task-agent.js";
|
|
18
|
-
import { withdrawVisionTools } from "./tools.js";
|
|
18
|
+
import { createLearnExperienceTool, withdrawVisionTools } from "./tools.js";
|
|
19
19
|
const MAX_STEPS = 15;
|
|
20
20
|
const CaptainBase = WithTestMode(WithWebMode(WithIdleMode(TaskAgent)));
|
|
21
21
|
export class Captain extends CaptainBase {
|
|
@@ -216,6 +216,15 @@ export class Captain extends CaptainBase {
|
|
|
216
216
|
}
|
|
217
217
|
coreTools(task, onDone) {
|
|
218
218
|
return {
|
|
219
|
+
learnExperience: createLearnExperienceTool({
|
|
220
|
+
getExperienceTracker: () => this.getExperienceTracker(),
|
|
221
|
+
getState: () => {
|
|
222
|
+
const state = this.explorBot.stateManager().getCurrentState();
|
|
223
|
+
if (!state)
|
|
224
|
+
return null;
|
|
225
|
+
return ActionResult.fromState(state);
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
219
228
|
done: tool({
|
|
220
229
|
description: 'Call when the user request is fulfilled.',
|
|
221
230
|
inputSchema: z.object({
|
package/dist/src/ai/driller.js
CHANGED
|
@@ -14,7 +14,7 @@ import { eidxInContainer } from "../utils/web-eidx.js";
|
|
|
14
14
|
import { WebElement } from "../utils/web-element.js";
|
|
15
15
|
import { drillLocatorRule } from "./rules.js";
|
|
16
16
|
import { TaskAgent, isInteractive } from "./task-agent.js";
|
|
17
|
-
import { createCodeceptJSTools } from "./tools.js";
|
|
17
|
+
import { createCodeceptJSTools, createLearnExperienceTool } from "./tools.js";
|
|
18
18
|
const debugLog = createDebug('explorbot:driller');
|
|
19
19
|
export class Driller extends TaskAgent {
|
|
20
20
|
ACTION_TOOLS = ['click', 'pressKey', 'form'];
|
|
@@ -234,7 +234,11 @@ export class Driller extends TaskAgent {
|
|
|
234
234
|
conversation.addUserText(await this.buildComponentPrompt(originalState, component));
|
|
235
235
|
let finished = false;
|
|
236
236
|
const actionTools = this.createVerifiedActionTools(createCodeceptJSTools(this.toolDeps, test), component);
|
|
237
|
-
const
|
|
237
|
+
const learnExperience = createLearnExperienceTool({
|
|
238
|
+
getExperienceTracker: () => this.getExperienceTracker(),
|
|
239
|
+
getState: () => ActionResult.fromState(this.stateManager.getCurrentState() || originalState),
|
|
240
|
+
});
|
|
241
|
+
const tools = { ...actionTools, learnExperience, ...this.createDrillFlowTools(originalState, test, interactive) };
|
|
238
242
|
await loop(async ({ stop, iteration }) => {
|
|
239
243
|
debugLog(`Drilling component ${component.name}, iteration ${iteration}`);
|
|
240
244
|
setActivity(`${this.emoji} Drilling ${component.name}...`, 'action');
|
|
@@ -9,18 +9,49 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
|
|
|
9
9
|
method: any;
|
|
10
10
|
path: any;
|
|
11
11
|
}, {
|
|
12
|
+
source: string;
|
|
13
|
+
method: any;
|
|
14
|
+
path: any;
|
|
15
|
+
definition: string;
|
|
16
|
+
rejectedCapture: {
|
|
17
|
+
status: number;
|
|
18
|
+
requestBody: any;
|
|
19
|
+
};
|
|
20
|
+
usable?: undefined;
|
|
21
|
+
rejectedRequestBody?: undefined;
|
|
22
|
+
status?: undefined;
|
|
23
|
+
requestBody?: undefined;
|
|
24
|
+
error?: undefined;
|
|
25
|
+
} | {
|
|
26
|
+
source: string;
|
|
27
|
+
method: any;
|
|
28
|
+
path: any;
|
|
29
|
+
usable: boolean;
|
|
30
|
+
rejectedRequestBody: any;
|
|
31
|
+
status: number;
|
|
32
|
+
definition?: undefined;
|
|
33
|
+
rejectedCapture?: undefined;
|
|
34
|
+
requestBody?: undefined;
|
|
35
|
+
error?: undefined;
|
|
36
|
+
} | {
|
|
12
37
|
source: string;
|
|
13
38
|
method: string;
|
|
14
39
|
path: string;
|
|
15
40
|
status: number;
|
|
16
41
|
requestBody: any;
|
|
17
42
|
definition?: undefined;
|
|
43
|
+
rejectedCapture?: undefined;
|
|
44
|
+
usable?: undefined;
|
|
45
|
+
rejectedRequestBody?: undefined;
|
|
18
46
|
error?: undefined;
|
|
19
47
|
} | {
|
|
20
48
|
source: string;
|
|
21
49
|
definition: string;
|
|
22
50
|
method?: undefined;
|
|
23
51
|
path?: undefined;
|
|
52
|
+
rejectedCapture?: undefined;
|
|
53
|
+
usable?: undefined;
|
|
54
|
+
rejectedRequestBody?: undefined;
|
|
24
55
|
status?: undefined;
|
|
25
56
|
requestBody?: undefined;
|
|
26
57
|
error?: undefined;
|
|
@@ -29,20 +60,25 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
|
|
|
29
60
|
error: any;
|
|
30
61
|
method?: undefined;
|
|
31
62
|
path?: undefined;
|
|
63
|
+
definition?: undefined;
|
|
64
|
+
rejectedCapture?: undefined;
|
|
65
|
+
usable?: undefined;
|
|
66
|
+
rejectedRequestBody?: undefined;
|
|
32
67
|
status?: undefined;
|
|
33
68
|
requestBody?: undefined;
|
|
34
|
-
definition?: undefined;
|
|
35
69
|
}, import("@ai-sdk/provider-utils").Context>>;
|
|
36
70
|
request: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<any, {
|
|
37
71
|
success: boolean;
|
|
38
72
|
error: string;
|
|
39
73
|
status?: undefined;
|
|
40
74
|
statusText?: undefined;
|
|
75
|
+
category?: undefined;
|
|
41
76
|
errorPreview?: undefined;
|
|
42
77
|
} | {
|
|
43
78
|
success: boolean;
|
|
44
79
|
status: number;
|
|
45
80
|
statusText: string;
|
|
81
|
+
category: ResponseCategory;
|
|
46
82
|
errorPreview: string;
|
|
47
83
|
error?: undefined;
|
|
48
84
|
} | {
|
|
@@ -50,6 +86,7 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
|
|
|
50
86
|
status: number;
|
|
51
87
|
error?: undefined;
|
|
52
88
|
statusText?: undefined;
|
|
89
|
+
category?: undefined;
|
|
53
90
|
errorPreview?: undefined;
|
|
54
91
|
}, import("@ai-sdk/provider-utils").Context>>;
|
|
55
92
|
finish: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
|
|
@@ -88,3 +125,5 @@ export interface FishermanResult {
|
|
|
88
125
|
reason: string;
|
|
89
126
|
}>;
|
|
90
127
|
}
|
|
128
|
+
type ResponseCategory = 'validation' | 'authorization' | 'not_found' | 'conflict' | 'temporary' | 'server' | 'client';
|
|
129
|
+
export {};
|
|
@@ -23,6 +23,29 @@ export function createFishermanTools(apiClient, requestStore, opts) {
|
|
|
23
23
|
tag('step').log(`Fisherman: spec lookup ${method} ${path}`);
|
|
24
24
|
const captured = requestStore.findCapturedRequest(method, path);
|
|
25
25
|
if (captured) {
|
|
26
|
+
if (captured.status >= 400) {
|
|
27
|
+
const rejectedCapture = {
|
|
28
|
+
status: captured.status,
|
|
29
|
+
requestBody: captured.requestBody || 'no body',
|
|
30
|
+
};
|
|
31
|
+
if (opts.spec) {
|
|
32
|
+
try {
|
|
33
|
+
const definition = extractEndpointDefinition(opts.spec, path, opts.baseEndpoint);
|
|
34
|
+
return { source: 'spec', method, path, definition, rejectedCapture };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { source: 'captured', method, path, usable: false, rejectedRequestBody: captured.requestBody || 'no body', status: captured.status };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
source: 'captured',
|
|
42
|
+
method: captured.method,
|
|
43
|
+
path: captured.path,
|
|
44
|
+
status: captured.status,
|
|
45
|
+
usable: false,
|
|
46
|
+
rejectedRequestBody: captured.requestBody || 'no body',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
26
49
|
return {
|
|
27
50
|
source: 'captured',
|
|
28
51
|
method: captured.method,
|
|
@@ -74,6 +97,7 @@ export function createFishermanTools(apiClient, requestStore, opts) {
|
|
|
74
97
|
success: false,
|
|
75
98
|
status: reqResult.status,
|
|
76
99
|
statusText: reqResult.statusText,
|
|
100
|
+
category: responseCategory(reqResult.status),
|
|
77
101
|
errorPreview: reqResult.rawResponseBody.substring(0, 300),
|
|
78
102
|
};
|
|
79
103
|
}
|
|
@@ -127,6 +151,21 @@ export function createFishermanTools(apiClient, requestStore, opts) {
|
|
|
127
151
|
};
|
|
128
152
|
return { tools, getResult, isFinished };
|
|
129
153
|
}
|
|
154
|
+
function responseCategory(status) {
|
|
155
|
+
if (status === 400 || status === 422)
|
|
156
|
+
return 'validation';
|
|
157
|
+
if (status === 401 || status === 403)
|
|
158
|
+
return 'authorization';
|
|
159
|
+
if (status === 404)
|
|
160
|
+
return 'not_found';
|
|
161
|
+
if (status === 409)
|
|
162
|
+
return 'conflict';
|
|
163
|
+
if (status === 408 || status === 425 || status === 429)
|
|
164
|
+
return 'temporary';
|
|
165
|
+
if (status >= 500)
|
|
166
|
+
return 'server';
|
|
167
|
+
return 'client';
|
|
168
|
+
}
|
|
130
169
|
function extractKeyFields(body, result = {}, depth = 0) {
|
|
131
170
|
if (!body || typeof body !== 'object' || depth > 5)
|
|
132
171
|
return result;
|
package/dist/src/ai/fisherman.js
CHANGED
|
@@ -170,7 +170,8 @@ export class Fisherman {
|
|
|
170
170
|
RULES:
|
|
171
171
|
- Always call getEndpointSpec before your first request to an unfamiliar endpoint
|
|
172
172
|
- Chain requests logically — create parent resources before children
|
|
173
|
-
-
|
|
173
|
+
- Use the response category and error text to decide what failed: validation requires corrected data, authorization requires valid access, not_found requires a valid path or parent, and conflict requires resolving the conflicting state
|
|
174
|
+
- Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
|
|
174
175
|
- Use realistic but unique data for each item (vary names, titles)
|
|
175
176
|
|
|
176
177
|
${dataProtectionRules}
|
|
@@ -39,12 +39,13 @@ declare class Navigator implements Agent {
|
|
|
39
39
|
resolveState(message: string, actionResult: ActionResult, opts?: {
|
|
40
40
|
action?: Action;
|
|
41
41
|
expectedUrl?: string;
|
|
42
|
+
experience?: string;
|
|
42
43
|
onAttempt?: (attempt: {
|
|
43
44
|
code: string;
|
|
44
45
|
error?: string;
|
|
45
46
|
}) => void;
|
|
46
47
|
}): Promise<boolean>;
|
|
47
|
-
buildResolutionPrompt(message: string, actionResult: ActionResult): Promise<string>;
|
|
48
|
+
buildResolutionPrompt(message: string, actionResult: ActionResult, injectedExperience?: string): Promise<string>;
|
|
48
49
|
buildRetryFeedback(failures: BatchFailure[], includeHtml: boolean, actionResult: ActionResult): Promise<string>;
|
|
49
50
|
executeAttempt(action: Action, codeBlock: string, message: string): Promise<{
|
|
50
51
|
ok: boolean;
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -200,7 +200,7 @@ class Navigator {
|
|
|
200
200
|
const expectedUrl = opts?.expectedUrl;
|
|
201
201
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
202
202
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
203
|
-
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult));
|
|
203
|
+
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
|
|
204
204
|
let stopReason = null;
|
|
205
205
|
const tools = {
|
|
206
206
|
stop: tool({
|
|
@@ -333,14 +333,10 @@ class Navigator {
|
|
|
333
333
|
}
|
|
334
334
|
return resolved;
|
|
335
335
|
}
|
|
336
|
-
async buildResolutionPrompt(message, actionResult) {
|
|
337
|
-
let experience = '';
|
|
338
|
-
if (!actionResult.isInsideIframe) {
|
|
339
|
-
|
|
340
|
-
if (successful.length > 0) {
|
|
341
|
-
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
|
|
342
|
-
experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
|
|
343
|
-
}
|
|
336
|
+
async buildResolutionPrompt(message, actionResult, injectedExperience) {
|
|
337
|
+
let experience = injectedExperience || '';
|
|
338
|
+
if (!experience && !actionResult.isInsideIframe) {
|
|
339
|
+
experience = this.experienceTracker.renderExperienceFor(actionResult);
|
|
344
340
|
}
|
|
345
341
|
return dedent `
|
|
346
342
|
<message>
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -15,6 +15,8 @@ import { isInteractive } from "./task-agent.js";
|
|
|
15
15
|
import { withdrawVisionTools } from "./tools.js";
|
|
16
16
|
const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
|
|
17
17
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
18
|
+
const PILOT_MESSAGE_LIMIT = 2;
|
|
19
|
+
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
18
20
|
export class Pilot {
|
|
19
21
|
emoji = '🧭';
|
|
20
22
|
provider;
|
|
@@ -411,9 +413,13 @@ export class Pilot {
|
|
|
411
413
|
the elements needed for the scenario. The page summary does not list every element.
|
|
412
414
|
Prefer interacting with the current page over navigating away.
|
|
413
415
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
Tester never sees <experience> — a recorded recipe reaches it only when you open one.
|
|
417
|
+
The entries listed are what was recorded on the page you are on now; recipes for the
|
|
418
|
+
pages this test moves to are listed when it gets there. Open the ones whose titles fit a
|
|
419
|
+
step taken from here, and say so in the plan when none of them fit.
|
|
420
|
+
Do NOT rewrite a loaded recipe's code — the raw recipe is forwarded to Tester
|
|
421
|
+
automatically. Reference it by step ("apply recipe steps 1–3, then…") and call out
|
|
422
|
+
anywhere your scenario diverges from it.
|
|
417
423
|
|
|
418
424
|
Be concise and specific. Tester will follow your plan.
|
|
419
425
|
`, 'pilot.planTest', { tools: true, maxToolRoundtrips: 3, task });
|
|
@@ -452,7 +458,9 @@ export class Pilot {
|
|
|
452
458
|
${this.formatExpectations(task)}
|
|
453
459
|
|
|
454
460
|
First: evaluate whether this navigation makes sense for the scenario goal. If the page is unrelated, instruct Tester to back() or reset(). Then plan next steps.
|
|
455
|
-
|
|
461
|
+
|
|
462
|
+
Tester holds no recipe for this page until you load one — open the <experience> entries whose titles fit a step you are about to instruct.
|
|
463
|
+
`, 'pilot.reviewNewPage', { tools: true, maxToolRoundtrips: 2, task });
|
|
456
464
|
}
|
|
457
465
|
async analyzeProgress(task, currentState, testerConversation) {
|
|
458
466
|
tag('substep').log('Pilot analyzing progress...');
|
|
@@ -479,6 +487,8 @@ export class Pilot {
|
|
|
479
487
|
</recent_actions>
|
|
480
488
|
|
|
481
489
|
What should Tester do next?
|
|
490
|
+
|
|
491
|
+
Before proposing new locators for a step that keeps failing, check <experience> for a recorded recipe covering it and load it.
|
|
482
492
|
`, 'pilot.analyze', { tools: hasFailures, maxToolRoundtrips: hasFailures ? 2 : 0, task });
|
|
483
493
|
const contextToAttach = await this.fetchRequestedContext(text, currentState);
|
|
484
494
|
if (contextToAttach) {
|
|
@@ -580,10 +590,10 @@ export class Pilot {
|
|
|
580
590
|
debugLog(`sendToPilot: ${functionId}, tools: ${!!opts.tools}, roundtrips: ${opts.maxToolRoundtrips ?? 0}`);
|
|
581
591
|
let finalUserText = userText;
|
|
582
592
|
if (opts.tools) {
|
|
593
|
+
this.conversation.cleanupTag('experience', '...cleaned experience index...');
|
|
583
594
|
const tocBlock = this.getExperienceToc();
|
|
584
|
-
if (tocBlock)
|
|
595
|
+
if (tocBlock)
|
|
585
596
|
finalUserText = `${tocBlock}\n\n${userText}`;
|
|
586
|
-
}
|
|
587
597
|
}
|
|
588
598
|
this.conversation.addUserText(finalUserText);
|
|
589
599
|
const tools = { ...this.pickPlanningTools(), ...this.buildPreconditionTool(opts.task) };
|
|
@@ -595,9 +605,10 @@ export class Pilot {
|
|
|
595
605
|
telemetry: { functionId },
|
|
596
606
|
});
|
|
597
607
|
const text = result?.response?.text || '';
|
|
598
|
-
const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => e.output.content);
|
|
608
|
+
const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => ({ url: e.output.url, content: e.output.content }));
|
|
599
609
|
if (learned.length === 0)
|
|
600
610
|
return text;
|
|
611
|
+
opts.task.applyExperience(learned);
|
|
601
612
|
return dedent `
|
|
602
613
|
${text}
|
|
603
614
|
|
|
@@ -605,7 +616,7 @@ export class Pilot {
|
|
|
605
616
|
Recipes from prior successful runs that Pilot judged relevant. Locators worked then; the page may have changed since.
|
|
606
617
|
Treat code blocks below as a starting hypothesis. If a locator misses, fall back to ARIA/UI-map.
|
|
607
618
|
|
|
608
|
-
${learned.join('\n\n')}
|
|
619
|
+
${learned.map((recipe) => recipe.content).join('\n\n')}
|
|
609
620
|
</applied_experience>
|
|
610
621
|
`;
|
|
611
622
|
}
|
|
@@ -638,6 +649,7 @@ export class Pilot {
|
|
|
638
649
|
return planning;
|
|
639
650
|
}
|
|
640
651
|
buildPreconditionTool(task) {
|
|
652
|
+
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.';
|
|
641
653
|
return {
|
|
642
654
|
precondition: tool({
|
|
643
655
|
description: 'Create fresh disposable data that the test will act on (edit, delete, filter). Describe WHAT to create, not what exists. Do NOT request users. Examples: "1 post", "1 comment", "1 label named Bug".',
|
|
@@ -652,7 +664,7 @@ export class Pilot {
|
|
|
652
664
|
const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
|
|
653
665
|
if (skipReason)
|
|
654
666
|
return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
655
|
-
return { noted: true, prepared: false, reason:
|
|
667
|
+
return { noted: true, prepared: false, reason: unavailable };
|
|
656
668
|
}
|
|
657
669
|
const result = await this.fisherman.prepareData(description, task.startUrl, task.sessionName);
|
|
658
670
|
if (!result.success || result.created.length === 0) {
|
|
@@ -661,7 +673,7 @@ export class Pilot {
|
|
|
661
673
|
const skipReason = await this.checkDataAvailability(task, description, result.summary);
|
|
662
674
|
if (skipReason)
|
|
663
675
|
return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
664
|
-
return { noted: true, prepared: false, reason: result.summary };
|
|
676
|
+
return { noted: true, prepared: false, reason: `${result.summary || 'Data preparation failed'}. ${unavailable}` };
|
|
665
677
|
}
|
|
666
678
|
const items = result.created.map((c) => {
|
|
667
679
|
const parts = [c.type];
|
|
@@ -696,7 +708,7 @@ export class Pilot {
|
|
|
696
708
|
|
|
697
709
|
Reply with YES or NO on the first line, then a one-sentence reason on the second line.
|
|
698
710
|
`;
|
|
699
|
-
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question);
|
|
711
|
+
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question).catch(() => null);
|
|
700
712
|
if (!answer)
|
|
701
713
|
return null;
|
|
702
714
|
const firstLine = answer.split('\n')[0]?.trim().toUpperCase() ?? '';
|
|
@@ -779,14 +791,6 @@ export class Pilot {
|
|
|
779
791
|
}
|
|
780
792
|
async fetchRequestedContext(text, currentState) {
|
|
781
793
|
const parts = [];
|
|
782
|
-
if (text.includes('ATTACH_HTML')) {
|
|
783
|
-
const html = await currentState.simplifiedHtml();
|
|
784
|
-
parts.push(dedent `
|
|
785
|
-
<page_html>
|
|
786
|
-
${html}
|
|
787
|
-
</page_html>
|
|
788
|
-
`);
|
|
789
|
-
}
|
|
790
794
|
if (text.includes('ATTACH_ARIA')) {
|
|
791
795
|
parts.push(dedent `
|
|
792
796
|
<page_aria>
|
|
@@ -942,6 +946,19 @@ export class Pilot {
|
|
|
942
946
|
const ariaDiff = t.output?.pageDiff?.ariaChanges;
|
|
943
947
|
if (ariaDiff)
|
|
944
948
|
line += `\n ${ariaDiff}`;
|
|
949
|
+
if (t.output?.pageDiff?.urlChanged)
|
|
950
|
+
line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
|
|
951
|
+
const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r) => r.status >= 400);
|
|
952
|
+
if (failedRequests.length > 0) {
|
|
953
|
+
line += `\n requests: ${failedRequests.map((r) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
|
|
954
|
+
}
|
|
955
|
+
const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
|
|
956
|
+
if (messages.length > 0) {
|
|
957
|
+
line += `\n messages: ${messages.map((m) => m.slice(0, PILOT_MESSAGE_MAX_LENGTH)).join(' | ')}`;
|
|
958
|
+
}
|
|
959
|
+
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
960
|
+
if (consoleError)
|
|
961
|
+
line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
945
962
|
return line;
|
|
946
963
|
})
|
|
947
964
|
.join('\n\n');
|
|
@@ -1007,10 +1024,10 @@ export class Pilot {
|
|
|
1007
1024
|
role, icon classes with "or" in one XPath. If empty, broaden (drop role filter). Pass discovered
|
|
1008
1025
|
XPath into NEXT instruction.
|
|
1009
1026
|
|
|
1010
|
-
To request more context, mention
|
|
1027
|
+
To request more context, mention ATTACH_ARIA, ATTACH_SUMMARY, or ATTACH_UI_MAP — only when recent actions show failures.
|
|
1011
1028
|
|
|
1012
|
-
Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck,
|
|
1013
|
-
back, getVisitedStates, reset, stop, finish, record.
|
|
1029
|
+
Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
|
|
1030
|
+
visualClick, back, getVisitedStates, reset, stop, finish, record.
|
|
1014
1031
|
Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
|
|
1015
1032
|
|
|
1016
1033
|
${capabilityGroundingRule}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { normalizeUrl } from "../../state-manager.js";
|
|
4
|
-
import {
|
|
4
|
+
import { isSamePageFamily } from "../../utils/url-matcher.js";
|
|
5
5
|
const planRegistry = new Map();
|
|
6
6
|
export function registerPlan(url, plan, feature, stateHash) {
|
|
7
7
|
const key = buildKey(url, feature);
|
|
@@ -28,21 +28,7 @@ function buildKey(url, feature) {
|
|
|
28
28
|
return normalized;
|
|
29
29
|
}
|
|
30
30
|
export function isTemplateMatch(urlA, urlB) {
|
|
31
|
-
|
|
32
|
-
const partsB = normalizeUrl(urlB).split('/');
|
|
33
|
-
if (partsA.length !== partsB.length)
|
|
34
|
-
return false;
|
|
35
|
-
let diffCount = 0;
|
|
36
|
-
for (let i = 0; i < partsA.length; i++) {
|
|
37
|
-
if (partsA[i] === partsB[i])
|
|
38
|
-
continue;
|
|
39
|
-
diffCount++;
|
|
40
|
-
if (diffCount > 1)
|
|
41
|
-
return false;
|
|
42
|
-
if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i]))
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
return diffCount === 1;
|
|
31
|
+
return isSamePageFamily(urlA, urlB);
|
|
46
32
|
}
|
|
47
33
|
export function getPlannedByStateHash(hash) {
|
|
48
34
|
for (const record of planRegistry.values()) {
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -127,7 +127,7 @@ export class Planner extends PlannerBase {
|
|
|
127
127
|
}
|
|
128
128
|
const actionResult = ActionResult.fromState(state);
|
|
129
129
|
const combinedHtml = await actionResult.combinedHtml();
|
|
130
|
-
const similarHash = await findSimilarStateHash(combinedHtml);
|
|
130
|
+
const similarHash = await findSimilarStateHash(combinedHtml, state.url);
|
|
131
131
|
if (similarHash) {
|
|
132
132
|
const planned = getPlannedByStateHash(similarHash);
|
|
133
133
|
if (planned) {
|