explorbot 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +14 -4
- package/boat/api-tester/src/ai/curler-tools.ts +1 -1
- package/boat/api-tester/src/cli.ts +3 -0
- package/boat/doc-collector/src/ai/documentarian.ts +57 -5
- package/boat/doc-collector/src/ai/tools.ts +1 -1
- package/boat/doc-collector/src/cli.ts +18 -2
- package/boat/doc-collector/src/config.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +56 -11
- package/boat/doc-collector/src/docs-renderer.ts +79 -55
- package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
- package/boat/doc-collector/src/screenshots.ts +106 -3
- package/boat/doc-collector/src/template-dedup.ts +31 -0
- package/boat/prima/src/cli.ts +3 -0
- package/boat/prima/src/prima.ts +3 -4
- package/dist/bin/explorbot-cli.js +14 -4
- package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
- package/dist/boat/doc-collector/src/ai/tools.js +1 -1
- package/dist/boat/doc-collector/src/cli.js +15 -2
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +52 -10
- package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
- package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
- package/dist/boat/doc-collector/src/screenshots.js +104 -1
- package/dist/boat/doc-collector/src/template-dedup.js +27 -0
- package/dist/boat/prima/src/cli.js +2 -0
- package/dist/boat/prima/src/prima.js +3 -4
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +2 -2
- package/dist/src/action-result.js +15 -17
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +15 -15
- package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
- package/dist/src/ai/fisherman/request-haul.js +31 -0
- package/dist/src/ai/fisherman/tools.d.ts +50 -0
- package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
- package/dist/src/ai/fisherman.d.ts +14 -4
- package/dist/src/ai/fisherman.js +96 -18
- package/dist/src/ai/pilot.d.ts +13 -1
- package/dist/src/ai/pilot.js +40 -22
- package/dist/src/ai/provider.js +8 -14
- package/dist/src/ai/researcher/coordinates.d.ts +2 -0
- package/dist/src/ai/researcher/coordinates.js +13 -1
- package/dist/src/ai/researcher/deep-analysis.js +9 -9
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/rules.js +10 -0
- package/dist/src/ai/tester.js +20 -20
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +18 -74
- package/dist/src/api/request-result.js +3 -1
- package/dist/src/api/request-store.d.ts +7 -6
- package/dist/src/api/request-store.js +65 -37
- package/dist/src/api/xhr-capture.d.ts +2 -0
- package/dist/src/api/xhr-capture.js +35 -10
- package/dist/src/commands/config-command.js +2 -9
- package/dist/src/commands/help-json-command.d.ts +31 -0
- package/dist/src/commands/help-json-command.js +58 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +3 -7
- package/dist/src/commands/plans-command.d.ts +4 -1
- package/dist/src/commands/plans-command.js +4 -4
- package/dist/src/commands/recommended-models-command.d.ts +9 -0
- package/dist/src/commands/recommended-models-command.js +85 -0
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +7 -2
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +3 -3
- package/dist/src/global-config.d.ts +2 -0
- package/dist/src/global-config.js +6 -0
- package/dist/src/state-manager.d.ts +3 -3
- package/dist/src/state-manager.js +8 -8
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +13 -9
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +23 -2
- package/dist/src/utils/cli-section.d.ts +1 -0
- package/dist/src/utils/cli-section.js +12 -0
- package/dist/src/utils/html-diff.d.ts +1 -0
- package/dist/src/utils/html-diff.js +5 -5
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +5 -4
- package/dist/src/utils/overlay.d.ts +19 -55
- package/dist/src/utils/overlay.js +94 -148
- package/dist/src/utils/region.d.ts +41 -0
- package/dist/src/utils/region.js +99 -0
- package/dist/src/utils/similarity.d.ts +1 -0
- package/dist/src/utils/similarity.js +11 -0
- package/dist/src/utils/url-matcher.d.ts +1 -1
- package/dist/src/utils/url-matcher.js +4 -4
- package/docs/doc-collection/basics.md +11 -5
- package/docs/doc-collection/crawling.md +21 -1
- package/docs/reference/commands.md +4 -0
- package/docs/reference/configuration.md +4 -0
- package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
- package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
- package/docs/workflow/agentic-usage.md +12 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +15 -16
- package/src/action.ts +16 -15
- package/src/ai/fisherman/request-haul.ts +36 -0
- package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
- package/src/ai/fisherman.ts +110 -20
- package/src/ai/pilot.ts +38 -20
- package/src/ai/provider.ts +8 -12
- package/src/ai/researcher/coordinates.ts +13 -1
- package/src/ai/researcher/deep-analysis.ts +9 -9
- package/src/ai/researcher/fingerprint-worker.ts +1 -10
- package/src/ai/researcher.ts +1 -1
- package/src/ai/rules.ts +10 -0
- package/src/ai/tester.ts +18 -18
- package/src/ai/tools.ts +18 -86
- package/src/api/request-result.ts +2 -1
- package/src/api/request-store.ts +68 -42
- package/src/api/xhr-capture.ts +39 -11
- package/src/commands/config-command.ts +2 -7
- package/src/commands/help-json-command.ts +74 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +4 -11
- package/src/commands/plans-command.ts +5 -5
- package/src/commands/recommended-models-command.ts +92 -0
- package/src/config.ts +9 -2
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +3 -3
- package/src/global-config.ts +8 -0
- package/src/state-manager.ts +10 -9
- package/src/test-plan.ts +12 -10
- package/src/utils/aria.ts +26 -3
- package/src/utils/cli-section.ts +12 -0
- package/src/utils/html-diff.ts +6 -6
- package/src/utils/html.ts +6 -4
- package/src/utils/overlay.ts +105 -192
- package/src/utils/region.ts +132 -0
- package/src/utils/similarity.ts +9 -0
- package/src/utils/url-matcher.ts +3 -3
- package/dist/src/ai/fisherman-tools.d.ts +0 -138
- package/dist/src/utils/request-map.d.ts +0 -7
- package/dist/src/utils/request-map.js +0 -16
- package/src/utils/request-map.ts +0 -19
package/src/ai/pilot.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { truncateJson } from '../utils/strings.ts';
|
|
|
18
18
|
import type { Agent, AgentDeps } from './agent.ts';
|
|
19
19
|
import type { Conversation } from './conversation.ts';
|
|
20
20
|
import type { Fisherman } from './fisherman.ts';
|
|
21
|
+
import { createAskApiTool } from './fisherman/tools.ts';
|
|
21
22
|
import type { Navigator } from './navigator.ts';
|
|
22
23
|
import type { Provider } from './provider.ts';
|
|
23
24
|
import type { Researcher } from './researcher.ts';
|
|
@@ -31,6 +32,7 @@ const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
|
31
32
|
const PILOT_REASONING_LIMIT = 500;
|
|
32
33
|
const PILOT_MESSAGE_LIMIT = 2;
|
|
33
34
|
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
35
|
+
const PILOT_REQUEST_LIMIT = 5;
|
|
34
36
|
|
|
35
37
|
export class Pilot implements Agent {
|
|
36
38
|
emoji = '🧭';
|
|
@@ -451,7 +453,8 @@ export class Pilot implements Agent {
|
|
|
451
453
|
|
|
452
454
|
Plan the test execution for this scenario.
|
|
453
455
|
|
|
454
|
-
FIRST: Decide if precondition() is needed.
|
|
456
|
+
FIRST: Decide if precondition() is needed. When the page does not settle whether suitable data
|
|
457
|
+
already exists, call askApi() to find out before creating any.
|
|
455
458
|
|
|
456
459
|
Call precondition() WHEN:
|
|
457
460
|
- The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
|
|
@@ -687,7 +690,7 @@ export class Pilot implements Agent {
|
|
|
687
690
|
}
|
|
688
691
|
this.conversation!.addUserText(finalUserText);
|
|
689
692
|
|
|
690
|
-
const tools = { ...this.pickPlanningTools(), ...this.
|
|
693
|
+
const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
|
|
691
694
|
|
|
692
695
|
const result = await this.provider.invokeConversation(this.conversation!, tools, {
|
|
693
696
|
maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
|
|
@@ -733,7 +736,12 @@ export class Pilot implements Agent {
|
|
|
733
736
|
return planning;
|
|
734
737
|
}
|
|
735
738
|
|
|
736
|
-
private
|
|
739
|
+
private fishermanStatus(): string {
|
|
740
|
+
if (this.fisherman?.isAvailable()) return 'available';
|
|
741
|
+
return 'none';
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private buildFishermanTools(task: Test) {
|
|
737
745
|
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.';
|
|
738
746
|
return {
|
|
739
747
|
precondition: tool({
|
|
@@ -744,7 +752,7 @@ export class Pilot implements Agent {
|
|
|
744
752
|
execute: async ({ description }) => {
|
|
745
753
|
task.addNote(`Precondition: ${description}`);
|
|
746
754
|
tag('info').log(`Precondition: ${description}`);
|
|
747
|
-
debugLog(`precondition: ${description}, fisherman: ${this.
|
|
755
|
+
debugLog(`precondition: ${description}, fisherman: ${this.fishermanStatus()}`);
|
|
748
756
|
|
|
749
757
|
if (!this.fisherman || !this.fisherman.isAvailable()) {
|
|
750
758
|
const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
|
|
@@ -775,6 +783,7 @@ export class Pilot implements Agent {
|
|
|
775
783
|
return { noted: true, prepared: true, created: result.created };
|
|
776
784
|
},
|
|
777
785
|
}),
|
|
786
|
+
...createAskApiTool(this.fisherman, task),
|
|
778
787
|
};
|
|
779
788
|
}
|
|
780
789
|
|
|
@@ -826,17 +835,17 @@ export class Pilot implements Agent {
|
|
|
826
835
|
lines.push(`h3: ${state.h3 || ''}`);
|
|
827
836
|
lines.push(`h4: ${state.h4 || ''}`);
|
|
828
837
|
|
|
829
|
-
const
|
|
830
|
-
if (
|
|
831
|
-
let line = `
|
|
832
|
-
if (
|
|
838
|
+
const region = state.overlay;
|
|
839
|
+
if (region.isModal) {
|
|
840
|
+
let line = `overlay: ${region.name || region.type}`;
|
|
841
|
+
if (region.root) line += ` (root: ${region.root})`;
|
|
833
842
|
lines.push(line);
|
|
834
|
-
} else if (
|
|
835
|
-
let line = `region: ${
|
|
836
|
-
if (
|
|
843
|
+
} else if (region.isOpen) {
|
|
844
|
+
let line = `region: ${region.name || 'unnamed'} (inline`;
|
|
845
|
+
if (region.root) line += `, root: ${region.root}`;
|
|
837
846
|
lines.push(`${line})`);
|
|
838
847
|
} else {
|
|
839
|
-
lines.push('
|
|
848
|
+
lines.push('overlay: none');
|
|
840
849
|
}
|
|
841
850
|
|
|
842
851
|
const tabs = this.stateManager.otherTabs;
|
|
@@ -1071,9 +1080,13 @@ export class Pilot implements Agent {
|
|
|
1071
1080
|
|
|
1072
1081
|
if (t.output?.pageDiff?.urlChanged) line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
|
|
1073
1082
|
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
|
|
1083
|
+
const pageRequests = t.output?.pageDiff?.requests ?? [];
|
|
1084
|
+
const requests = pageRequests
|
|
1085
|
+
.filter((r: any) => r.status >= 400)
|
|
1086
|
+
.concat(pageRequests.filter((r: any) => r.status < 400))
|
|
1087
|
+
.slice(0, PILOT_REQUEST_LIMIT);
|
|
1088
|
+
if (requests.length > 0) {
|
|
1089
|
+
line += `\n requests: ${requests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
|
|
1077
1090
|
}
|
|
1078
1091
|
|
|
1079
1092
|
const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
|
|
@@ -1139,15 +1152,15 @@ export class Pilot implements Agent {
|
|
|
1139
1152
|
|
|
1140
1153
|
Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
|
|
1141
1154
|
- Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
|
|
1142
|
-
- "
|
|
1155
|
+
- "overlay: none" but Tester targets an overlay → overlay closed; re-trigger.
|
|
1143
1156
|
- "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
|
|
1144
1157
|
- Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
|
|
1145
|
-
- MultipleElementsFound →
|
|
1158
|
+
- MultipleElementsFound → nothing was clicked. Tell Tester to reuse the same locator with step.opts({ elementIndex: N }) from the numbered elements list.
|
|
1146
1159
|
- Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
|
|
1147
1160
|
- Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
|
|
1148
1161
|
- form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
|
|
1149
1162
|
- ariaDiff shows 5+ added/removed → page entered new mode (editor/modal); call context() before guessing selectors.
|
|
1150
|
-
- Empty dropdown/list when items expected →
|
|
1163
|
+
- Empty dropdown/list when items expected → wait explicitly, then check the state changed: ariaDiff and any GET that loaded data. If still nothing loaded, confirm the empty state with verify(), or askApi() for whether the data exists at all.
|
|
1151
1164
|
- Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
|
|
1152
1165
|
- Multi-action explanation in one tool call → instruct Tester to split.
|
|
1153
1166
|
|
|
@@ -1163,8 +1176,13 @@ export class Pilot implements Agent {
|
|
|
1163
1176
|
|
|
1164
1177
|
${capabilityGroundingRule}
|
|
1165
1178
|
|
|
1166
|
-
YOUR Pilot-only
|
|
1167
|
-
|
|
1179
|
+
YOUR Pilot-only tools, both over the API:
|
|
1180
|
+
|
|
1181
|
+
askApi(question) — ask what data already exists. It changes nothing. Use it to check whether
|
|
1182
|
+
suitable data is already there before creating any, and to get the exact name or id of an existing
|
|
1183
|
+
record a step must act on.
|
|
1184
|
+
|
|
1185
|
+
precondition(description) — create FRESH disposable test data. Never request users. Use when:
|
|
1168
1186
|
|
|
1169
1187
|
- Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
|
|
1170
1188
|
- Scenario needs auxiliary data (labels, categories, statuses for filtering).
|
package/src/ai/provider.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type AIConfig, configuredModels, modelName as getModelName } from '../c
|
|
|
11
11
|
import { executionController } from '../execution-controller.ts';
|
|
12
12
|
import { Observability } from '../observability.ts';
|
|
13
13
|
import { Stats } from '../stats.ts';
|
|
14
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
14
15
|
import { createDebug, tag } from '../utils/logger.js';
|
|
15
16
|
import { type RetryOptions, withRetry } from '../utils/retry.js';
|
|
16
17
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
@@ -51,12 +52,7 @@ export async function flushTelemetry(): Promise<void> {
|
|
|
51
52
|
const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
|
|
52
53
|
|
|
53
54
|
function extractCachedTokens(usage: any): number {
|
|
54
|
-
|
|
55
|
-
const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
|
|
56
|
-
if (typeof direct === 'number') return direct;
|
|
57
|
-
const raw = usage.raw;
|
|
58
|
-
const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
|
|
59
|
-
return typeof fromRaw === 'number' ? fromRaw : 0;
|
|
55
|
+
return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
60
56
|
}
|
|
61
57
|
|
|
62
58
|
function abortAfterIdle(ms: number, cancel: { cancelled: boolean }, controller: AbortController): Promise<never> {
|
|
@@ -110,7 +106,7 @@ export class Provider {
|
|
|
110
106
|
|
|
111
107
|
constructor(config: AIConfig) {
|
|
112
108
|
if (!config?.model) {
|
|
113
|
-
throw new AiError(
|
|
109
|
+
throw new AiError(`AI model is not configured. Set ai.model in your config file, or pick one with ${getCliName()} recommended-models`);
|
|
114
110
|
}
|
|
115
111
|
this.config = config;
|
|
116
112
|
this.initLangfuse();
|
|
@@ -243,8 +239,8 @@ export class Provider {
|
|
|
243
239
|
private recordUsage(agentName: string, modelName: string, usage: any): void {
|
|
244
240
|
if (!usage) return;
|
|
245
241
|
Stats.recordTokens(agentName, modelName, {
|
|
246
|
-
input: usage.inputTokens ??
|
|
247
|
-
output: usage.outputTokens ??
|
|
242
|
+
input: usage.inputTokens ?? 0,
|
|
243
|
+
output: usage.outputTokens ?? 0,
|
|
248
244
|
total: usage.totalTokens ?? 0,
|
|
249
245
|
cached: extractCachedTokens(usage),
|
|
250
246
|
});
|
|
@@ -428,7 +424,7 @@ export class Provider {
|
|
|
428
424
|
let invalidRequestFeedbackAdded = false;
|
|
429
425
|
const executedStepMessages: ModelMessage[] = [];
|
|
430
426
|
try {
|
|
431
|
-
|
|
427
|
+
let response = await this.withModelRequestSlot(() =>
|
|
432
428
|
withRetry(async () => {
|
|
433
429
|
const stepMessages: ModelMessage[] = [];
|
|
434
430
|
const onStepEnd = (step: any) => {
|
|
@@ -458,7 +454,7 @@ export class Provider {
|
|
|
458
454
|
|
|
459
455
|
clearActivity();
|
|
460
456
|
|
|
461
|
-
withExecutedSteps(response, executedStepMessages);
|
|
457
|
+
response = withExecutedSteps(response, executedStepMessages);
|
|
462
458
|
|
|
463
459
|
// Log tool usage summary
|
|
464
460
|
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
@@ -651,7 +647,7 @@ export class Provider {
|
|
|
651
647
|
|
|
652
648
|
async processImage(prompt: string, image: string): Promise<any> {
|
|
653
649
|
if (!this.config.visionModel) {
|
|
654
|
-
throw new Error(
|
|
650
|
+
throw new Error(`Vision model not configured. Set ai.visionModel in your config; ${getCliName()} recommended-models lists the providers that serve one`);
|
|
655
651
|
}
|
|
656
652
|
|
|
657
653
|
setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
|
|
@@ -71,6 +71,14 @@ export async function visuallyAnnotateContainers(page: Page, containers: Array<{
|
|
|
71
71
|
}, containers);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
export async function removeVisualAnnotations(page: Page): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
|
|
77
|
+
for (const element of elements) element.remove();
|
|
78
|
+
});
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
75
83
|
return class extends Base {
|
|
76
84
|
declare explorer: Explorer;
|
|
@@ -85,6 +93,10 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
|
85
93
|
return this.explorer.withPage((page) => visuallyAnnotateContainers(page, opts?.containers || []));
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
async removeVisualAnnotations(): Promise<void> {
|
|
97
|
+
await this.explorer.withPage((page) => removeVisualAnnotations(page));
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
private async _analyzeScreenshotForVisualProps(): Promise<VisualAnalysisResult> {
|
|
89
101
|
const elements = new Map<string, { coordinates: string | null; color: string | null; icon: string | null }>();
|
|
90
102
|
const emptyResult: VisualAnalysisResult = { elements, pagePurpose: null, primaryActions: null, focusedSection: null };
|
|
@@ -96,7 +108,7 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
|
96
108
|
|
|
97
109
|
const prompt = dedent`
|
|
98
110
|
This screenshot has two types of annotations:
|
|
99
|
-
- **Section containers**: dashed bordered boxes (no labels on them). A legend at the bottom-
|
|
111
|
+
- **Section containers**: dashed bordered boxes (no labels on them). A legend at the bottom-right maps dashed line colors to section names. Ignore containers for this task.
|
|
100
112
|
- **Interactive elements**: solid bordered boxes with eidx numbers in the top-right corner above the box. Adjacent elements use different colors.
|
|
101
113
|
|
|
102
114
|
For each interactive element (solid border, eidx number), report:
|
|
@@ -88,22 +88,22 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
91
|
+
const region = current.overlay;
|
|
92
|
+
if (!region.isOpen || !region.name) return null;
|
|
93
93
|
|
|
94
94
|
const cached = getCachedResearch(pageStateHash);
|
|
95
95
|
if (!cached) return null;
|
|
96
96
|
|
|
97
|
-
const escaped =
|
|
97
|
+
const escaped = region.name.replace(/"/g, '\\"');
|
|
98
98
|
if (mdq(cached).query(`section3(~"${escaped}")`).count() > 0) {
|
|
99
|
-
debugLog(`Overlay "${
|
|
99
|
+
debugLog(`Overlay "${region.name}" already in cached research, skipping`);
|
|
100
100
|
return null;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
const diff = await current.diff(previous);
|
|
104
104
|
|
|
105
105
|
if (!diff.ariaChanged && diff.htmlParts.length === 0) {
|
|
106
|
-
debugLog(`No diff between current and previous state for overlay "${
|
|
106
|
+
debugLog(`No diff between current and previous state for overlay "${region.name}"`);
|
|
107
107
|
return null;
|
|
108
108
|
}
|
|
109
109
|
|
|
@@ -113,10 +113,10 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
113
113
|
.map((s) => s.rawMarkdown)
|
|
114
114
|
);
|
|
115
115
|
|
|
116
|
-
tag('substep').log(`Researching overlay: ${
|
|
117
|
-
const sectionMarkdown = await this._analyzeExpandedAction('',
|
|
116
|
+
tag('substep').log(`Researching overlay: ${region.name}`);
|
|
117
|
+
const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
|
|
118
118
|
if (!sectionMarkdown) {
|
|
119
|
-
debugLog(`Overlay "${
|
|
119
|
+
debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
|
|
120
120
|
return null;
|
|
121
121
|
}
|
|
122
122
|
|
|
@@ -130,7 +130,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
saveResearch({ hash: pageStateHash }, updated);
|
|
133
|
-
tag('substep').log(`Overlay research appended: ${
|
|
133
|
+
tag('substep').log(`Overlay research appended: ${region.name}`);
|
|
134
134
|
return sectionMarkdown;
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -3,16 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { parentPort } from 'node:worker_threads';
|
|
4
4
|
import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
|
|
5
5
|
import { isSamePageFamily } from '../../utils/url-matcher.ts';
|
|
6
|
-
|
|
7
|
-
function diceSimilarity(a: Set<string>, b: Set<string>): number {
|
|
8
|
-
let intersection = 0;
|
|
9
|
-
for (const item of a) {
|
|
10
|
-
if (b.has(item)) intersection++;
|
|
11
|
-
}
|
|
12
|
-
const total = a.size + b.size;
|
|
13
|
-
if (total === 0) return 100;
|
|
14
|
-
return Math.round(((2 * intersection) / total) * 100);
|
|
15
|
-
}
|
|
6
|
+
import { diceSimilarity } from '../../utils/similarity.ts';
|
|
16
7
|
|
|
17
8
|
parentPort!.on('message', (data: FingerprintRequest) => {
|
|
18
9
|
const { html, statesDir, maxAgeMs, threshold, url } = data;
|
package/src/ai/researcher.ts
CHANGED
|
@@ -236,7 +236,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
236
236
|
const containers = validContainers.filter((c) => !freshBroken.includes(c.css));
|
|
237
237
|
await this.visuallyAnnotateElements({ containers });
|
|
238
238
|
this.actionResult = await this.explorer.capture({ screenshot: true });
|
|
239
|
-
const visualResult = await this.analyzeScreenshotForVisualProps();
|
|
239
|
+
const visualResult = await this.analyzeScreenshotForVisualProps().finally(() => this.removeVisualAnnotations());
|
|
240
240
|
if (visualResult.elements.size > 0) {
|
|
241
241
|
await this.mergeVisualData(result, visualResult.elements);
|
|
242
242
|
result.parseLocators();
|
package/src/ai/rules.ts
CHANGED
|
@@ -173,6 +173,8 @@ export const dataProtectionRules = dedent`
|
|
|
173
173
|
Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
|
|
174
174
|
filter, tab, or list-inspection constraint. Use visible existing data when it is available.
|
|
175
175
|
If no suitable data exists, report the missing precondition instead of creating data.
|
|
176
|
+
Reading through the API to establish what already exists is not a mutation and stays allowed
|
|
177
|
+
under a read-only constraint.
|
|
176
178
|
|
|
177
179
|
Destructive actions are allowed only against data created by the current scenario
|
|
178
180
|
or prepared for that scenario by Fisherman/API preconditions. Existing application data must
|
|
@@ -335,6 +337,14 @@ export const actionRule = dedent`
|
|
|
335
337
|
If locator doesn't work, try CSS or XPath locators.
|
|
336
338
|
If nothing works, use I.clickXY(x, y) as last resort.
|
|
337
339
|
|
|
340
|
+
When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
|
|
341
|
+
Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
|
|
342
|
+
|
|
343
|
+
<example>
|
|
344
|
+
I.click('Remove', step.opts({ elementIndex: 2 }));
|
|
345
|
+
I.click({ role: 'link', text: 'Details' }, '.panel', step.opts({ elementIndex: 1 }));
|
|
346
|
+
</example>
|
|
347
|
+
|
|
338
348
|
For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
|
|
339
349
|
|
|
340
350
|
|
package/src/ai/tester.ts
CHANGED
|
@@ -548,17 +548,17 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
548
548
|
const isNewUrl = this.previousUrl !== currentUrl;
|
|
549
549
|
const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
|
|
550
550
|
|
|
551
|
-
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.
|
|
551
|
+
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.isOpen) {
|
|
552
552
|
this.regionTransitioned = true;
|
|
553
553
|
}
|
|
554
|
-
this.previousRegionPresent = currentState.overlay.
|
|
554
|
+
this.previousRegionPresent = currentState.overlay.isOpen;
|
|
555
555
|
|
|
556
556
|
this.previousUrl = currentUrl;
|
|
557
557
|
this.previousStateHash = currentStateHash;
|
|
558
558
|
|
|
559
559
|
let context = '';
|
|
560
560
|
|
|
561
|
-
const
|
|
561
|
+
const region = currentState.overlay;
|
|
562
562
|
|
|
563
563
|
const focusedElement = currentState.focusedElement;
|
|
564
564
|
if (focusedElement) {
|
|
@@ -577,29 +577,29 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
577
577
|
`;
|
|
578
578
|
}
|
|
579
579
|
|
|
580
|
-
if (
|
|
581
|
-
const areaName =
|
|
580
|
+
if (region.isModal) {
|
|
581
|
+
const areaName = region.name ? ` "${region.name}"` : '';
|
|
582
582
|
let rootHint = '';
|
|
583
|
-
if (
|
|
583
|
+
if (region.root) rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
|
|
584
584
|
context += dedent`
|
|
585
|
-
<
|
|
586
|
-
|
|
587
|
-
Scope all interactions to elements inside this
|
|
588
|
-
Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the
|
|
589
|
-
Use <page_aria> to confirm the element you target is actually inside the
|
|
590
|
-
</
|
|
585
|
+
<overlay>
|
|
586
|
+
An overlay${areaName} is currently open above the page.${rootHint}
|
|
587
|
+
Scope all interactions to elements inside this overlay.
|
|
588
|
+
Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the overlay.
|
|
589
|
+
Use <page_aria> to confirm the element you target is actually inside the overlay.
|
|
590
|
+
</overlay>
|
|
591
591
|
`;
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
if (!
|
|
594
|
+
if (!region.isModal && region.isOpen && isNewState) {
|
|
595
595
|
let rootHint = '';
|
|
596
|
-
if (
|
|
596
|
+
if (region.root) rootHint = `\nIt lives inside \`${region.root}\`.`;
|
|
597
597
|
context += dedent`
|
|
598
|
-
<
|
|
599
|
-
A large new
|
|
598
|
+
<region>
|
|
599
|
+
A large new region "${region.name || 'unnamed region'}" appeared on this page without navigation.${rootHint}
|
|
600
600
|
The scenario most likely continues inside this area — prefer its elements for your next actions.
|
|
601
601
|
The rest of the page (navigation, menus, filters) is still interactive and remains available.
|
|
602
|
-
</
|
|
602
|
+
</region>
|
|
603
603
|
`;
|
|
604
604
|
}
|
|
605
605
|
|
|
@@ -663,7 +663,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
663
663
|
return context;
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
-
if (
|
|
666
|
+
if (region.isOpen && region.name && this.pageStateHash && this.pageActionResult) {
|
|
667
667
|
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
|
|
668
668
|
if (overlaySection) {
|
|
669
669
|
context += dedent`
|
package/src/ai/tools.ts
CHANGED
|
@@ -15,7 +15,6 @@ import { pause } from '../utils/loop.js';
|
|
|
15
15
|
import { WebElement } from '../utils/web-element.ts';
|
|
16
16
|
import type { ToolDeps } from './agent.ts';
|
|
17
17
|
import { Navigator } from './navigator.ts';
|
|
18
|
-
import type { AIProvider } from './provider.ts';
|
|
19
18
|
import { Researcher } from './researcher.ts';
|
|
20
19
|
import { sectionContextRule } from './rules.ts';
|
|
21
20
|
import { isInteractive } from './task-agent.ts';
|
|
@@ -31,7 +30,7 @@ interface AgentToolDeps extends ToolDeps {
|
|
|
31
30
|
|
|
32
31
|
export const ASSERTION_TOOLS = ['verify'] as const;
|
|
33
32
|
|
|
34
|
-
export function createCodeceptJSTools({ explorer, stateManager
|
|
33
|
+
export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task: Task) {
|
|
35
34
|
return {
|
|
36
35
|
click: tool({
|
|
37
36
|
description: dedent`
|
|
@@ -44,6 +43,12 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
44
43
|
Container narrows search area. Use when page has multiple matching elements.
|
|
45
44
|
Example: Page has 3 "Delete" buttons in different rows:
|
|
46
45
|
I.click("Delete", ".row-1") - clicks Delete inside element with class row-1
|
|
46
|
+
I.click(locator, step.opts({ elementIndex: N })) - click the Nth element the locator matches
|
|
47
|
+
Use after a result reported several matches. N is the "Element N" number from that list.
|
|
48
|
+
step.opts() always goes LAST, after the container when there is one.
|
|
49
|
+
Example: the result listed 3 "Delete" buttons and you want the second:
|
|
50
|
+
I.click("Delete", step.opts({ elementIndex: 2 }))
|
|
51
|
+
I.click("Delete", ".table", step.opts({ elementIndex: 2 })) - same, narrowed to a container
|
|
47
52
|
|
|
48
53
|
IMPORTANT: This tool ONLY accepts click commands. For typing text, use form() tool.
|
|
49
54
|
CRITICAL: All commands MUST target the SAME element using different locators.
|
|
@@ -62,6 +67,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
62
67
|
3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
|
|
63
68
|
4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
|
|
64
69
|
5. I.clickXY(x, y) - coordinates fallback
|
|
70
|
+
After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
|
|
65
71
|
`),
|
|
66
72
|
explanation: z.string().describe('Why you are clicking this element'),
|
|
67
73
|
}),
|
|
@@ -91,6 +97,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
91
97
|
const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
92
98
|
const action = explorer.action();
|
|
93
99
|
const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
|
|
100
|
+
let ambiguityError: Error | null = null;
|
|
94
101
|
|
|
95
102
|
for (let i = 0; i < commands.length; i++) {
|
|
96
103
|
const command = transformContainsCommand(commands[i]);
|
|
@@ -100,6 +107,8 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
100
107
|
if (action.lastError) attempt.error = errorText(action.lastError);
|
|
101
108
|
attempts.push(attempt);
|
|
102
109
|
|
|
110
|
+
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') ambiguityError = action.lastError;
|
|
111
|
+
|
|
103
112
|
if (success) {
|
|
104
113
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
|
|
105
114
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
@@ -107,31 +116,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
110
|
-
let disambiguated = null;
|
|
111
|
-
if (attempts.some((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
112
|
-
disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (disambiguated) {
|
|
116
|
-
debugLog('Disambiguation picked element %d', disambiguated.position);
|
|
117
|
-
const failedCommand = attempts.find((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))?.command;
|
|
118
|
-
const retryCommands = [];
|
|
119
|
-
if (failedCommand) {
|
|
120
|
-
retryCommands.push(failedCommand.replace(/\)$/, `, step.opts({ elementIndex: ${disambiguated.position} }))`));
|
|
121
|
-
}
|
|
122
|
-
retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
|
|
123
|
-
|
|
124
|
-
for (const retryCmd of retryCommands) {
|
|
125
|
-
if (!(await action.attempt(retryCmd, explanation))) {
|
|
126
|
-
attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, retryCmd);
|
|
130
|
-
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
131
|
-
return successToolResult('click', { ...toolResult, attempts, code: retryCmd, disambiguated: true }, action);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
119
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
|
|
136
120
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
137
121
|
|
|
@@ -145,7 +129,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
145
129
|
attempts,
|
|
146
130
|
suggestion,
|
|
147
131
|
},
|
|
148
|
-
action.lastError
|
|
132
|
+
ambiguityError || action.lastError
|
|
149
133
|
);
|
|
150
134
|
},
|
|
151
135
|
}),
|
|
@@ -413,13 +397,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
413
397
|
const message = errorText(action.lastError);
|
|
414
398
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
415
399
|
|
|
416
|
-
|
|
417
|
-
if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
|
|
418
|
-
const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
419
|
-
if (disambiguated) {
|
|
420
|
-
formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
400
|
+
const formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
|
|
423
401
|
|
|
424
402
|
return failedToolResult(
|
|
425
403
|
'form',
|
|
@@ -1284,13 +1262,11 @@ export async function failedToolResult(action: string, message: string, data?: R
|
|
|
1284
1262
|
|
|
1285
1263
|
function getMultipleElementsSuggestion(): string {
|
|
1286
1264
|
return dedent`
|
|
1287
|
-
Multiple elements matched your locator
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
5. Use xpathCheck() to inspect matched elements and pick the correct one
|
|
1293
|
-
6. Use visualClick() to click the right element by visual appearance
|
|
1265
|
+
Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
|
|
1266
|
+
Read the numbered elements list and click the one you meant by its number:
|
|
1267
|
+
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1268
|
+
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1269
|
+
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1294
1270
|
`;
|
|
1295
1271
|
}
|
|
1296
1272
|
|
|
@@ -1367,50 +1343,6 @@ export async function formatMatchedElements(error: Error | null | undefined): Pr
|
|
|
1367
1343
|
return formatElementList(details);
|
|
1368
1344
|
}
|
|
1369
1345
|
|
|
1370
|
-
async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
|
|
1371
|
-
const elementDetails = await extractWebElements(error);
|
|
1372
|
-
if (!elementDetails) return null;
|
|
1373
|
-
|
|
1374
|
-
const elementList = formatElementList(elementDetails);
|
|
1375
|
-
|
|
1376
|
-
const schema = z.object({
|
|
1377
|
-
position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
|
|
1378
|
-
});
|
|
1379
|
-
|
|
1380
|
-
try {
|
|
1381
|
-
const result = await provider.generateObject(
|
|
1382
|
-
[
|
|
1383
|
-
{
|
|
1384
|
-
role: 'user' as const,
|
|
1385
|
-
content: dedent`
|
|
1386
|
-
A click action failed because multiple elements matched the locator.
|
|
1387
|
-
The intended action was: ${explanation}
|
|
1388
|
-
|
|
1389
|
-
Here are the matched elements:
|
|
1390
|
-
|
|
1391
|
-
${elementList}
|
|
1392
|
-
|
|
1393
|
-
Which element (1-${elementDetails.length}) best matches the intended action?
|
|
1394
|
-
Return the position number, or null if none of them match.
|
|
1395
|
-
`,
|
|
1396
|
-
},
|
|
1397
|
-
],
|
|
1398
|
-
schema,
|
|
1399
|
-
provider.getModelForAgent(),
|
|
1400
|
-
{ agentName: 'disambiguator', timeout: 15000 }
|
|
1401
|
-
);
|
|
1402
|
-
|
|
1403
|
-
const position = result?.object?.position;
|
|
1404
|
-
if (position && position >= 1 && position <= elementDetails.length) {
|
|
1405
|
-
return { position, xpath: elementDetails[position - 1].xpath };
|
|
1406
|
-
}
|
|
1407
|
-
return null;
|
|
1408
|
-
} catch (e) {
|
|
1409
|
-
debugLog('Element disambiguation AI call failed: %s', e);
|
|
1410
|
-
return null;
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
1346
|
function getNotFoundSuggestion(errorMessage: string): string | null {
|
|
1415
1347
|
if (!errorMessage.includes('not found')) {
|
|
1416
1348
|
return null;
|
|
@@ -124,7 +124,8 @@ export class RequestResult {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
writeFileSync(this.requestFile, yaml, 'utf8');
|
|
127
|
-
|
|
127
|
+
if (!this._rawResponseBody) return;
|
|
128
|
+
writeFileSync(this.responseFile, this._rawResponseBody, 'utf8');
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
static load(requestFile: string): RequestResult {
|