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/src/action.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { context, trace } from '@opentelemetry/api';
|
|
4
4
|
import { container, recorder } from 'codeceptjs';
|
|
5
5
|
import * as codeceptjs from 'codeceptjs';
|
|
6
|
-
import { ActionResult, type FocusedElement } from './action-result.js';
|
|
6
|
+
import { ActionResult, type FocusedElement, type NetworkCall } from './action-result.js';
|
|
7
7
|
import { clearActivity, setActivity } from './activity.ts';
|
|
8
8
|
import { ConfigParser, outputPath } from './config.js';
|
|
9
9
|
import type { ExplorbotConfig } from './config.js';
|
|
@@ -21,6 +21,8 @@ const debugLog = createDebug('explorbot:action');
|
|
|
21
21
|
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
|
|
22
22
|
const DEFAULT_ACTION_TIMEOUT = 3000;
|
|
23
23
|
const DEFAULT_PAGE_TIMEOUT = 3000;
|
|
24
|
+
const MAX_NETWORK_CALLS = 10;
|
|
25
|
+
const IMPORTANT_LOG_LEVELS = new Set(['info', 'error', 'warning', 'warn']);
|
|
24
26
|
|
|
25
27
|
class Action {
|
|
26
28
|
private actor: CodeceptJS.I;
|
|
@@ -38,6 +40,8 @@ class Action {
|
|
|
38
40
|
private recorder?: PlaywrightRecorder;
|
|
39
41
|
private recovery: RecoveryRunner;
|
|
40
42
|
private mainDocumentStatus: number | undefined = undefined;
|
|
43
|
+
private networkRequests: NetworkCall[] = [];
|
|
44
|
+
private baseOrigin: string;
|
|
41
45
|
|
|
42
46
|
constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder, recovery?: RecoveryRunner) {
|
|
43
47
|
this.actor = actor;
|
|
@@ -46,6 +50,7 @@ class Action {
|
|
|
46
50
|
this.playwrightHelper = container.helpers('Playwright');
|
|
47
51
|
this.recorder = recorder;
|
|
48
52
|
this.recovery = recovery || ((fn) => fn());
|
|
53
|
+
this.baseOrigin = URL.parse(this.config.playwright?.url || '')?.origin || '';
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
async saveScreenshot(): Promise<string | undefined> {
|
|
@@ -127,9 +132,7 @@ class Action {
|
|
|
127
132
|
const logPath = join(statesDir, logFile);
|
|
128
133
|
const formattedLogs = browserLogs.map((log: any) => {
|
|
129
134
|
const logTimestamp = new Date().toISOString();
|
|
130
|
-
|
|
131
|
-
const message = log.text || log.message || String(log);
|
|
132
|
-
return `[${logTimestamp}] ${level}: ${message}`;
|
|
135
|
+
return `[${logTimestamp}] ${log.type.toUpperCase()}: ${log.text}`;
|
|
133
136
|
});
|
|
134
137
|
fs.writeFileSync(logPath, `${formattedLogs.join('\n')}\n`, 'utf8');
|
|
135
138
|
|
|
@@ -157,12 +160,16 @@ class Action {
|
|
|
157
160
|
ariaSnapshotFile = ariaFileName;
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
const networkRequests = this.networkRequests;
|
|
164
|
+
this.networkRequests = [];
|
|
165
|
+
|
|
160
166
|
const result = new ActionResult({
|
|
161
167
|
html,
|
|
162
168
|
title,
|
|
163
169
|
httpStatus: await this.captureMainDocumentStatus(),
|
|
164
170
|
url,
|
|
165
171
|
browserLogs,
|
|
172
|
+
networkRequests,
|
|
166
173
|
htmlFile,
|
|
167
174
|
logFile,
|
|
168
175
|
screenshotFile,
|
|
@@ -202,26 +209,53 @@ class Action {
|
|
|
202
209
|
}
|
|
203
210
|
}
|
|
204
211
|
|
|
205
|
-
private
|
|
212
|
+
private captureResponses(): () => void {
|
|
206
213
|
const page = this.playwrightHelper.page;
|
|
207
214
|
if (!page?.on || !page?.off) return () => {};
|
|
208
215
|
|
|
209
216
|
this.mainDocumentStatus = undefined;
|
|
217
|
+
this.networkRequests = [];
|
|
210
218
|
|
|
211
219
|
const handler = (response: any) => {
|
|
212
220
|
const request = response.request();
|
|
213
|
-
if (request.resourceType() !== 'document') return;
|
|
214
|
-
if (response.frame() !== page.mainFrame()) return;
|
|
215
221
|
const status = response.status();
|
|
216
222
|
if (typeof status !== 'number') return;
|
|
217
223
|
if (status <= 0) return;
|
|
218
|
-
|
|
224
|
+
|
|
225
|
+
if (request.resourceType() === 'document') {
|
|
226
|
+
if (response.frame() !== page.mainFrame()) return;
|
|
227
|
+
this.mainDocumentStatus = status;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.recordNetworkCall(request, status);
|
|
219
232
|
};
|
|
220
233
|
|
|
221
234
|
page.on('response', handler);
|
|
222
235
|
return () => page.off('response', handler);
|
|
223
236
|
}
|
|
224
237
|
|
|
238
|
+
private recordNetworkCall(request: any, status: number): void {
|
|
239
|
+
const resourceType = request.resourceType();
|
|
240
|
+
if (resourceType !== 'xhr' && resourceType !== 'fetch') return;
|
|
241
|
+
|
|
242
|
+
const url = URL.parse(request.url());
|
|
243
|
+
if (!url) return;
|
|
244
|
+
if (url.origin !== this.baseOrigin) return;
|
|
245
|
+
|
|
246
|
+
const call: NetworkCall = { method: request.method(), path: url.pathname, status };
|
|
247
|
+
if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status)) return;
|
|
248
|
+
|
|
249
|
+
if (this.networkRequests.length >= MAX_NETWORK_CALLS) {
|
|
250
|
+
if (status < 400) return;
|
|
251
|
+
const succeeded = this.networkRequests.findIndex((r) => r.status < 400);
|
|
252
|
+
if (succeeded === -1) return;
|
|
253
|
+
this.networkRequests.splice(succeeded, 1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.networkRequests.push(call);
|
|
257
|
+
}
|
|
258
|
+
|
|
225
259
|
/**
|
|
226
260
|
* Capture HTML snapshots of all iframes on the page
|
|
227
261
|
*/
|
|
@@ -267,13 +301,7 @@ class Action {
|
|
|
267
301
|
try {
|
|
268
302
|
const logs = await (this.actor as any).grabBrowserLogs();
|
|
269
303
|
|
|
270
|
-
|
|
271
|
-
const importantLogs = logs.filter((log: any) => {
|
|
272
|
-
const level = log.type || log.level;
|
|
273
|
-
return ['info', 'error', 'warning', 'warn'].includes(level);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
return importantLogs;
|
|
304
|
+
return logs.map(toBrowserLog).filter((log: any) => IMPORTANT_LOG_LEVELS.has(log.type));
|
|
277
305
|
} catch (error) {
|
|
278
306
|
debugLog('Failed to capture browser logs:', error);
|
|
279
307
|
return [];
|
|
@@ -292,7 +320,7 @@ class Action {
|
|
|
292
320
|
const stepListener = attachStepLogger(executedSteps, assertionSteps);
|
|
293
321
|
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
294
322
|
this.playwrightGroupId = groupId;
|
|
295
|
-
const
|
|
323
|
+
const detachResponses = this.captureResponses();
|
|
296
324
|
const activeSpan = Observability.getSpan();
|
|
297
325
|
const tracer = trace.getTracer('ai');
|
|
298
326
|
const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
|
|
@@ -341,7 +369,7 @@ class Action {
|
|
|
341
369
|
throw err;
|
|
342
370
|
} finally {
|
|
343
371
|
this.restorePageTimeout();
|
|
344
|
-
|
|
372
|
+
detachResponses();
|
|
345
373
|
if (groupId) await this.recorder!.endAction();
|
|
346
374
|
detachStepLogger(stepListener);
|
|
347
375
|
if (stepSpan) {
|
|
@@ -429,6 +457,12 @@ async function captureHtml(page: any, frame: any, actor: any): Promise<string> {
|
|
|
429
457
|
throw new Error('Playwright page is unavailable for HTML capture');
|
|
430
458
|
}
|
|
431
459
|
|
|
460
|
+
function toBrowserLog(log: any): { type: string; text: string } {
|
|
461
|
+
const type = typeof log.type === 'function' ? log.type() : log.type || log.level || 'log';
|
|
462
|
+
const text = typeof log.text === 'function' ? log.text() : log.text || log.message || String(log);
|
|
463
|
+
return { type, text: text.replace(/\s+/g, ' ').trim() };
|
|
464
|
+
}
|
|
465
|
+
|
|
432
466
|
async function captureTitle(page: any, actor: any): Promise<string> {
|
|
433
467
|
if (page?.title) return page.title();
|
|
434
468
|
if (actor?.grabTitle) return actor.grabTitle();
|
|
@@ -16,7 +16,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
|
|
|
16
16
|
researcher: ctx.explorBot.agentResearcher(),
|
|
17
17
|
navigator: ctx.explorBot.agentNavigator(),
|
|
18
18
|
});
|
|
19
|
-
const { see, context, visualClick
|
|
19
|
+
const { see, context, visualClick } = agentTools;
|
|
20
20
|
|
|
21
21
|
const tools: Record<string, any> = {
|
|
22
22
|
navigate: tool({
|
|
@@ -124,7 +124,6 @@ export function WithWebMode<T extends Constructor>(Base: T) {
|
|
|
124
124
|
|
|
125
125
|
...codeceptTools,
|
|
126
126
|
context,
|
|
127
|
-
learnExperience,
|
|
128
127
|
};
|
|
129
128
|
|
|
130
129
|
if (see) tools.see = see;
|
package/src/ai/captain.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { Navigator } from './navigator.ts';
|
|
|
21
21
|
import type { Provider } from './provider.ts';
|
|
22
22
|
import { Researcher } from './researcher.ts';
|
|
23
23
|
import { TaskAgent } from './task-agent.ts';
|
|
24
|
-
import { withdrawVisionTools } from './tools.ts';
|
|
24
|
+
import { createLearnExperienceTool, withdrawVisionTools } from './tools.ts';
|
|
25
25
|
|
|
26
26
|
const MAX_STEPS = 15;
|
|
27
27
|
|
|
@@ -241,6 +241,14 @@ export class Captain extends CaptainBase implements Agent {
|
|
|
241
241
|
|
|
242
242
|
private coreTools(task: Task, onDone: (summary: string) => void) {
|
|
243
243
|
return {
|
|
244
|
+
learnExperience: createLearnExperienceTool({
|
|
245
|
+
getExperienceTracker: () => this.getExperienceTracker(),
|
|
246
|
+
getState: () => {
|
|
247
|
+
const state = this.explorBot.stateManager().getCurrentState();
|
|
248
|
+
if (!state) return null;
|
|
249
|
+
return ActionResult.fromState(state);
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
244
252
|
done: tool({
|
|
245
253
|
description: 'Call when the user request is fulfilled.',
|
|
246
254
|
inputSchema: z.object({
|
package/src/ai/driller.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { Navigator } from './navigator.ts';
|
|
|
30
30
|
import type { Provider } from './provider.ts';
|
|
31
31
|
import { drillLocatorRule } from './rules.ts';
|
|
32
32
|
import { TaskAgent, isInteractive } from './task-agent.ts';
|
|
33
|
-
import { createCodeceptJSTools } from './tools.ts';
|
|
33
|
+
import { createCodeceptJSTools, createLearnExperienceTool } from './tools.ts';
|
|
34
34
|
|
|
35
35
|
const debugLog = createDebug('explorbot:driller');
|
|
36
36
|
|
|
@@ -306,7 +306,11 @@ export class Driller extends TaskAgent implements Agent {
|
|
|
306
306
|
|
|
307
307
|
let finished = false;
|
|
308
308
|
const actionTools = this.createVerifiedActionTools(createCodeceptJSTools(this.toolDeps, test), component);
|
|
309
|
-
const
|
|
309
|
+
const learnExperience = createLearnExperienceTool({
|
|
310
|
+
getExperienceTracker: () => this.getExperienceTracker(),
|
|
311
|
+
getState: () => ActionResult.fromState(this.stateManager.getCurrentState() || originalState),
|
|
312
|
+
});
|
|
313
|
+
const tools = { ...actionTools, learnExperience, ...this.createDrillFlowTools(originalState, test, interactive) };
|
|
310
314
|
|
|
311
315
|
await loop(
|
|
312
316
|
async ({ stop, iteration }) => {
|
|
@@ -29,6 +29,28 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
29
29
|
|
|
30
30
|
const captured = requestStore.findCapturedRequest(method, path);
|
|
31
31
|
if (captured) {
|
|
32
|
+
if (captured.status >= 400) {
|
|
33
|
+
const rejectedCapture = {
|
|
34
|
+
status: captured.status,
|
|
35
|
+
requestBody: captured.requestBody || 'no body',
|
|
36
|
+
};
|
|
37
|
+
if (opts.spec) {
|
|
38
|
+
try {
|
|
39
|
+
const definition = extractEndpointDefinition(opts.spec, path, opts.baseEndpoint);
|
|
40
|
+
return { source: 'spec', method, path, definition, rejectedCapture };
|
|
41
|
+
} catch {
|
|
42
|
+
return { source: 'captured', method, path, usable: false, rejectedRequestBody: captured.requestBody || 'no body', status: captured.status };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
source: 'captured',
|
|
47
|
+
method: captured.method,
|
|
48
|
+
path: captured.path,
|
|
49
|
+
status: captured.status,
|
|
50
|
+
usable: false,
|
|
51
|
+
rejectedRequestBody: captured.requestBody || 'no body',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
32
54
|
return {
|
|
33
55
|
source: 'captured',
|
|
34
56
|
method: captured.method,
|
|
@@ -87,6 +109,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
87
109
|
success: false,
|
|
88
110
|
status: reqResult.status,
|
|
89
111
|
statusText: reqResult.statusText,
|
|
112
|
+
category: responseCategory(reqResult.status),
|
|
90
113
|
errorPreview: reqResult.rawResponseBody.substring(0, 300),
|
|
91
114
|
};
|
|
92
115
|
}
|
|
@@ -149,6 +172,16 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
149
172
|
return { tools, getResult, isFinished };
|
|
150
173
|
}
|
|
151
174
|
|
|
175
|
+
function responseCategory(status: number): ResponseCategory {
|
|
176
|
+
if (status === 400 || status === 422) return 'validation';
|
|
177
|
+
if (status === 401 || status === 403) return 'authorization';
|
|
178
|
+
if (status === 404) return 'not_found';
|
|
179
|
+
if (status === 409) return 'conflict';
|
|
180
|
+
if (status === 408 || status === 425 || status === 429) return 'temporary';
|
|
181
|
+
if (status >= 500) return 'server';
|
|
182
|
+
return 'client';
|
|
183
|
+
}
|
|
184
|
+
|
|
152
185
|
function extractKeyFields(body: any, result: Record<string, any> = {}, depth = 0): Record<string, any> {
|
|
153
186
|
if (!body || typeof body !== 'object' || depth > 5) return result;
|
|
154
187
|
|
|
@@ -179,3 +212,5 @@ export interface FishermanResult {
|
|
|
179
212
|
created: Array<{ type: string; id?: string | number; title?: string }>;
|
|
180
213
|
failed: Array<{ type: string; reason: string }>;
|
|
181
214
|
}
|
|
215
|
+
|
|
216
|
+
type ResponseCategory = 'validation' | 'authorization' | 'not_found' | 'conflict' | 'temporary' | 'server' | 'client';
|
package/src/ai/fisherman.ts
CHANGED
|
@@ -208,7 +208,8 @@ export class Fisherman implements Agent {
|
|
|
208
208
|
RULES:
|
|
209
209
|
- Always call getEndpointSpec before your first request to an unfamiliar endpoint
|
|
210
210
|
- Chain requests logically — create parent resources before children
|
|
211
|
-
-
|
|
211
|
+
- 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
|
|
212
|
+
- Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
|
|
212
213
|
- Use realistic but unique data for each item (vary names, titles)
|
|
213
214
|
|
|
214
215
|
${dataProtectionRules}
|
package/src/ai/navigator.ts
CHANGED
|
@@ -213,7 +213,7 @@ class Navigator implements Agent {
|
|
|
213
213
|
return reasons.join('; ') || null;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
|
|
216
|
+
async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; experience?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
|
|
217
217
|
if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
218
218
|
|
|
219
219
|
this.lastFailureReason = null;
|
|
@@ -226,7 +226,7 @@ class Navigator implements Agent {
|
|
|
226
226
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
227
227
|
|
|
228
228
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
229
|
-
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult));
|
|
229
|
+
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
|
|
230
230
|
|
|
231
231
|
let stopReason: string | null = null;
|
|
232
232
|
const tools = {
|
|
@@ -369,14 +369,10 @@ class Navigator implements Agent {
|
|
|
369
369
|
return resolved;
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
private async buildResolutionPrompt(message: string, actionResult: ActionResult): Promise<string> {
|
|
373
|
-
let experience = '';
|
|
374
|
-
if (!actionResult.isInsideIframe) {
|
|
375
|
-
|
|
376
|
-
if (successful.length > 0) {
|
|
377
|
-
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
|
|
378
|
-
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>`;
|
|
379
|
-
}
|
|
372
|
+
private async buildResolutionPrompt(message: string, actionResult: ActionResult, injectedExperience?: string): Promise<string> {
|
|
373
|
+
let experience = injectedExperience || '';
|
|
374
|
+
if (!experience && !actionResult.isInsideIframe) {
|
|
375
|
+
experience = this.experienceTracker.renderExperienceFor(actionResult);
|
|
380
376
|
}
|
|
381
377
|
|
|
382
378
|
return dedent`
|
package/src/ai/pilot.ts
CHANGED
|
@@ -27,6 +27,8 @@ import { withdrawVisionTools } from './tools.ts';
|
|
|
27
27
|
|
|
28
28
|
const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
|
|
29
29
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
30
|
+
const PILOT_MESSAGE_LIMIT = 2;
|
|
31
|
+
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
30
32
|
|
|
31
33
|
export class Pilot implements Agent {
|
|
32
34
|
emoji = '🧭';
|
|
@@ -465,9 +467,13 @@ export class Pilot implements Agent {
|
|
|
465
467
|
the elements needed for the scenario. The page summary does not list every element.
|
|
466
468
|
Prefer interacting with the current page over navigating away.
|
|
467
469
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
470
|
+
Tester never sees <experience> — a recorded recipe reaches it only when you open one.
|
|
471
|
+
The entries listed are what was recorded on the page you are on now; recipes for the
|
|
472
|
+
pages this test moves to are listed when it gets there. Open the ones whose titles fit a
|
|
473
|
+
step taken from here, and say so in the plan when none of them fit.
|
|
474
|
+
Do NOT rewrite a loaded recipe's code — the raw recipe is forwarded to Tester
|
|
475
|
+
automatically. Reference it by step ("apply recipe steps 1–3, then…") and call out
|
|
476
|
+
anywhere your scenario diverges from it.
|
|
471
477
|
|
|
472
478
|
Be concise and specific. Tester will follow your plan.
|
|
473
479
|
`,
|
|
@@ -513,9 +519,11 @@ export class Pilot implements Agent {
|
|
|
513
519
|
${this.formatExpectations(task)}
|
|
514
520
|
|
|
515
521
|
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.
|
|
522
|
+
|
|
523
|
+
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.
|
|
516
524
|
`,
|
|
517
525
|
'pilot.reviewNewPage',
|
|
518
|
-
{ task }
|
|
526
|
+
{ tools: true, maxToolRoundtrips: 2, task }
|
|
519
527
|
);
|
|
520
528
|
}
|
|
521
529
|
|
|
@@ -549,6 +557,8 @@ export class Pilot implements Agent {
|
|
|
549
557
|
</recent_actions>
|
|
550
558
|
|
|
551
559
|
What should Tester do next?
|
|
560
|
+
|
|
561
|
+
Before proposing new locators for a step that keeps failing, check <experience> for a recorded recipe covering it and load it.
|
|
552
562
|
`,
|
|
553
563
|
'pilot.analyze',
|
|
554
564
|
{ tools: hasFailures, maxToolRoundtrips: hasFailures ? 2 : 0, task }
|
|
@@ -665,10 +675,9 @@ export class Pilot implements Agent {
|
|
|
665
675
|
|
|
666
676
|
let finalUserText = userText;
|
|
667
677
|
if (opts.tools) {
|
|
678
|
+
this.conversation!.cleanupTag('experience', '...cleaned experience index...');
|
|
668
679
|
const tocBlock = this.getExperienceToc();
|
|
669
|
-
if (tocBlock) {
|
|
670
|
-
finalUserText = `${tocBlock}\n\n${userText}`;
|
|
671
|
-
}
|
|
680
|
+
if (tocBlock) finalUserText = `${tocBlock}\n\n${userText}`;
|
|
672
681
|
}
|
|
673
682
|
this.conversation!.addUserText(finalUserText);
|
|
674
683
|
|
|
@@ -682,8 +691,9 @@ export class Pilot implements Agent {
|
|
|
682
691
|
telemetry: { functionId },
|
|
683
692
|
});
|
|
684
693
|
const text = result?.response?.text || '';
|
|
685
|
-
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => e.output.content);
|
|
694
|
+
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => ({ url: e.output.url, content: e.output.content }));
|
|
686
695
|
if (learned.length === 0) return text;
|
|
696
|
+
opts.task.applyExperience(learned);
|
|
687
697
|
return dedent`
|
|
688
698
|
${text}
|
|
689
699
|
|
|
@@ -691,7 +701,7 @@ export class Pilot implements Agent {
|
|
|
691
701
|
Recipes from prior successful runs that Pilot judged relevant. Locators worked then; the page may have changed since.
|
|
692
702
|
Treat code blocks below as a starting hypothesis. If a locator misses, fall back to ARIA/UI-map.
|
|
693
703
|
|
|
694
|
-
${learned.join('\n\n')}
|
|
704
|
+
${learned.map((recipe) => recipe.content).join('\n\n')}
|
|
695
705
|
</applied_experience>
|
|
696
706
|
`;
|
|
697
707
|
}
|
|
@@ -718,6 +728,7 @@ export class Pilot implements Agent {
|
|
|
718
728
|
}
|
|
719
729
|
|
|
720
730
|
private buildPreconditionTool(task: Test) {
|
|
731
|
+
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.';
|
|
721
732
|
return {
|
|
722
733
|
precondition: tool({
|
|
723
734
|
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".',
|
|
@@ -732,7 +743,7 @@ export class Pilot implements Agent {
|
|
|
732
743
|
if (!this.fisherman || !this.fisherman.isAvailable()) {
|
|
733
744
|
const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
|
|
734
745
|
if (skipReason) return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
735
|
-
return { noted: true, prepared: false, reason:
|
|
746
|
+
return { noted: true, prepared: false, reason: unavailable };
|
|
736
747
|
}
|
|
737
748
|
|
|
738
749
|
const result = await this.fisherman.prepareData(description, task.startUrl, task.sessionName);
|
|
@@ -741,7 +752,7 @@ export class Pilot implements Agent {
|
|
|
741
752
|
if (result.summary) tag('warning').log(`Precondition failed: ${result.summary}`);
|
|
742
753
|
const skipReason = await this.checkDataAvailability(task, description, result.summary);
|
|
743
754
|
if (skipReason) return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
744
|
-
return { noted: true, prepared: false, reason: result.summary };
|
|
755
|
+
return { noted: true, prepared: false, reason: `${result.summary || 'Data preparation failed'}. ${unavailable}` };
|
|
745
756
|
}
|
|
746
757
|
|
|
747
758
|
const items = result.created.map((c) => {
|
|
@@ -778,7 +789,7 @@ export class Pilot implements Agent {
|
|
|
778
789
|
Reply with YES or NO on the first line, then a one-sentence reason on the second line.
|
|
779
790
|
`;
|
|
780
791
|
|
|
781
|
-
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question);
|
|
792
|
+
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question).catch(() => null);
|
|
782
793
|
if (!answer) return null;
|
|
783
794
|
|
|
784
795
|
const firstLine = answer.split('\n')[0]?.trim().toUpperCase() ?? '';
|
|
@@ -870,15 +881,6 @@ export class Pilot implements Agent {
|
|
|
870
881
|
private async fetchRequestedContext(text: string, currentState: ActionResult): Promise<string> {
|
|
871
882
|
const parts: string[] = [];
|
|
872
883
|
|
|
873
|
-
if (text.includes('ATTACH_HTML')) {
|
|
874
|
-
const html = await currentState.simplifiedHtml();
|
|
875
|
-
parts.push(dedent`
|
|
876
|
-
<page_html>
|
|
877
|
-
${html}
|
|
878
|
-
</page_html>
|
|
879
|
-
`);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
884
|
if (text.includes('ATTACH_ARIA')) {
|
|
883
885
|
parts.push(dedent`
|
|
884
886
|
<page_aria>
|
|
@@ -1048,6 +1050,21 @@ export class Pilot implements Agent {
|
|
|
1048
1050
|
const ariaDiff = t.output?.pageDiff?.ariaChanges;
|
|
1049
1051
|
if (ariaDiff) line += `\n ${ariaDiff}`;
|
|
1050
1052
|
|
|
1053
|
+
if (t.output?.pageDiff?.urlChanged) line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
|
|
1054
|
+
|
|
1055
|
+
const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r: any) => r.status >= 400);
|
|
1056
|
+
if (failedRequests.length > 0) {
|
|
1057
|
+
line += `\n requests: ${failedRequests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
|
|
1061
|
+
if (messages.length > 0) {
|
|
1062
|
+
line += `\n messages: ${messages.map((m: string) => m.slice(0, PILOT_MESSAGE_MAX_LENGTH)).join(' | ')}`;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
1066
|
+
if (consoleError) line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1067
|
+
|
|
1051
1068
|
return line;
|
|
1052
1069
|
})
|
|
1053
1070
|
.join('\n\n');
|
|
@@ -1116,10 +1133,10 @@ export class Pilot implements Agent {
|
|
|
1116
1133
|
role, icon classes with "or" in one XPath. If empty, broaden (drop role filter). Pass discovered
|
|
1117
1134
|
XPath into NEXT instruction.
|
|
1118
1135
|
|
|
1119
|
-
To request more context, mention
|
|
1136
|
+
To request more context, mention ATTACH_ARIA, ATTACH_SUMMARY, or ATTACH_UI_MAP — only when recent actions show failures.
|
|
1120
1137
|
|
|
1121
|
-
Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck,
|
|
1122
|
-
back, getVisitedStates, reset, stop, finish, record.
|
|
1138
|
+
Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
|
|
1139
|
+
visualClick, back, getVisitedStates, reset, stop, finish, record.
|
|
1123
1140
|
Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
|
|
1124
1141
|
|
|
1125
1142
|
${capabilityGroundingRule}
|
|
@@ -4,7 +4,7 @@ import { normalizeUrl } from '../../state-manager.ts';
|
|
|
4
4
|
import type { StateManager } from '../../state-manager.ts';
|
|
5
5
|
import type { Plan } from '../../test-plan.ts';
|
|
6
6
|
import { tag } from '../../utils/logger.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { isSamePageFamily } from '../../utils/url-matcher.ts';
|
|
8
8
|
import type { Provider } from '../provider.ts';
|
|
9
9
|
import type { Constructor } from '../researcher/mixin.ts';
|
|
10
10
|
|
|
@@ -39,18 +39,7 @@ function buildKey(url: string, feature?: string): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function isTemplateMatch(urlA: string, urlB: string): boolean {
|
|
42
|
-
|
|
43
|
-
const partsB = normalizeUrl(urlB).split('/');
|
|
44
|
-
if (partsA.length !== partsB.length) return false;
|
|
45
|
-
|
|
46
|
-
let diffCount = 0;
|
|
47
|
-
for (let i = 0; i < partsA.length; i++) {
|
|
48
|
-
if (partsA[i] === partsB[i]) continue;
|
|
49
|
-
diffCount++;
|
|
50
|
-
if (diffCount > 1) return false;
|
|
51
|
-
if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i])) return false;
|
|
52
|
-
}
|
|
53
|
-
return diffCount === 1;
|
|
42
|
+
return isSamePageFamily(urlA, urlB);
|
|
54
43
|
}
|
|
55
44
|
|
|
56
45
|
export function getPlannedByStateHash(hash: string): PlanRecord | null {
|
package/src/ai/planner.ts
CHANGED
|
@@ -149,7 +149,7 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
149
149
|
|
|
150
150
|
const actionResult = ActionResult.fromState(state);
|
|
151
151
|
const combinedHtml = await actionResult.combinedHtml();
|
|
152
|
-
const similarHash = await findSimilarStateHash(combinedHtml);
|
|
152
|
+
const similarHash = await findSimilarStateHash(combinedHtml, state.url);
|
|
153
153
|
if (similarHash) {
|
|
154
154
|
const planned = getPlannedByStateHash(similarHash);
|
|
155
155
|
if (planned) {
|
package/src/ai/provider.ts
CHANGED
|
@@ -365,7 +365,7 @@ export class Provider {
|
|
|
365
365
|
const extraStop = options.stopWhen;
|
|
366
366
|
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
367
367
|
if (extraStop) stopConditions.push(extraStop);
|
|
368
|
-
const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto' }, { stopWhen: stopConditions, model }, options);
|
|
368
|
+
const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
369
369
|
try {
|
|
370
370
|
const response = await withRetry(async () => {
|
|
371
371
|
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
|
|
@@ -627,4 +627,20 @@ export class Provider {
|
|
|
627
627
|
}
|
|
628
628
|
}
|
|
629
629
|
|
|
630
|
+
function repairToolCall(options: ToolCallRepairOptions): any | null {
|
|
631
|
+
if (options.toolCall.toolName.includes('<|channel|>')) return repairChannelMarker(options);
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
636
|
+
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
637
|
+
if (markerIndex <= 0) return null;
|
|
638
|
+
const toolName = toolCall.toolName.slice(0, markerIndex);
|
|
639
|
+
if (!tools[toolName]) return null;
|
|
640
|
+
tag('warning').log(`Repaired tool name '${toolCall.toolName}' → '${toolName}'`);
|
|
641
|
+
return { ...toolCall, toolName };
|
|
642
|
+
}
|
|
643
|
+
|
|
630
644
|
export { AiError, Provider as AIProvider };
|
|
645
|
+
|
|
646
|
+
type ToolCallRepairOptions = { toolCall: any; tools: any };
|
|
@@ -60,7 +60,8 @@ export function getPreviousResearch(hash: string): string {
|
|
|
60
60
|
return readFileSync(researchFile, 'utf8');
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export function saveResearch(
|
|
63
|
+
export function saveResearch(state: ResearchState, text: string, combinedHtml?: string): string {
|
|
64
|
+
const { hash, url } = state;
|
|
64
65
|
const researchDir = outputPath('research');
|
|
65
66
|
const researchFile = join(researchDir, `${hash}.md`);
|
|
66
67
|
if (!existsSync(researchDir)) mkdirSync(researchDir, { recursive: true });
|
|
@@ -74,14 +75,16 @@ export function saveResearch(hash: string, text: string, combinedHtml?: string):
|
|
|
74
75
|
if (!existsSync(statesDir)) mkdirSync(statesDir, { recursive: true });
|
|
75
76
|
const fingerprint = computeHtmlFingerprint(combinedHtml);
|
|
76
77
|
const fingerprintFile = join(statesDir, `${hash}.fingerprint`);
|
|
77
|
-
|
|
78
|
+
const record: FingerprintRecord = { entries: fingerprint };
|
|
79
|
+
if (url) record.url = url;
|
|
80
|
+
writeFileSync(fingerprintFile, JSON.stringify(record));
|
|
78
81
|
debugLog(`Fingerprint saved to ${fingerprintFile}`);
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
return researchFile;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
function findSimilarMatch(combinedHtml: string): Promise<
|
|
87
|
+
function findSimilarMatch(combinedHtml: string, url?: string): Promise<FingerprintMatch | null> {
|
|
85
88
|
const statesDir = getStatesDir();
|
|
86
89
|
if (!existsSync(statesDir)) return Promise.resolve(null);
|
|
87
90
|
|
|
@@ -93,7 +96,7 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
93
96
|
resolve(null);
|
|
94
97
|
}, FINGERPRINT_WORKER_TIMEOUT_MS);
|
|
95
98
|
|
|
96
|
-
worker.on('message', (data: { matchHash: string | null; similarity: number }) => {
|
|
99
|
+
worker.on('message', (data: { matchHash: string | null; similarity: number; url?: string }) => {
|
|
97
100
|
clearTimeout(timeout);
|
|
98
101
|
const { matchHash, similarity } = data;
|
|
99
102
|
if (!matchHash) {
|
|
@@ -102,7 +105,7 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
|
|
105
|
-
resolve({ hash: matchHash, similarity });
|
|
108
|
+
resolve({ hash: matchHash, similarity, url: data.url });
|
|
106
109
|
});
|
|
107
110
|
|
|
108
111
|
worker.postMessage({
|
|
@@ -110,17 +113,22 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
110
113
|
statesDir,
|
|
111
114
|
maxAgeMs: FINGERPRINT_MAX_AGE_MS,
|
|
112
115
|
threshold: SIMILARITY_THRESHOLD,
|
|
116
|
+
url,
|
|
113
117
|
});
|
|
114
118
|
});
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
export async function findSimilarResearch(combinedHtml: string): Promise<string | null> {
|
|
118
|
-
const match = await findSimilarMatch(combinedHtml);
|
|
121
|
+
export async function findSimilarResearch(combinedHtml: string, url?: string): Promise<string | null> {
|
|
122
|
+
const match = await findSimilarMatch(combinedHtml, url);
|
|
119
123
|
if (!match) return null;
|
|
120
124
|
return getCachedResearch(match.hash) || null;
|
|
121
125
|
}
|
|
122
126
|
|
|
123
|
-
export async function findSimilarStateHash(combinedHtml: string): Promise<string | null> {
|
|
124
|
-
const match = await findSimilarMatch(combinedHtml);
|
|
127
|
+
export async function findSimilarStateHash(combinedHtml: string, url?: string): Promise<string | null> {
|
|
128
|
+
const match = await findSimilarMatch(combinedHtml, url);
|
|
125
129
|
return match?.hash || null;
|
|
126
130
|
}
|
|
131
|
+
|
|
132
|
+
type FingerprintRecord = { entries: string[]; url?: string };
|
|
133
|
+
type FingerprintMatch = { hash: string; similarity: number; url?: string };
|
|
134
|
+
type ResearchState = { hash: string; url?: string };
|
|
@@ -128,7 +128,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
128
128
|
updated = `${cached.trimEnd()}\n\n# Extended Research\n\n${sectionMarkdown}\n`;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
saveResearch(pageStateHash, updated);
|
|
131
|
+
saveResearch({ hash: pageStateHash }, updated);
|
|
132
132
|
tag('substep').log(`Overlay research appended: ${focusArea.name}`);
|
|
133
133
|
return sectionMarkdown;
|
|
134
134
|
}
|