explorbot 0.3.4 → 0.4.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/bin/explorbot-cli.ts +18 -13
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +3 -1
- package/boat/prima/src/cli.ts +21 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +19 -13
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +3 -1
- package/dist/boat/prima/src/cli.js +19 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +15 -1
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +47 -4
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +27 -2
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.js +1 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +23 -36
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/reference/commands.md +8 -1
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +1 -0
- package/docs/workflow/ci.md +1 -0
- package/package.json +2 -2
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/pilot.ts +11 -1
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +48 -4
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/commands/explore-command.ts +25 -2
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +20 -36
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
package/src/action-result.ts
CHANGED
|
@@ -35,7 +35,6 @@ interface ActionResultData extends WebPageState {
|
|
|
35
35
|
focusedElement?: FocusedElement | null;
|
|
36
36
|
iframeURL?: string;
|
|
37
37
|
links?: Link[];
|
|
38
|
-
overlayHtml?: string;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
export interface PageDiff {
|
|
@@ -49,6 +48,7 @@ export interface PageDiff {
|
|
|
49
48
|
consoleErrors?: string[];
|
|
50
49
|
htmlParts?: HtmlDiffPart[];
|
|
51
50
|
iframes?: string;
|
|
51
|
+
areaOfInterest?: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export interface ToolResultMetadata {
|
|
@@ -89,6 +89,7 @@ export class ActionResult implements ActionResultData {
|
|
|
89
89
|
public links: Link[] = [];
|
|
90
90
|
public verifications?: Record<string, boolean>;
|
|
91
91
|
public overlay: Overlay = new Overlay();
|
|
92
|
+
private _diffCache: { previousId: number | undefined; diff: Diff } | null = null;
|
|
92
93
|
|
|
93
94
|
constructor(data: ActionResultData) {
|
|
94
95
|
this.id = data.id;
|
|
@@ -168,6 +169,7 @@ export class ActionResult implements ActionResultData {
|
|
|
168
169
|
set html(value: string) {
|
|
169
170
|
this._html = value;
|
|
170
171
|
this.snapshotCache.clear();
|
|
172
|
+
this._diffCache = null;
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
get screenshot(): Buffer | undefined {
|
|
@@ -260,6 +262,11 @@ export class ActionResult implements ActionResultData {
|
|
|
260
262
|
|
|
261
263
|
isRelevantExperienceRecord(record: WebPageState, options?: { includeDescendantExperience?: boolean }): boolean {
|
|
262
264
|
if (!record.url || !this.url) return false;
|
|
265
|
+
if (record.region && this.overlay.name !== record.region) return false;
|
|
266
|
+
if (record.root) {
|
|
267
|
+
if (!this.overlay.present) return false;
|
|
268
|
+
if (this.overlay.root && this.overlay.root !== record.root) return false;
|
|
269
|
+
}
|
|
263
270
|
if (this.isMatchedBy(record)) return true;
|
|
264
271
|
if (!options?.includeDescendantExperience) return false;
|
|
265
272
|
const cur = extractStatePath(this.url);
|
|
@@ -476,29 +483,18 @@ export class ActionResult implements ActionResultData {
|
|
|
476
483
|
}
|
|
477
484
|
|
|
478
485
|
getStateHash(): string {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
parts.push(this.relativeUrl || this.url || '/');
|
|
482
|
-
|
|
483
|
-
this.extractHeadings(this.html);
|
|
484
|
-
|
|
485
|
-
if (this.h1) parts.push(`h1_${this.h1}`);
|
|
486
|
-
if (this.h2) parts.push(`h2_${this.h2}`);
|
|
487
|
-
|
|
488
|
-
let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
|
|
489
|
-
|
|
490
|
-
if (stateString.length > 200) {
|
|
491
|
-
stateString = stateString.substring(0, 200);
|
|
492
|
-
if (stateString.endsWith('_')) {
|
|
493
|
-
stateString = stateString.slice(0, -1);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
486
|
+
return this.computeStateHash(true);
|
|
487
|
+
}
|
|
496
488
|
|
|
497
|
-
|
|
489
|
+
get baseHash(): string {
|
|
490
|
+
return this.computeStateHash(false);
|
|
498
491
|
}
|
|
499
492
|
|
|
500
493
|
async diff(previousState: ActionResult | null): Promise<Diff> {
|
|
501
|
-
|
|
494
|
+
if (this._diffCache && this._diffCache.previousId === previousState?.id) return this._diffCache.diff;
|
|
495
|
+
const diff = await Diff.create(this, previousState);
|
|
496
|
+
this._diffCache = { previousId: previousState?.id, diff };
|
|
497
|
+
return diff;
|
|
502
498
|
}
|
|
503
499
|
|
|
504
500
|
async toToolResult(previousState: ActionResult | null, locator: string): Promise<ToolResultMetadata> {
|
|
@@ -549,7 +545,18 @@ export class ActionResult implements ActionResultData {
|
|
|
549
545
|
pageDiff.ariaChangeCount = diff.ariaChangeCount;
|
|
550
546
|
}
|
|
551
547
|
|
|
552
|
-
if (
|
|
548
|
+
if (this.overlay.present && (!previousState.overlay.present || previousState.overlay.name !== this.overlay.name)) {
|
|
549
|
+
pageDiff.areaOfInterest = this.overlay.describe();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (pageDiff.areaOfInterest && this.overlay.html && this.overlay.root) {
|
|
553
|
+
const htmlConfig = ConfigParser.getInstance().getConfig().html;
|
|
554
|
+
let subtree = await minifyHtml(htmlCombinedSnapshot(this.overlay.html, htmlConfig?.combined));
|
|
555
|
+
if (subtree.length > HTML_PART_SUBTREE_BUDGET) {
|
|
556
|
+
subtree = `${subtree.slice(0, HTML_PART_SUBTREE_BUDGET)}...<!-- truncated -->`;
|
|
557
|
+
}
|
|
558
|
+
pageDiff.htmlParts = [{ container: this.overlay.root, subtree, rawSize: subtree.length, added: [], removed: [] }];
|
|
559
|
+
} else if (diff.isSameUrl() && diff.htmlParts.length > 0) {
|
|
553
560
|
const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
|
|
554
561
|
if (collapsed.length > 0) {
|
|
555
562
|
pageDiff.htmlParts = collapsed;
|
|
@@ -565,6 +572,29 @@ export class ActionResult implements ActionResultData {
|
|
|
565
572
|
return result;
|
|
566
573
|
}
|
|
567
574
|
|
|
575
|
+
private computeStateHash(includeRegion: boolean): string {
|
|
576
|
+
const parts: string[] = [];
|
|
577
|
+
|
|
578
|
+
parts.push(this.relativeUrl || this.url || '/');
|
|
579
|
+
|
|
580
|
+
this.extractHeadings(this.html);
|
|
581
|
+
|
|
582
|
+
if (this.h1) parts.push(`h1_${this.h1}`);
|
|
583
|
+
if (this.h2) parts.push(`h2_${this.h2}`);
|
|
584
|
+
if (includeRegion && this.overlay.present && this.overlay.name) parts.push(`region_${this.overlay.name}`);
|
|
585
|
+
|
|
586
|
+
let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
|
|
587
|
+
|
|
588
|
+
if (stateString.length > 200) {
|
|
589
|
+
stateString = stateString.substring(0, 200);
|
|
590
|
+
if (stateString.endsWith('_')) {
|
|
591
|
+
stateString = stateString.slice(0, -1);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return stateString;
|
|
596
|
+
}
|
|
597
|
+
|
|
568
598
|
private consoleErrors(): string[] {
|
|
569
599
|
const errors: string[] = [];
|
|
570
600
|
|
|
@@ -681,15 +711,24 @@ export class Diff {
|
|
|
681
711
|
return this._messages;
|
|
682
712
|
}
|
|
683
713
|
|
|
714
|
+
get similarity(): number {
|
|
715
|
+
return this._htmlDiffResult?.similarity ?? 0;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
get pageSize(): number {
|
|
719
|
+
return this._htmlDiffResult?.pageSize ?? 0;
|
|
720
|
+
}
|
|
721
|
+
|
|
684
722
|
async calculate(): Promise<void> {
|
|
685
723
|
if (!this.previous) return;
|
|
686
724
|
|
|
725
|
+
this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
|
|
726
|
+
|
|
687
727
|
if (!this._isSameUrl) {
|
|
688
728
|
this._messages = liveRegionMessages(this.previous.html, this.current.html);
|
|
689
729
|
return;
|
|
690
730
|
}
|
|
691
731
|
|
|
692
|
-
this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
|
|
693
732
|
this._messages = this._htmlDiffResult.messages;
|
|
694
733
|
|
|
695
734
|
const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
|
package/src/action.ts
CHANGED
|
@@ -11,9 +11,9 @@ import { Observability } from './observability.ts';
|
|
|
11
11
|
import type { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
12
12
|
import type { StateManager } from './state-manager.js';
|
|
13
13
|
import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
14
|
-
import { captureHtmlForSnapshot,
|
|
14
|
+
import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
15
15
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
16
|
-
import { Overlay } from './utils/overlay.js';
|
|
16
|
+
import { Overlay, OverlayPage } from './utils/overlay.js';
|
|
17
17
|
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
|
|
18
18
|
import { safeFilename } from './utils/strings.ts';
|
|
19
19
|
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
|
|
@@ -146,13 +146,11 @@ class Action {
|
|
|
146
146
|
let ariaSnapshot: string | null = null;
|
|
147
147
|
let ariaSnapshotFile: string | undefined = undefined;
|
|
148
148
|
let focusedElement: FocusedElement | null = null;
|
|
149
|
-
let overlayHtml = '';
|
|
150
149
|
|
|
151
150
|
try {
|
|
152
151
|
const page = this.playwrightHelper.page;
|
|
153
152
|
ariaSnapshot = await page.locator('body').ariaSnapshot();
|
|
154
153
|
focusedElement = await page.evaluate(readFocusedElement);
|
|
155
|
-
if (!frame) overlayHtml = await this.captureOverlayHtml();
|
|
156
154
|
} catch (err) {
|
|
157
155
|
debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
|
|
158
156
|
}
|
|
@@ -181,9 +179,9 @@ class Action {
|
|
|
181
179
|
ariaSnapshot,
|
|
182
180
|
ariaSnapshotFile,
|
|
183
181
|
focusedElement,
|
|
184
|
-
overlayHtml: overlayHtml || undefined,
|
|
185
182
|
iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
|
|
186
183
|
});
|
|
184
|
+
if (!frame) await this.detectRegionOfInterest(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
|
|
187
185
|
this.stateManager.updateState(result, codeBlock);
|
|
188
186
|
return result;
|
|
189
187
|
} catch (err) {
|
|
@@ -195,14 +193,64 @@ class Action {
|
|
|
195
193
|
}
|
|
196
194
|
}
|
|
197
195
|
|
|
198
|
-
private async
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
196
|
+
private async detectRegionOfInterest(result: ActionResult): Promise<void> {
|
|
197
|
+
const previousState = this.stateManager.getCurrentState();
|
|
198
|
+
if (!previousState) return;
|
|
199
|
+
const previous = ActionResult.fromState(previousState);
|
|
200
|
+
const previousOverlay = previous.overlay;
|
|
201
|
+
const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
|
|
202
|
+
const overlayPage = new OverlayPage(this.playwrightHelper.page);
|
|
203
|
+
|
|
204
|
+
if (result.overlay.detected && previousOverlay.detected && previousOverlay.root && previousOverlay.type === result.overlay.type && previousOverlay.name === result.overlay.name) {
|
|
205
|
+
result.overlay = previousOverlay;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!previous.html) return;
|
|
210
|
+
|
|
211
|
+
if (previous.html === result.html) {
|
|
212
|
+
if (sameUrl && previousOverlay.present && previousOverlay.xpath && !result.overlay.detected) result.overlay = previousOverlay;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let carried: Overlay | null = null;
|
|
217
|
+
if (sameUrl && previousOverlay.present && previousOverlay.xpath) {
|
|
218
|
+
if (await overlayPage.isStillOpen(previousOverlay)) {
|
|
219
|
+
carried = previousOverlay;
|
|
220
|
+
} else {
|
|
221
|
+
debugLog(`Region closed: ${previousOverlay.name || previousOverlay.type}`);
|
|
222
|
+
if (!result.overlay.detected) {
|
|
223
|
+
const parent = previousOverlay.parent;
|
|
224
|
+
if (parent?.xpath) {
|
|
225
|
+
const restored = new Overlay(parent);
|
|
226
|
+
if (await overlayPage.isStillOpen(restored)) result.overlay = restored;
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const diff = await result.diff(previous);
|
|
234
|
+
const detected = await overlayPage.detectRegion({
|
|
235
|
+
parts: diff.htmlParts,
|
|
236
|
+
pageSize: diff.pageSize,
|
|
237
|
+
similarity: diff.similarity,
|
|
238
|
+
sameUrl,
|
|
239
|
+
previousHtml: previous.html,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (result.overlay.detected) {
|
|
243
|
+
if (detected) result.overlay = result.overlay.withGeometry(detected);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (detected) {
|
|
248
|
+
result.overlay = detected;
|
|
249
|
+
if (carried) result.overlay = detected.withParent(carried);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (carried) result.overlay = carried;
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
private async captureMainDocumentStatus(): Promise<number | undefined> {
|
|
@@ -381,6 +429,13 @@ class Action {
|
|
|
381
429
|
await recorder.reset();
|
|
382
430
|
await recorder.start();
|
|
383
431
|
}
|
|
432
|
+
if (executedSteps.length > 0) {
|
|
433
|
+
codeString = executedSteps.map((step) => step.command).join('\n');
|
|
434
|
+
}
|
|
435
|
+
if (!isFatalBrowserError(err)) {
|
|
436
|
+
const captured = await this.captureOnce({ codeBlock: codeString }).catch(() => null);
|
|
437
|
+
if (captured && !captured.error) this.actionResult = captured;
|
|
438
|
+
}
|
|
384
439
|
this.assertionSteps = [];
|
|
385
440
|
throw err;
|
|
386
441
|
} finally {
|
|
@@ -491,11 +546,29 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
|
|
|
491
546
|
type StepListener = (step: any, error?: any) => void;
|
|
492
547
|
|
|
493
548
|
export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
|
|
549
|
+
const recorded = new WeakMap<object, ExecutedStep>();
|
|
550
|
+
let batchFailed = false;
|
|
494
551
|
const listener: StepListener = (step, error) => {
|
|
495
552
|
if (!step?.toCode) return;
|
|
496
553
|
if (step.name?.startsWith('grab')) return;
|
|
554
|
+
|
|
555
|
+
const existing = recorded.get(step);
|
|
556
|
+
if (existing) {
|
|
557
|
+
if (!error && !existing.success) {
|
|
558
|
+
existing.success = true;
|
|
559
|
+
existing.error = undefined;
|
|
560
|
+
batchFailed = target.some((entry) => !entry.success);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (batchFailed) return;
|
|
565
|
+
|
|
497
566
|
const executed: ExecutedStep = { command: step.toCode(), success: !error };
|
|
498
|
-
if (error)
|
|
567
|
+
if (error) {
|
|
568
|
+
executed.error = errorToString(error);
|
|
569
|
+
batchFailed = true;
|
|
570
|
+
}
|
|
571
|
+
recorded.set(step, executed);
|
|
499
572
|
target.push(executed);
|
|
500
573
|
if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
|
|
501
574
|
assertionsTarget.push({ name: step.name, args: step.args || [] });
|
package/src/ai/driller.ts
CHANGED
|
@@ -6,23 +6,9 @@ import { setActivity } from '../activity.ts';
|
|
|
6
6
|
import { Observability } from '../observability.ts';
|
|
7
7
|
import { Plan, Test, TestResult } from '../test-plan.ts';
|
|
8
8
|
import { collectInteractiveNodes } from '../utils/aria.ts';
|
|
9
|
-
import {
|
|
10
|
-
EXPLORBOT_ATTRS,
|
|
11
|
-
HTML_COMPOSITE_AREA_HINTS,
|
|
12
|
-
HTML_COMPOSITE_TARGET_ROLES,
|
|
13
|
-
HTML_EXTRACTION_LIMITS,
|
|
14
|
-
HTML_FORM_CONTROL_ROLES,
|
|
15
|
-
HTML_FORM_CONTROL_TAGS,
|
|
16
|
-
HTML_INTERACTIVE_ROLES,
|
|
17
|
-
HTML_SELECTORS,
|
|
18
|
-
HTML_VISIBILITY_LIMITS,
|
|
19
|
-
getComponentScopeHtmlExtractorSource,
|
|
20
|
-
getVisibleOverlayHtmlExtractorSource,
|
|
21
|
-
inferHtmlRole,
|
|
22
|
-
} from '../utils/html.ts';
|
|
9
|
+
import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, getComponentScopeHtmlExtractorSource, inferHtmlRole } from '../utils/html.ts';
|
|
23
10
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
24
11
|
import { loop, pause } from '../utils/loop.ts';
|
|
25
|
-
import { OVERLAY_SELECTORS } from '../utils/overlay.ts';
|
|
26
12
|
import { annotatePageElements } from '../utils/web-annotate.ts';
|
|
27
13
|
import { eidxInContainer } from '../utils/web-eidx.ts';
|
|
28
14
|
import { WebElement } from '../utils/web-element.ts';
|
|
@@ -648,8 +634,11 @@ export class Driller extends TaskAgent implements Agent {
|
|
|
648
634
|
private async detectNestedOverlayContext(component: ComponentInfo, result: any): Promise<string | null> {
|
|
649
635
|
if (!result?.pageDiff?.ariaChanges || result.pageDiff.urlChanged) return null;
|
|
650
636
|
|
|
651
|
-
const
|
|
652
|
-
|
|
637
|
+
const parts = result.pageDiff.htmlParts ?? [];
|
|
638
|
+
let appeared = parts.filter((part: any) => part.added?.length > 0);
|
|
639
|
+
if (result.pageDiff.areaOfInterest) appeared = parts;
|
|
640
|
+
const appearedHtml = appeared.map((part: any) => part.subtree).join('\n');
|
|
641
|
+
if (!appearedHtml) return null;
|
|
653
642
|
|
|
654
643
|
const state = this.stateManager.getCurrentState();
|
|
655
644
|
if (!state) return null;
|
|
@@ -661,7 +650,7 @@ export class Driller extends TaskAgent implements Agent {
|
|
|
661
650
|
Keep the recorded code reusable and include the parent-opening action when the nested element requires the overlay to be open.
|
|
662
651
|
|
|
663
652
|
<overlay_html>
|
|
664
|
-
${
|
|
653
|
+
${appearedHtml}
|
|
665
654
|
</overlay_html>
|
|
666
655
|
|
|
667
656
|
<current_page_aria>
|
|
@@ -671,27 +660,6 @@ export class Driller extends TaskAgent implements Agent {
|
|
|
671
660
|
`;
|
|
672
661
|
}
|
|
673
662
|
|
|
674
|
-
private async getVisibleOverlayHtml(): Promise<string> {
|
|
675
|
-
return this.explorer.withPage((page) =>
|
|
676
|
-
page.evaluate(
|
|
677
|
-
({ extractorSource, config }) => {
|
|
678
|
-
const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
|
|
679
|
-
return extract(config);
|
|
680
|
-
},
|
|
681
|
-
{
|
|
682
|
-
extractorSource: getVisibleOverlayHtmlExtractorSource(),
|
|
683
|
-
config: {
|
|
684
|
-
interactiveContentSelector: HTML_SELECTORS.interactiveContent,
|
|
685
|
-
limits: HTML_EXTRACTION_LIMITS,
|
|
686
|
-
overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
|
|
687
|
-
overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
|
|
688
|
-
visibilityLimits: HTML_VISIBILITY_LIMITS,
|
|
689
|
-
},
|
|
690
|
-
}
|
|
691
|
-
)
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
663
|
private async getComponentScopeHtml(component: ComponentInfo, originalState: ActionResult): Promise<string> {
|
|
696
664
|
const scopedHtml = await this.explorer.withPage((page) =>
|
|
697
665
|
page.evaluate(
|
|
@@ -2,16 +2,28 @@ import { tool } from 'ai';
|
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import type { ApiClient } from '../api/api-client.ts';
|
|
5
|
+
import type { RequestResult } from '../api/request-result.ts';
|
|
5
6
|
import type { RequestStore } from '../api/request-store.ts';
|
|
6
7
|
import { extractEndpointDefinition } from '../api/spec-reader.ts';
|
|
7
8
|
import { tag } from '../utils/logger.ts';
|
|
9
|
+
import { RequestMap } from '../utils/request-map.ts';
|
|
10
|
+
import { isDynamicSegment } from '../utils/url-matcher.ts';
|
|
8
11
|
|
|
9
12
|
export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) {
|
|
10
13
|
let finished = false;
|
|
11
|
-
let result: FishermanResult
|
|
14
|
+
let result: FishermanResult | null = null;
|
|
15
|
+
const ledgerStart = requestStore.getMadeRequests().length;
|
|
12
16
|
|
|
13
|
-
const
|
|
17
|
+
const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
|
|
18
|
+
const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
19
|
+
const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
|
|
14
20
|
const isFinished = () => finished;
|
|
21
|
+
const finishFromText = (text?: string) => {
|
|
22
|
+
finished = true;
|
|
23
|
+
const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
|
|
24
|
+
if (text && synthesized.success) synthesized.summary = text;
|
|
25
|
+
result = synthesized;
|
|
26
|
+
};
|
|
15
27
|
|
|
16
28
|
const tools = {
|
|
17
29
|
getEndpointSpec: tool({
|
|
@@ -76,7 +88,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
76
88
|
request: tool({
|
|
77
89
|
description: dedent`
|
|
78
90
|
Make an HTTP request to the API.
|
|
79
|
-
Returns status,
|
|
91
|
+
Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
|
|
80
92
|
`,
|
|
81
93
|
inputSchema: z.object({
|
|
82
94
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
|
|
@@ -119,7 +131,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
119
131
|
return {
|
|
120
132
|
success: true,
|
|
121
133
|
status: reqResult.status,
|
|
122
|
-
|
|
134
|
+
extracted,
|
|
123
135
|
};
|
|
124
136
|
},
|
|
125
137
|
}),
|
|
@@ -148,9 +160,32 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
148
160
|
.describe('List of items that could not be created'),
|
|
149
161
|
}),
|
|
150
162
|
execute: async ({ summary, created, failed }) => {
|
|
163
|
+
const writes = successfulWrites();
|
|
164
|
+
if (writes.length === 0) {
|
|
165
|
+
tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
|
|
166
|
+
return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const createdRequests = new RequestMap(writes);
|
|
170
|
+
|
|
171
|
+
const verified: FishermanResult['created'] = [];
|
|
172
|
+
for (const item of created) {
|
|
173
|
+
if (item.id === undefined) {
|
|
174
|
+
verified.push(item);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const request = createdRequests.get(item.id);
|
|
178
|
+
if (!request) {
|
|
179
|
+
tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
verified.push({ ...item, request: request.toEndpoint() });
|
|
183
|
+
}
|
|
184
|
+
if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
|
|
185
|
+
|
|
151
186
|
tag('success').log(`Fisherman done: ${summary}`);
|
|
152
187
|
finished = true;
|
|
153
|
-
result = { success: true, summary, created, failed: failed || [] };
|
|
188
|
+
result = { success: true, summary, created: verified, failed: failed || [] };
|
|
154
189
|
return { finished: true };
|
|
155
190
|
},
|
|
156
191
|
}),
|
|
@@ -169,7 +204,21 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
169
204
|
}),
|
|
170
205
|
};
|
|
171
206
|
|
|
172
|
-
return { tools, getResult, isFinished };
|
|
207
|
+
return { tools, getResult, isFinished, finishFromText };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function synthesizeResult(made: RequestResult[], writes: RequestResult[], declaredDone: boolean): FishermanResult {
|
|
211
|
+
const failures = made.filter((r) => r.status >= 400 || r.error);
|
|
212
|
+
let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
|
|
213
|
+
const lastFailure = failures[failures.length - 1];
|
|
214
|
+
if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
|
|
215
|
+
return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function toCreatedItem(write: RequestResult): FishermanResult['created'][number] {
|
|
219
|
+
const { id, title } = write.extractIdAndTitle();
|
|
220
|
+
const segments = write.path.split('/').filter((s) => s && !isDynamicSegment(s));
|
|
221
|
+
return { type: segments[segments.length - 1] || 'item', id, title, request: write.toEndpoint() };
|
|
173
222
|
}
|
|
174
223
|
|
|
175
224
|
function responseCategory(status: number): ResponseCategory {
|
|
@@ -209,7 +258,7 @@ function extractKeyFields(body: any, result: Record<string, any> = {}, depth = 0
|
|
|
209
258
|
export interface FishermanResult {
|
|
210
259
|
success: boolean;
|
|
211
260
|
summary: string;
|
|
212
|
-
created: Array<{ type: string; id?: string | number; title?: string }>;
|
|
261
|
+
created: Array<{ type: string; id?: string | number; title?: string; request?: string }>;
|
|
213
262
|
failed: Array<{ type: string; reason: string }>;
|
|
214
263
|
}
|
|
215
264
|
|
package/src/ai/fisherman.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { dataProtectionRules } from './rules.ts';
|
|
|
13
13
|
|
|
14
14
|
const MAX_ITERATIONS = 15;
|
|
15
15
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
16
|
+
const REPEATED_FAILURE_LIMIT = 4;
|
|
16
17
|
|
|
17
18
|
export class Fisherman implements Agent {
|
|
18
19
|
emoji = '🎣';
|
|
@@ -20,21 +21,22 @@ export class Fisherman implements Agent {
|
|
|
20
21
|
private apiClient: ApiClient;
|
|
21
22
|
private requestStore: RequestStore;
|
|
22
23
|
private specLoader: () => Promise<any | null>;
|
|
23
|
-
private
|
|
24
|
+
private browserHeaderProvider: () => Promise<Record<string, string>>;
|
|
24
25
|
private configHeaders: Record<string, string>;
|
|
25
26
|
private sessionName?: string;
|
|
26
27
|
private baseEndpoint: string;
|
|
27
28
|
private spec: any | null = null;
|
|
28
29
|
private mode: 'replicate' | 'achieve' | 'disabled' = 'disabled';
|
|
29
30
|
private hasApiConfig: boolean;
|
|
31
|
+
private scopeDegraded = false;
|
|
30
32
|
|
|
31
|
-
constructor(provider: Provider, apiClient: ApiClient, requestStore: RequestStore, specLoader: () => Promise<any | null>, baseEndpoint: string,
|
|
33
|
+
constructor(provider: Provider, apiClient: ApiClient, requestStore: RequestStore, specLoader: () => Promise<any | null>, baseEndpoint: string, browserHeaderProvider: () => Promise<Record<string, string>>, configHeaders: Record<string, string> = {}, hasApiConfig = false) {
|
|
32
34
|
this.provider = provider;
|
|
33
35
|
this.apiClient = apiClient;
|
|
34
36
|
this.requestStore = requestStore;
|
|
35
37
|
this.specLoader = specLoader;
|
|
36
38
|
this.baseEndpoint = baseEndpoint;
|
|
37
|
-
this.
|
|
39
|
+
this.browserHeaderProvider = browserHeaderProvider;
|
|
38
40
|
this.configHeaders = configHeaders;
|
|
39
41
|
this.hasApiConfig = hasApiConfig;
|
|
40
42
|
this.mode = hasApiConfig ? 'achieve' : 'replicate';
|
|
@@ -77,10 +79,11 @@ export class Fisherman implements Agent {
|
|
|
77
79
|
await this.refreshAuth();
|
|
78
80
|
debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
|
|
79
81
|
|
|
80
|
-
const { tools, getResult, isFinished } = createFishermanTools(this.apiClient, this.requestStore, {
|
|
82
|
+
const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, {
|
|
81
83
|
spec: this.spec,
|
|
82
84
|
baseEndpoint: this.baseEndpoint,
|
|
83
85
|
});
|
|
86
|
+
const ledgerStart = this.requestStore.getMadeRequests().length;
|
|
84
87
|
|
|
85
88
|
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
|
|
86
89
|
conversation.addUserText(this.buildTaskPrompt(instructions));
|
|
@@ -90,7 +93,6 @@ export class Fisherman implements Agent {
|
|
|
90
93
|
debugLog(`iteration ${iteration}`);
|
|
91
94
|
const invokeResult = await this.provider.invokeConversation(conversation, tools, {
|
|
92
95
|
maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
|
|
93
|
-
toolChoice: 'required',
|
|
94
96
|
agentName: 'fisherman',
|
|
95
97
|
});
|
|
96
98
|
debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
|
|
@@ -100,6 +102,19 @@ export class Fisherman implements Agent {
|
|
|
100
102
|
return;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
if (!invokeResult?.toolExecutions?.length) {
|
|
106
|
+
debugLog('no tool call in this turn — treating as finish');
|
|
107
|
+
finishFromText(invokeResult?.response?.text);
|
|
108
|
+
stop();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (this.isStuckOnEndpoint(ledgerStart)) {
|
|
113
|
+
tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
|
|
114
|
+
stop();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
if (iteration >= MAX_ITERATIONS) {
|
|
104
119
|
tag('warning').log('Fisherman: max iterations reached');
|
|
105
120
|
stop();
|
|
@@ -145,14 +160,16 @@ export class Fisherman implements Agent {
|
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
private async refreshAuth(): Promise<void> {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
if (this.mode === 'replicate') {
|
|
164
|
+
const xhrHeaders = this.requestStore.extractAuthHeaders();
|
|
165
|
+
if (Object.keys(xhrHeaders).length > 0) {
|
|
166
|
+
this.apiClient.setHeaders(xhrHeaders);
|
|
167
|
+
}
|
|
152
168
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
const browserHeaders = await this.browserHeaderProvider();
|
|
170
|
+
if (Object.keys(browserHeaders).length > 0) {
|
|
171
|
+
this.apiClient.setHeaders(browserHeaders);
|
|
172
|
+
}
|
|
156
173
|
}
|
|
157
174
|
|
|
158
175
|
if (Object.keys(this.configHeaders).length > 0) {
|
|
@@ -161,31 +178,25 @@ export class Fisherman implements Agent {
|
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
private buildEndpointList(scopeUrl?: string): string {
|
|
181
|
+
this.scopeDegraded = false;
|
|
164
182
|
if (this.mode === 'achieve' && this.spec) {
|
|
165
183
|
const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
|
|
166
184
|
if (specEndpoints) return specEndpoints;
|
|
167
185
|
}
|
|
168
186
|
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
writeRequests = this.requestStore.getWriteRequestsForScope('/');
|
|
172
|
-
}
|
|
187
|
+
const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
|
|
188
|
+
if (scoped) return scoped;
|
|
173
189
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
for (const req of writeRequests) {
|
|
178
|
-
const key = `${req.method} ${req.path}`;
|
|
179
|
-
if (seen.has(key)) continue;
|
|
180
|
-
seen.add(key);
|
|
181
|
-
lines.push(key);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return lines.join('\n');
|
|
190
|
+
this.scopeDegraded = true;
|
|
191
|
+
return this.requestStore.toEndpointList();
|
|
185
192
|
}
|
|
186
193
|
|
|
187
194
|
private buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
|
|
188
|
-
|
|
195
|
+
let scopeBlock = '';
|
|
196
|
+
if (scopeUrl) {
|
|
197
|
+
scopeBlock = `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.`;
|
|
198
|
+
if (this.scopeDegraded) scopeBlock += '\nThe endpoint list could not be narrowed to this scope and may include endpoints belonging to other scopes. Before writing, confirm the target belongs to this scope.';
|
|
199
|
+
}
|
|
189
200
|
|
|
190
201
|
return dedent`
|
|
191
202
|
You are Fisherman — a data preparation agent. You create test data by making API requests.
|
|
@@ -210,12 +221,21 @@ export class Fisherman implements Agent {
|
|
|
210
221
|
- Chain requests logically — create parent resources before children
|
|
211
222
|
- 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
223
|
- Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
|
|
224
|
+
- Create only the resource types that were requested. If no endpoint creates a requested type, call stop — never create a different type as a substitute
|
|
213
225
|
- Use realistic but unique data for each item (vary names, titles)
|
|
214
226
|
|
|
215
227
|
${dataProtectionRules}
|
|
216
228
|
`;
|
|
217
229
|
}
|
|
218
230
|
|
|
231
|
+
private isStuckOnEndpoint(ledgerStart: number): boolean {
|
|
232
|
+
const made = this.requestStore.getMadeRequests().slice(ledgerStart);
|
|
233
|
+
if (made.length < REPEATED_FAILURE_LIMIT) return false;
|
|
234
|
+
const recent = made.slice(-REPEATED_FAILURE_LIMIT);
|
|
235
|
+
const first = recent[0];
|
|
236
|
+
return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
|
|
237
|
+
}
|
|
238
|
+
|
|
219
239
|
private buildTaskPrompt(instructions: string): string {
|
|
220
240
|
return dedent`
|
|
221
241
|
Prepare the following test data:
|
|
@@ -64,7 +64,7 @@ export function WithCodeceptJS<T extends Constructor>(Base: T) {
|
|
|
64
64
|
lines.push(`Feature('${escapeString(plan.title)}')`);
|
|
65
65
|
lines.push('');
|
|
66
66
|
|
|
67
|
-
const startUrl = plan.
|
|
67
|
+
const startUrl = plan.startUrl;
|
|
68
68
|
if (startUrl) {
|
|
69
69
|
lines.push('Before(({ I }) => {');
|
|
70
70
|
lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
|