explorbot 0.1.28 → 0.1.29
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/README.md +83 -245
- package/bin/explorbot-cli.ts +1 -0
- package/dist/bin/explorbot-cli.js +1 -0
- package/dist/package.json +8 -6
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/ai/fisherman.js +14 -3
- package/dist/src/ai/pilot.js +19 -4
- package/dist/src/ai/planner.js +16 -5
- package/dist/src/ai/provider.js +53 -18
- package/dist/src/ai/researcher.js +7 -1
- package/dist/src/ai/rules.js +44 -0
- package/dist/src/ai/tester.js +70 -7
- package/dist/src/ai/tools.js +67 -1
- package/dist/src/explorbot.js +7 -2
- package/dist/src/stats.js +16 -0
- package/dist/src/utils/aria.js +66 -6
- package/package.json +8 -6
- package/rules/navigator/verification-actions.md +2 -0
- package/src/ai/fisherman.ts +14 -3
- package/src/ai/pilot.ts +19 -4
- package/src/ai/planner.ts +16 -5
- package/src/ai/provider.ts +51 -19
- package/src/ai/researcher.ts +8 -1
- package/src/ai/rules.ts +46 -0
- package/src/ai/tester.ts +74 -7
- package/src/ai/tools.ts +80 -1
- package/src/config.ts +1 -0
- package/src/explorbot.ts +6 -2
- package/src/stats.ts +18 -0
- package/src/utils/aria.ts +63 -6
package/src/ai/provider.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
1
2
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
2
3
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
3
|
-
import { generateObject, generateText,
|
|
4
|
+
import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
|
|
4
5
|
import type { ModelMessage } from 'ai';
|
|
5
6
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
6
7
|
import type { AIConfig } from '../config.js';
|
|
@@ -19,9 +20,11 @@ const responseLog = createDebug('explorbot:provider:in');
|
|
|
19
20
|
class AiError extends Error {}
|
|
20
21
|
export class ContextLengthError extends Error {}
|
|
21
22
|
|
|
23
|
+
let telemetryRegistered = false;
|
|
24
|
+
|
|
22
25
|
function extractCachedTokens(usage: any): number {
|
|
23
26
|
if (!usage) return 0;
|
|
24
|
-
const direct = usage.
|
|
27
|
+
const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
|
|
25
28
|
if (typeof direct === 'number') return direct;
|
|
26
29
|
const raw = usage.raw;
|
|
27
30
|
const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
|
|
@@ -86,7 +89,7 @@ export class Provider {
|
|
|
86
89
|
await generateText({
|
|
87
90
|
model: this.config.model,
|
|
88
91
|
prompt: 'hi',
|
|
89
|
-
|
|
92
|
+
maxOutputTokens: 1,
|
|
90
93
|
});
|
|
91
94
|
} catch (error: any) {
|
|
92
95
|
throw new AiError(`AI connection failed: ${error.message}`);
|
|
@@ -110,6 +113,16 @@ export class Provider {
|
|
|
110
113
|
return this.config.agenticModel || this.config.model;
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
getConfiguredModels(): Record<string, string> {
|
|
117
|
+
const models: Record<string, string> = { model: this.getModelName(this.config.model) };
|
|
118
|
+
if (this.config.agenticModel) models.agenticModel = this.getModelName(this.config.agenticModel);
|
|
119
|
+
if (this.config.visionModel) models.visionModel = this.getModelName(this.config.visionModel);
|
|
120
|
+
for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
|
|
121
|
+
if (agentConfig?.model) models[agent] = this.getModelName(agentConfig.model);
|
|
122
|
+
}
|
|
123
|
+
return models;
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
getSystemPromptForAgent(agentName: string, currentUrl?: string): string | undefined {
|
|
114
127
|
const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
|
|
115
128
|
const parts: string[] = [];
|
|
@@ -129,6 +142,12 @@ export class Provider {
|
|
|
129
142
|
return agentConfig?.providerOptions;
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
getReasoningForAgent(agentName?: string): string | undefined {
|
|
146
|
+
if (!agentName) return undefined;
|
|
147
|
+
const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
|
|
148
|
+
return agentConfig?.reasoning;
|
|
149
|
+
}
|
|
150
|
+
|
|
132
151
|
private getRetryOptions(options: any = {}): RetryOptions {
|
|
133
152
|
return {
|
|
134
153
|
...this.defaultRetryOptions,
|
|
@@ -146,6 +165,12 @@ export class Provider {
|
|
|
146
165
|
};
|
|
147
166
|
}
|
|
148
167
|
|
|
168
|
+
private finalizeConfig(config: Record<string, any>, options: any, telemetry: any): void {
|
|
169
|
+
if (telemetry) config.telemetry = telemetry;
|
|
170
|
+
const reasoning = this.getReasoningForAgent(options.agentName);
|
|
171
|
+
if (reasoning) config.reasoning ??= reasoning;
|
|
172
|
+
}
|
|
173
|
+
|
|
149
174
|
private initLangfuse() {
|
|
150
175
|
const langfuseConfig = this.config.langfuse;
|
|
151
176
|
const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
@@ -167,6 +192,10 @@ export class Provider {
|
|
|
167
192
|
instrumentations: [],
|
|
168
193
|
});
|
|
169
194
|
void this.otelSdk.start();
|
|
195
|
+
if (!telemetryRegistered) {
|
|
196
|
+
registerTelemetry(new OpenTelemetry());
|
|
197
|
+
telemetryRegistered = true;
|
|
198
|
+
}
|
|
170
199
|
this.telemetryEnabled = true;
|
|
171
200
|
}
|
|
172
201
|
|
|
@@ -177,20 +206,20 @@ export class Provider {
|
|
|
177
206
|
|
|
178
207
|
const runTelemetry = Observability.getTelemetry();
|
|
179
208
|
|
|
180
|
-
if (!options.
|
|
181
|
-
return runTelemetry
|
|
209
|
+
if (!options.telemetry) {
|
|
210
|
+
return runTelemetry;
|
|
182
211
|
}
|
|
183
212
|
|
|
184
213
|
if (!runTelemetry) {
|
|
185
|
-
return options.
|
|
214
|
+
return options.telemetry;
|
|
186
215
|
}
|
|
187
216
|
|
|
188
217
|
return {
|
|
189
218
|
...runTelemetry,
|
|
190
|
-
...options.
|
|
219
|
+
...options.telemetry,
|
|
191
220
|
metadata: {
|
|
192
221
|
...runTelemetry.metadata,
|
|
193
|
-
...options.
|
|
222
|
+
...options.telemetry.metadata,
|
|
194
223
|
},
|
|
195
224
|
};
|
|
196
225
|
}
|
|
@@ -242,15 +271,16 @@ export class Provider {
|
|
|
242
271
|
const telemetry = this.getTelemetry(options);
|
|
243
272
|
const config = this.mergeProviderOptions(
|
|
244
273
|
{
|
|
245
|
-
|
|
274
|
+
maxOutputTokens: 16384,
|
|
275
|
+
allowSystemInMessages: true,
|
|
246
276
|
...(this.config.config || {}),
|
|
247
277
|
...options,
|
|
248
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
249
278
|
model,
|
|
250
279
|
abortSignal: executionController.getAbortSignal(),
|
|
251
280
|
},
|
|
252
281
|
options.agentName
|
|
253
282
|
);
|
|
283
|
+
this.finalizeConfig(config, options, telemetry);
|
|
254
284
|
|
|
255
285
|
promptLog(messages[messages.length - 1].content);
|
|
256
286
|
try {
|
|
@@ -259,7 +289,7 @@ export class Provider {
|
|
|
259
289
|
if (!result.text) {
|
|
260
290
|
debugLog(result);
|
|
261
291
|
if (result.finishReason === 'length') {
|
|
262
|
-
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase
|
|
292
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
263
293
|
}
|
|
264
294
|
throw new Error('No response text from AI');
|
|
265
295
|
}
|
|
@@ -315,23 +345,24 @@ export class Provider {
|
|
|
315
345
|
const telemetry = this.getTelemetry(options);
|
|
316
346
|
const maxRoundtrips = options.maxToolRoundtrips ?? 5;
|
|
317
347
|
const extraStop = options.stopWhen;
|
|
318
|
-
const stopConditions: any[] = [
|
|
348
|
+
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
319
349
|
if (extraStop) stopConditions.push(extraStop);
|
|
320
350
|
const { stopWhen: _ignoredStopWhen, ...optionsWithoutStop } = options;
|
|
321
351
|
const config = this.mergeProviderOptions(
|
|
322
352
|
{
|
|
323
353
|
tools,
|
|
324
|
-
|
|
354
|
+
maxOutputTokens: 16384,
|
|
325
355
|
toolChoice: 'auto',
|
|
356
|
+
allowSystemInMessages: true,
|
|
326
357
|
...(this.config.config || {}),
|
|
327
358
|
...optionsWithoutStop,
|
|
328
359
|
stopWhen: stopConditions,
|
|
329
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
330
360
|
model,
|
|
331
361
|
abortSignal: executionController.getAbortSignal(),
|
|
332
362
|
},
|
|
333
363
|
options.agentName
|
|
334
364
|
);
|
|
365
|
+
this.finalizeConfig(config, options, telemetry);
|
|
335
366
|
try {
|
|
336
367
|
const response = await withRetry(async () => {
|
|
337
368
|
const timeout = config.timeout || 30000;
|
|
@@ -346,7 +377,7 @@ export class Provider {
|
|
|
346
377
|
])) as any;
|
|
347
378
|
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
348
379
|
if (!result.text && !hasToolCall && result.finishReason === 'length') {
|
|
349
|
-
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase
|
|
380
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
350
381
|
}
|
|
351
382
|
return result;
|
|
352
383
|
} finally {
|
|
@@ -409,14 +440,15 @@ export class Provider {
|
|
|
409
440
|
const config = this.mergeProviderOptions(
|
|
410
441
|
{
|
|
411
442
|
schema,
|
|
443
|
+
allowSystemInMessages: true,
|
|
412
444
|
...(this.config.config || {}),
|
|
413
445
|
...options,
|
|
414
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
415
446
|
model: modelToUse,
|
|
416
447
|
abortSignal: executionController.getAbortSignal(),
|
|
417
448
|
},
|
|
418
449
|
options.agentName
|
|
419
450
|
);
|
|
451
|
+
this.finalizeConfig(config, options, telemetry);
|
|
420
452
|
|
|
421
453
|
try {
|
|
422
454
|
promptLog(messages[messages.length - 1].content);
|
|
@@ -617,13 +649,13 @@ export class Provider {
|
|
|
617
649
|
];
|
|
618
650
|
|
|
619
651
|
const telemetry = this.getTelemetry({});
|
|
620
|
-
const config = {
|
|
621
|
-
|
|
652
|
+
const config: Record<string, any> = {
|
|
653
|
+
maxOutputTokens: 16384,
|
|
622
654
|
...(this.config.config || {}),
|
|
623
|
-
...(telemetry ? { experimental_telemetry: telemetry } : {}),
|
|
624
655
|
model: this.config.visionModel,
|
|
625
656
|
abortSignal: executionController.getAbortSignal(),
|
|
626
657
|
};
|
|
658
|
+
if (telemetry) config.telemetry = telemetry;
|
|
627
659
|
|
|
628
660
|
try {
|
|
629
661
|
promptLog(`Processing image with prompt: ${prompt}`);
|
package/src/ai/researcher.ts
CHANGED
|
@@ -68,6 +68,13 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
68
68
|
this.stateManager = explorer.getStateManager();
|
|
69
69
|
this.experienceTracker = this.stateManager.getExperienceTracker();
|
|
70
70
|
this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
|
|
71
|
+
|
|
72
|
+
const ai = explorer.getConfig().ai;
|
|
73
|
+
if (ai) {
|
|
74
|
+
ai.agents ??= {};
|
|
75
|
+
ai.agents.researcher ??= {};
|
|
76
|
+
ai.agents.researcher.reasoning ??= 'low';
|
|
77
|
+
}
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
protected getNavigator(): Navigator {
|
|
@@ -177,7 +184,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
177
184
|
} catch (error) {
|
|
178
185
|
if (!(error instanceof ContextLengthError) || retriesLeft <= 0) {
|
|
179
186
|
if (error instanceof ContextLengthError) {
|
|
180
|
-
tag('warning').log('Output truncated. Try lowering reasoning effort or increasing
|
|
187
|
+
tag('warning').log('Output truncated. Try lowering reasoning effort or increasing maxOutputTokens in ai.config.');
|
|
181
188
|
}
|
|
182
189
|
throw error;
|
|
183
190
|
}
|
package/src/ai/rules.ts
CHANGED
|
@@ -153,6 +153,52 @@ export const protectionRule = dedent`
|
|
|
153
153
|
</important>
|
|
154
154
|
`;
|
|
155
155
|
|
|
156
|
+
export const dataProtectionRules = dedent`
|
|
157
|
+
<data_protection_rules>
|
|
158
|
+
${protectionRule}
|
|
159
|
+
|
|
160
|
+
If the user request, scenario, focus, or test instructions explicitly prohibit creating,
|
|
161
|
+
editing, updating, deleting, removing, or otherwise mutating data, do not perform those
|
|
162
|
+
actions through the UI, API preconditions, cleanup, fallback steps, or Fisherman.
|
|
163
|
+
|
|
164
|
+
Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
|
|
165
|
+
filter, tab, or list-inspection constraint. Use visible existing data when it is available.
|
|
166
|
+
If no suitable data exists, report the missing precondition instead of creating data.
|
|
167
|
+
|
|
168
|
+
Destructive actions are allowed only against disposable data created by the current scenario
|
|
169
|
+
or prepared for that scenario by Fisherman/API preconditions. Existing application data must
|
|
170
|
+
remain unchanged.
|
|
171
|
+
</data_protection_rules>
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
export const capabilityGroundingRule = dedent`
|
|
175
|
+
<capability_grounding>
|
|
176
|
+
When a scenario depends on a named action, menu item, status, option, workflow, or feature,
|
|
177
|
+
that capability must be visible or explicitly confirmed in the current research/page context
|
|
178
|
+
for the same target entity type.
|
|
179
|
+
|
|
180
|
+
Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
|
|
181
|
+
Do not replace a requested action with a synonym or related action unless the UI explicitly
|
|
182
|
+
shows that action for the target entity.
|
|
183
|
+
|
|
184
|
+
When an action is described as applying to an item, row, card, record, node, or entity,
|
|
185
|
+
the target must be grounded as that kind of data entity in the current context. Do not use
|
|
186
|
+
navigation links, filter tabs, counters, breadcrumbs, headings, toolbar controls, or other
|
|
187
|
+
page controls as the subject of row/entity actions.
|
|
188
|
+
|
|
189
|
+
When a scenario asks to open, view, inspect, or navigate to an entity detail view, success
|
|
190
|
+
requires evidence of that entity detail context. An active filter, selected tab, visible count,
|
|
191
|
+
or filtered list is not enough to prove an entity detail view opened.
|
|
192
|
+
|
|
193
|
+
Do not rewrite a scenario goal to match a similar outcome that happened accidentally. If the
|
|
194
|
+
requested entity detail/action/workflow was not achieved, report that mismatch instead of
|
|
195
|
+
passing the test for a related filter, tab, navigation, or status view.
|
|
196
|
+
|
|
197
|
+
If the required capability is not available for the target entity after reasonable discovery,
|
|
198
|
+
record the missing capability and stop instead of repeatedly trying unrelated locators.
|
|
199
|
+
</capability_grounding>
|
|
200
|
+
`;
|
|
201
|
+
|
|
156
202
|
export const focusedElementRule = dedent`
|
|
157
203
|
<focused_element_actions>
|
|
158
204
|
When a text input element is focused (textbox, combobox, contenteditable):
|
package/src/ai/tester.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { Navigator } from './navigator.ts';
|
|
|
25
25
|
import type { Pilot } from './pilot.ts';
|
|
26
26
|
import { Provider } from './provider.ts';
|
|
27
27
|
import { Researcher } from './researcher.ts';
|
|
28
|
-
import { actionRule, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule,
|
|
28
|
+
import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from './rules.ts';
|
|
29
29
|
import { TaskAgent } from './task-agent.ts';
|
|
30
30
|
import { createCodeceptJSTools, createSpecialContextTools } from './tools.ts';
|
|
31
31
|
|
|
@@ -43,7 +43,7 @@ const SAMPLE_FILES: Record<string, string> = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
export class Tester extends TaskAgent implements Agent {
|
|
46
|
-
protected readonly ACTION_TOOLS = ['click', 'pressKey', 'form'];
|
|
46
|
+
protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
47
47
|
protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
|
|
48
48
|
emoji = '🧪';
|
|
49
49
|
private explorer: Explorer;
|
|
@@ -66,6 +66,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
66
66
|
private hooksRunner: HooksRunner;
|
|
67
67
|
private seenUiMapUrls = new Set<string>();
|
|
68
68
|
private lastAnalyzedStateHash: string | null = null;
|
|
69
|
+
private stalledIterations = 0;
|
|
70
|
+
private readonly MAX_STALLED_ITERATIONS = 3;
|
|
69
71
|
|
|
70
72
|
constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) {
|
|
71
73
|
super();
|
|
@@ -126,6 +128,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
126
128
|
this.pageActionResult = null;
|
|
127
129
|
this.seenUiMapUrls.clear();
|
|
128
130
|
this.lastAnalyzedStateHash = null;
|
|
131
|
+
this.stalledIterations = 0;
|
|
129
132
|
this.explorer.getStateManager().clearHistory();
|
|
130
133
|
this.resetFailureCount();
|
|
131
134
|
this.pilot?.reset();
|
|
@@ -348,6 +351,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
348
351
|
});
|
|
349
352
|
}
|
|
350
353
|
|
|
354
|
+
if (this.shouldStopForStalledExecution(task, currentState, result?.toolExecutions || [])) {
|
|
355
|
+
stop();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
351
359
|
if (assertionPerformed) {
|
|
352
360
|
const message = result?.toolExecutions?.find((execution: any) => execution.toolName === 'verify')?.output?.message || '';
|
|
353
361
|
task.addNote(message, wasSuccessful ? TestResult.PASSED : TestResult.FAILED);
|
|
@@ -456,6 +464,31 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
456
464
|
return true;
|
|
457
465
|
}
|
|
458
466
|
|
|
467
|
+
private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
|
|
468
|
+
if (task.hasFinished) return false;
|
|
469
|
+
|
|
470
|
+
const currentState = this.getCurrentState();
|
|
471
|
+
const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
|
|
472
|
+
const actionTools = [...this.ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
|
|
473
|
+
const hasSuccessfulAction = toolExecutions.some((execution) => execution.wasSuccessful && actionTools.includes(execution.toolName));
|
|
474
|
+
const hasSuccessfulAssertion = toolExecutions.some((execution) => execution.wasSuccessful && this.ASSERTION_TOOLS.includes(execution.toolName));
|
|
475
|
+
|
|
476
|
+
if (stateChanged || hasSuccessfulAction || hasSuccessfulAssertion) {
|
|
477
|
+
this.stalledIterations = 0;
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const hasNoBrowserProgress = toolExecutions.length === 0 || toolExecutions.every((execution) => !actionTools.includes(execution.toolName) || !execution.wasSuccessful);
|
|
482
|
+
if (!hasNoBrowserProgress) return false;
|
|
483
|
+
|
|
484
|
+
this.stalledIterations++;
|
|
485
|
+
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
|
|
486
|
+
|
|
487
|
+
task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
|
|
488
|
+
task.finish(TestResult.FAILED);
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
459
492
|
private async prepareInstructionsForNextStep(task: Test): Promise<string> {
|
|
460
493
|
let outcomeStatus = dedent`
|
|
461
494
|
<task>
|
|
@@ -464,6 +497,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
464
497
|
|
|
465
498
|
<rules>
|
|
466
499
|
Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
|
|
500
|
+
Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
|
|
501
|
+
Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
|
|
467
502
|
Do not do unsuccesful clicks again.
|
|
468
503
|
Do not run same tool calls with same parameters again.
|
|
469
504
|
</rules>
|
|
@@ -746,6 +781,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
746
781
|
<rules>
|
|
747
782
|
- Refer to UI Map from <page_ui_map> to understand the page structure and its main elements
|
|
748
783
|
- Use only elements that exist in the provided ARIA tree or HTML, <page_aria> and <page_html>
|
|
784
|
+
- Use tool input schemas exactly as documented. Do not invent parameter names or add fields not listed by the tool schema.
|
|
749
785
|
- Use click() for buttons, links, and clickable elements ONLY - do NOT include I.fillField() or I.type() commands in click() tool
|
|
750
786
|
- click() commands array is for FALLBACK LOCATORS of the SAME element, NOT for clicking different elements in sequence. If you need to click two different elements, make two separate click() calls.
|
|
751
787
|
- Use form() for text input (I.fillField, I.type), dropdown selection (I.selectOption), file uploads (I.attachFile), and multi-step form interactions
|
|
@@ -758,6 +794,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
758
794
|
- NEVER call record(status: "success") if your last verify() or see() call FAILED. A failed check means the outcome is NOT confirmed — use record(status: "fail") instead, or retry with a different approach.
|
|
759
795
|
- Use finish() to complete the test, not record(). record() is for intermediate notes.
|
|
760
796
|
- Call finish(verify) when all goals are achieved — provide an assertion to verify
|
|
797
|
+
- NEVER call finish() with a negative assertion that says the goal did NOT happen. If the goal cannot be achieved after real attempts, record the blocker and call stop().
|
|
761
798
|
- ONLY call stop() if the scenario itself is completely irrelevant to this page and no expectations can be achieved
|
|
762
799
|
- Use reset() ONLY as a last resort when the current page cannot host the scenario. Never reset after a successful flow just because an assertion or milestone did not match — verify differently or record() the finding instead. Reset is destructive and does not undo server-side side effects.
|
|
763
800
|
- Be precise with locators (CSS or XPath)
|
|
@@ -771,6 +808,13 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
771
808
|
- When you interact with form with inputs, ensure that you click corresponding button to save its data
|
|
772
809
|
- Follow <locator_priority> rules when selecting locators for all tools
|
|
773
810
|
- Before retrying your actions check maybe they already achived expected results. Use see() tool for that
|
|
811
|
+
- If the current URL is already a create/edit/new form and the scenario is about creating/editing that entity, fill and submit that form. Do not click the list-page "New" button again from inside the form.
|
|
812
|
+
- If the scenario is about search/filter/sort/tabs/list inspection and the current URL is a create/edit/new form, go back or reset to the stable list page before interacting with list controls.
|
|
813
|
+
- When selecting related entities from a list, do not choose rows/options/cards marked as "0 items", "0 tests", or otherwise empty if the scenario requires selecting real content.
|
|
814
|
+
- In selection pickers, counters such as "Selected 0", "Matched tests 0", or disabled Save/Apply mean the selection did not register. Choose a non-empty item or change filters before submitting.
|
|
815
|
+
- A passed form/click command only means the command executed. If a required field remains empty, submit stays disabled, or the expected text is not visible, treat the action as not completed and correct the missing field/state.
|
|
816
|
+
- For filter/tab scenarios, success requires BOTH: the requested filter/tab is visibly active/selected AND the list content matches that filter. Do not finish from only one of these signals.
|
|
817
|
+
- Empty-state text such as "No matched items" only proves a filter when the requested filter/tab is active and the empty state belongs to the filtered list.
|
|
774
818
|
- When filling complex form with lot of actions performed, use see() to look which fields were filled and which are not
|
|
775
819
|
- When verify() fails, use see() to visually confirm the result — visual confirmation is equally valid evidence
|
|
776
820
|
- For visual state verification (active tabs, selected items, counts, colors), prefer see() over DOM-based verify()
|
|
@@ -799,6 +843,10 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
799
843
|
|
|
800
844
|
${formRequirementsRule}
|
|
801
845
|
|
|
846
|
+
${capabilityGroundingRule}
|
|
847
|
+
|
|
848
|
+
${dataProtectionRules}
|
|
849
|
+
|
|
802
850
|
${this.provider.getSystemPromptForAgent('tester', this.explorer.getStateManager().getCurrentState()?.url) || ''}
|
|
803
851
|
`;
|
|
804
852
|
}
|
|
@@ -822,10 +870,13 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
822
870
|
Try to achieve as many goals as possible.
|
|
823
871
|
If goal is not achievable, log that and skip to next one.
|
|
824
872
|
Do not hallucinate that goal was achieved when it was not.
|
|
873
|
+
If the scenario action could not be completed, do not finish with a verification of the failure state.
|
|
825
874
|
When creating or editing items via form() or type() you should include ${task.sessionName} in the value (if it is not restricted by the application logic)
|
|
826
875
|
Initial page URL: ${actionResult.url}
|
|
827
876
|
|
|
828
|
-
${
|
|
877
|
+
${capabilityGroundingRule}
|
|
878
|
+
|
|
879
|
+
${dataProtectionRules}
|
|
829
880
|
|
|
830
881
|
${this.buildDeletionScope(task)}
|
|
831
882
|
|
|
@@ -963,12 +1014,13 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
963
1014
|
}),
|
|
964
1015
|
stop: tool({
|
|
965
1016
|
description: dedent`
|
|
966
|
-
Stop the current test because
|
|
967
|
-
Use this
|
|
968
|
-
|
|
1017
|
+
Stop the current test because it cannot be completed in the current session.
|
|
1018
|
+
Use this when the scenario is incompatible, required UI/data is absent, or repeated varied attempts
|
|
1019
|
+
show that automation cannot complete the workflow.
|
|
1020
|
+
Do NOT use this immediately after the first failed action — retry with a materially different approach first.
|
|
969
1021
|
`,
|
|
970
1022
|
inputSchema: z.object({
|
|
971
|
-
reason: z.string().describe('Explanation why the scenario
|
|
1023
|
+
reason: z.string().describe('Explanation why the scenario cannot be completed'),
|
|
972
1024
|
}),
|
|
973
1025
|
execute: async ({ reason }) => {
|
|
974
1026
|
task.addNote(`Stop requested: ${reason}`);
|
|
@@ -1003,6 +1055,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1003
1055
|
Provide a specific assertion to verify the final state.
|
|
1004
1056
|
The assertion MUST prove that YOUR ACTIONS changed the page state.
|
|
1005
1057
|
Do NOT verify something that was already true before you started testing.
|
|
1058
|
+
Do NOT provide an assertion that verifies absence, failure, an empty state, or that the goal did not happen.
|
|
1006
1059
|
|
|
1007
1060
|
Examples of good assertions:
|
|
1008
1061
|
- "New user 'john@example.com' is visible in the users list"
|
|
@@ -1135,12 +1188,26 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1135
1188
|
this.resetFailureCount();
|
|
1136
1189
|
this.previousUrl = null;
|
|
1137
1190
|
this.previousStateHash = null;
|
|
1191
|
+
this.stalledIterations = 0;
|
|
1192
|
+
} else if (this.shouldStopAfterStalledLoopError(task)) {
|
|
1193
|
+
return 'stop';
|
|
1138
1194
|
}
|
|
1139
1195
|
|
|
1140
1196
|
this.currentConversation?.addUserText(result.message);
|
|
1141
1197
|
return 'continue';
|
|
1142
1198
|
}
|
|
1143
1199
|
|
|
1200
|
+
private shouldStopAfterStalledLoopError(task: Test): boolean {
|
|
1201
|
+
if (task.hasFinished) return false;
|
|
1202
|
+
|
|
1203
|
+
this.stalledIterations++;
|
|
1204
|
+
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
|
|
1205
|
+
|
|
1206
|
+
task.addNote('No browser progress after repeated execution errors', TestResult.FAILED);
|
|
1207
|
+
task.finish(TestResult.FAILED);
|
|
1208
|
+
return true;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1144
1211
|
private async cleanupStartedTest(task: Test): Promise<void> {
|
|
1145
1212
|
await this.finishTest(task);
|
|
1146
1213
|
await this.explorer.stopTest(task, {
|
package/src/ai/tools.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { isInteractive } from './task-agent.ts';
|
|
|
18
18
|
|
|
19
19
|
const debugLog = createDebug('explorbot:tools');
|
|
20
20
|
|
|
21
|
-
export const CODECEPT_TOOLS = ['click', 'pressKey', 'form'] as const;
|
|
21
|
+
export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'] as const;
|
|
22
22
|
export const ASSERTION_TOOLS = ['verify'] as const;
|
|
23
23
|
|
|
24
24
|
export function createCodeceptJSTools(explorer: Explorer, task: Task) {
|
|
@@ -160,6 +160,84 @@ export function createCodeceptJSTools(explorer: Explorer, task: Task) {
|
|
|
160
160
|
},
|
|
161
161
|
}),
|
|
162
162
|
|
|
163
|
+
hover: tool({
|
|
164
|
+
description: dedent`
|
|
165
|
+
Move the mouse cursor to an element to reveal hover-only controls.
|
|
166
|
+
|
|
167
|
+
Use this before clicking row actions, icon buttons, menus, or toolbars that appear only
|
|
168
|
+
when the user hovers a list item, table row, card, or tree node.
|
|
169
|
+
|
|
170
|
+
This tool ONLY accepts I.moveCursorTo(locator) commands. It does not click.
|
|
171
|
+
After hovering, use context(), see(), or click() the revealed control.
|
|
172
|
+
`,
|
|
173
|
+
inputSchema: z.object({
|
|
174
|
+
commands: z.array(z.string()).describe(dedent`
|
|
175
|
+
FALLBACK LOCATORS for ONE element to hover.
|
|
176
|
+
Order by reliability:
|
|
177
|
+
1. I.moveCursorTo(text, container)
|
|
178
|
+
2. I.moveCursorTo(ARIA, container)
|
|
179
|
+
3. I.moveCursorTo(CSS, container)
|
|
180
|
+
4. I.moveCursorTo(CSS) or I.moveCursorTo(XPath)
|
|
181
|
+
`),
|
|
182
|
+
explanation: z.string().describe('Why you are hovering this element'),
|
|
183
|
+
}),
|
|
184
|
+
execute: async ({ commands: rawCommands, explanation }) => {
|
|
185
|
+
const activeNote = task.startNote(explanation);
|
|
186
|
+
|
|
187
|
+
if (rawCommands.length === 0) {
|
|
188
|
+
activeNote.commit(TestResult.FAILED);
|
|
189
|
+
return failedToolResult('hover', 'No commands provided');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.moveCursorTo'));
|
|
193
|
+
|
|
194
|
+
if (invalidCommands.length > 0) {
|
|
195
|
+
activeNote.commit(TestResult.FAILED);
|
|
196
|
+
return failedToolResult('hover', `Invalid commands: ${invalidCommands.join(', ')}. Hover tool only accepts I.moveCursorTo() commands.`, {
|
|
197
|
+
suggestion: 'Use click() to click elements, or form() for typing/selecting.',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const commands = rawCommands.map((cmd) => {
|
|
202
|
+
const trimmed = cmd.trim();
|
|
203
|
+
if (trimmed.startsWith('I.moveCursorTo')) return trimmed;
|
|
204
|
+
return `I.moveCursorTo(${JSON.stringify(trimmed)})`;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
208
|
+
const action = explorer.createAction();
|
|
209
|
+
const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
|
|
210
|
+
|
|
211
|
+
for (const command of commands) {
|
|
212
|
+
const success = await action.attempt(command, explanation, true);
|
|
213
|
+
attempts.push({
|
|
214
|
+
command,
|
|
215
|
+
success,
|
|
216
|
+
...(action.lastError && { error: action.lastError.toString() }),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!success) continue;
|
|
220
|
+
|
|
221
|
+
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
|
|
222
|
+
activeNote.commit(TestResult.PASSED);
|
|
223
|
+
return successToolResult('hover', { ...toolResult, attempts, code: command }, action);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
|
|
227
|
+
activeNote.commit(TestResult.FAILED);
|
|
228
|
+
return failedToolResult(
|
|
229
|
+
'hover',
|
|
230
|
+
'All hover commands failed',
|
|
231
|
+
{
|
|
232
|
+
...toolResult,
|
|
233
|
+
attempts,
|
|
234
|
+
suggestion: 'Use xpathCheck() to locate the row/card/tree node, or visualClick() if the hover target is only visually identifiable.',
|
|
235
|
+
},
|
|
236
|
+
action.lastError
|
|
237
|
+
);
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
|
|
163
241
|
pressKey: tool({
|
|
164
242
|
description: dedent`
|
|
165
243
|
Press a keyboard key or key combination. Use this for special keys like Enter, Escape, Tab, Arrow keys, or key combinations with modifiers.
|
|
@@ -488,6 +566,7 @@ export function createAgentTools({
|
|
|
488
566
|
Check the page contents based on current page state and screenshot.
|
|
489
567
|
This tool will trigger visual research to check the page contents on request.
|
|
490
568
|
Use it to verify the actions were performed correctly and the page is in the expected state.
|
|
569
|
+
Input schema has exactly one field: request. Do not pass text, reason, assertion, or other fields.
|
|
491
570
|
|
|
492
571
|
<example>
|
|
493
572
|
request: "Check current state of the Login form"
|
package/src/config.ts
CHANGED
|
@@ -59,6 +59,7 @@ interface AgentConfig extends HooksConfig {
|
|
|
59
59
|
systemPrompt?: string;
|
|
60
60
|
rules?: RuleEntry[];
|
|
61
61
|
providerOptions?: Record<string, any>;
|
|
62
|
+
reasoning?: 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
interface ResearcherAgentConfig extends AgentConfig {
|
package/src/explorbot.ts
CHANGED
|
@@ -25,12 +25,13 @@ import { ExperienceTracker } from './experience-tracker.ts';
|
|
|
25
25
|
import Explorer from './explorer.ts';
|
|
26
26
|
import { KnowledgeTracker } from './knowledge-tracker.ts';
|
|
27
27
|
import { WebPageState } from './state-manager.ts';
|
|
28
|
+
import { Stats } from './stats.ts';
|
|
28
29
|
import type { Suite } from './suite.ts';
|
|
29
30
|
import { Plan, type Test } from './test-plan.ts';
|
|
30
|
-
import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
31
31
|
import { setVerboseMode, tag } from './utils/logger.ts';
|
|
32
32
|
import { relativeToCwd } from './utils/next-steps.ts';
|
|
33
33
|
import { sanitizeFilename } from './utils/strings.ts';
|
|
34
|
+
import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
34
35
|
|
|
35
36
|
export interface ExplorBotOptions {
|
|
36
37
|
from?: string;
|
|
@@ -494,7 +495,10 @@ export class ExplorBot {
|
|
|
494
495
|
|
|
495
496
|
const reporter = this.explorer?.getReporter();
|
|
496
497
|
if (reporter?.isEnabled()) {
|
|
497
|
-
|
|
498
|
+
let description = markdown;
|
|
499
|
+
const modelsTable = Stats.modelsTable(this.provider.getConfiguredModels());
|
|
500
|
+
if (modelsTable) description = `${markdown}\n\n${modelsTable}`;
|
|
501
|
+
await reporter.setRunDescription(description);
|
|
498
502
|
}
|
|
499
503
|
|
|
500
504
|
this.lastReportedTestCount = tests.length;
|
package/src/stats.ts
CHANGED
|
@@ -54,6 +54,24 @@ export class Stats {
|
|
|
54
54
|
return String(num);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
static modelsTable(roleModels: Record<string, string>): string {
|
|
58
|
+
const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
|
|
59
|
+
if (usedModels.length === 0) return '';
|
|
60
|
+
|
|
61
|
+
const rolesByModel: Record<string, string[]> = {};
|
|
62
|
+
for (const [role, model] of Object.entries(roleModels)) {
|
|
63
|
+
if (!rolesByModel[model]) rolesByModel[model] = [];
|
|
64
|
+
rolesByModel[model].push(role);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rows = usedModels.map(([model, tokens]) => {
|
|
68
|
+
const roles = rolesByModel[model]?.join(', ') || '-';
|
|
69
|
+
return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
static hasActivity(): boolean {
|
|
58
76
|
if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0) return true;
|
|
59
77
|
const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
|