explorbot 0.3.2 → 0.3.5
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 -3
- package/dist/bin/explorbot-cli.js +12 -3
- package/dist/models.json +2 -0
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +1 -0
- package/dist/src/action-result.js +17 -12
- package/dist/src/action.d.ts +10 -0
- package/dist/src/action.js +16 -10
- package/dist/src/ai/conversation.d.ts +2 -1
- package/dist/src/ai/conversation.js +9 -4
- package/dist/src/ai/navigator.d.ts +3 -0
- package/dist/src/ai/navigator.js +20 -4
- package/dist/src/ai/pilot.js +9 -0
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +28 -2
- package/dist/src/ai/researcher/deep-analysis.js +5 -1
- package/dist/src/ai/researcher/sections.js +0 -1
- package/dist/src/ai/researcher.js +0 -1
- package/dist/src/ai/rules.js +0 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +8 -9
- package/dist/src/ai/tools.d.ts +2 -0
- package/dist/src/ai/tools.js +21 -4
- package/dist/src/commands/init-command.js +74 -24
- package/dist/src/components/InitWizard.d.ts +2 -1
- package/dist/src/components/InitWizard.js +8 -4
- package/dist/src/explorer.js +1 -0
- package/dist/src/knowledge-tracker.d.ts +3 -1
- package/dist/src/knowledge-tracker.js +4 -4
- package/dist/src/utils/aria.js +1 -1
- package/docs/basics/providers.md +2 -4
- package/models.json +2 -0
- package/package.json +1 -1
- package/src/action-result.ts +20 -15
- package/src/action.ts +21 -12
- package/src/ai/conversation.ts +11 -5
- package/src/ai/navigator.ts +22 -4
- package/src/ai/pilot.ts +9 -0
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +28 -2
- package/src/ai/researcher/deep-analysis.ts +6 -1
- package/src/ai/researcher/sections.ts +0 -1
- package/src/ai/researcher.ts +0 -1
- package/src/ai/rules.ts +0 -1
- package/src/ai/tester.ts +8 -7
- package/src/ai/tools.ts +20 -4
- package/src/commands/init-command.ts +81 -22
- package/src/components/InitWizard.tsx +8 -4
- package/src/explorer.ts +1 -0
- package/src/knowledge-tracker.ts +4 -4
- package/src/utils/aria.ts +1 -1
package/src/ai/navigator.ts
CHANGED
|
@@ -3,12 +3,13 @@ import dedent from 'dedent';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult } from '../action-result.js';
|
|
5
5
|
import type Action from '../action.ts';
|
|
6
|
+
import type { ExecutedStep } from '../action.ts';
|
|
6
7
|
import type { ExplorbotConfig } from '../config.ts';
|
|
7
8
|
import type { ExperienceTracker } from '../experience-tracker.js';
|
|
8
9
|
import Explorer from '../explorer.ts';
|
|
9
10
|
import type { KnowledgeTracker } from '../knowledge-tracker.js';
|
|
10
|
-
import { type StateManager, normalizeUrl } from '../state-manager.js';
|
|
11
11
|
import { renderAssertion } from '../playwright-recorder.ts';
|
|
12
|
+
import { type StateManager, normalizeUrl } from '../state-manager.js';
|
|
12
13
|
import { isFatalBrowserError } from '../utils/browser-errors.ts';
|
|
13
14
|
import { getCliName } from '../utils/cli-name.ts';
|
|
14
15
|
import { extractCodeBlocks } from '../utils/code-extractor.js';
|
|
@@ -37,6 +38,7 @@ class Navigator implements Agent {
|
|
|
37
38
|
|
|
38
39
|
private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
|
|
39
40
|
lastFailureReason: string | null = null;
|
|
41
|
+
executedSteps: ExecutedStep[] = [];
|
|
40
42
|
|
|
41
43
|
private systemPrompt = dedent`
|
|
42
44
|
<role>
|
|
@@ -217,12 +219,18 @@ class Navigator implements Agent {
|
|
|
217
219
|
if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
218
220
|
|
|
219
221
|
this.lastFailureReason = null;
|
|
222
|
+
this.executedSteps = [];
|
|
220
223
|
tag('info').log('AI Navigator resolving state at', actionResult.url);
|
|
221
224
|
debugLog('Resolution message:', message);
|
|
222
225
|
|
|
223
226
|
const action = opts?.action ?? this.explorer.action();
|
|
224
227
|
const expectedUrl = opts?.expectedUrl;
|
|
225
228
|
|
|
229
|
+
if (expectedUrl && this.targetUrlReached(action, expectedUrl, actionResult)) {
|
|
230
|
+
tag('success').log(`Already at ${expectedUrl} — navigation resolved`);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
226
234
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
227
235
|
|
|
228
236
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
@@ -313,14 +321,19 @@ class Navigator implements Agent {
|
|
|
313
321
|
resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
|
|
314
322
|
|
|
315
323
|
if (!resolved && attempt.ok) {
|
|
316
|
-
|
|
324
|
+
if (check.urlMatches) {
|
|
325
|
+
lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
|
|
326
|
+
tag('warning').log(`Page state did not change at ${check.freshState.url}`);
|
|
327
|
+
} else {
|
|
328
|
+
lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
|
|
329
|
+
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
330
|
+
}
|
|
317
331
|
batchFailures.push({
|
|
318
332
|
code: codeBlock,
|
|
319
333
|
error: lastFailure,
|
|
320
334
|
ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
|
|
321
335
|
urlAfter: check.freshState.url,
|
|
322
336
|
});
|
|
323
|
-
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
324
337
|
}
|
|
325
338
|
if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
|
|
326
339
|
progressBlocks.push(codeBlock);
|
|
@@ -456,6 +469,7 @@ class Navigator implements Agent {
|
|
|
456
469
|
|
|
457
470
|
debugLog(`Attempting resolution: ${codeBlock}`);
|
|
458
471
|
const ok = await action.attempt(codeBlock, message);
|
|
472
|
+
this.executedSteps.push(...action.executedSteps);
|
|
459
473
|
|
|
460
474
|
const page = action.playwrightHelper?.page;
|
|
461
475
|
if (page) {
|
|
@@ -484,11 +498,15 @@ class Navigator implements Agent {
|
|
|
484
498
|
}
|
|
485
499
|
|
|
486
500
|
const freshState = await this.explorer.capture();
|
|
487
|
-
const urlMatches = this.
|
|
501
|
+
const urlMatches = this.targetUrlReached(action, expectedUrl, freshState);
|
|
488
502
|
|
|
489
503
|
return { freshState, urlMatches };
|
|
490
504
|
}
|
|
491
505
|
|
|
506
|
+
private targetUrlReached(action: Action, expectedUrl: string, state: ActionResult): boolean {
|
|
507
|
+
return this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(state, expectedUrl));
|
|
508
|
+
}
|
|
509
|
+
|
|
492
510
|
private async ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null> {
|
|
493
511
|
if (freshState.getStateHash() === previous.getStateHash()) return null;
|
|
494
512
|
try {
|
package/src/ai/pilot.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { withdrawVisionTools } from './tools.ts';
|
|
|
28
28
|
const CHECK_TOOLS = ['verify', 'see', 'research'];
|
|
29
29
|
const EVIDENCE_TOOLS = ['verify', 'see'];
|
|
30
30
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
31
|
+
const PILOT_REASONING_LIMIT = 500;
|
|
31
32
|
const PILOT_MESSAGE_LIMIT = 2;
|
|
32
33
|
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
33
34
|
|
|
@@ -1044,6 +1045,12 @@ export class Pilot implements Agent {
|
|
|
1044
1045
|
if (resultMessage) line += `\n result: ${resultMessage}`;
|
|
1045
1046
|
if (errorDetail && errorDetail !== resultMessage) line += `\n error: ${errorDetail}`;
|
|
1046
1047
|
|
|
1048
|
+
if (!t.wasSuccessful && t.reasoning) {
|
|
1049
|
+
let rationale = t.reasoning;
|
|
1050
|
+
if (rationale.length > PILOT_REASONING_LIMIT) rationale = `...${rationale.slice(-PILOT_REASONING_LIMIT)}`;
|
|
1051
|
+
line += `\n tester reasoned: ${rationale.replace(/\n+/g, ' ')}`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1047
1054
|
const attempts = t.output?.attempts;
|
|
1048
1055
|
if (attempts && attempts.length > 1 && t.wasSuccessful) {
|
|
1049
1056
|
const failedBefore = attempts.filter((a: any) => !a.success);
|
|
@@ -1107,6 +1114,8 @@ export class Pilot implements Agent {
|
|
|
1107
1114
|
state), instruct Tester to verify() and finish(). If goal was already true at the start, propose
|
|
1108
1115
|
different input data so the test is meaningful. If Tester repeats the same successful action, STOP.
|
|
1109
1116
|
|
|
1117
|
+
If needed you should pick the exact item the scenario should act on (from the page, or precondition() one) and pass it to tester
|
|
1118
|
+
|
|
1110
1119
|
Action classification: GOAL-ADVANCING actions mutate the scenario's subject data (create/edit/delete/submit/verify).
|
|
1111
1120
|
VIEW-ONLY actions toggle filters/tabs/sort/collapse without changing data. One VIEW-ONLY to reveal a
|
|
1112
1121
|
target is fine; ≥2 consecutive VIEW-ONLY actions with no GOAL-ADVANCING action in between is thrashing
|
package/src/ai/planner.ts
CHANGED
|
@@ -33,7 +33,7 @@ const TasksSchema = z.object({
|
|
|
33
33
|
scenarios: z
|
|
34
34
|
.array(
|
|
35
35
|
z.object({
|
|
36
|
-
scenario: z.string().describe('A single sentence describing
|
|
36
|
+
scenario: z.string().describe('A single sentence describing the behavior to test.'),
|
|
37
37
|
priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
|
|
38
38
|
startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
|
|
39
39
|
steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
|
package/src/ai/provider.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { OpenTelemetry } from '@ai-sdk/otel';
|
|
|
2
2
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
3
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
4
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
-
import
|
|
5
|
+
import dedent from 'dedent';
|
|
6
|
+
import { APICallError, generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
|
|
6
7
|
import type { ModelMessage } from 'ai';
|
|
7
8
|
import { z } from 'zod';
|
|
8
9
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
@@ -423,10 +424,19 @@ export class Provider {
|
|
|
423
424
|
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
424
425
|
if (extraStop) stopConditions.push(extraStop);
|
|
425
426
|
const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
427
|
+
let attemptMessages = messages;
|
|
428
|
+
let invalidRequestFeedbackAdded = false;
|
|
426
429
|
try {
|
|
427
430
|
const response = await this.withModelRequestSlot(() =>
|
|
428
431
|
withRetry(async () => {
|
|
429
|
-
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000))
|
|
432
|
+
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal }), config.timeout || 30000).catch((error) => {
|
|
433
|
+
if (!invalidRequestFeedbackAdded) {
|
|
434
|
+
const amended = withInvalidRequestFeedback(attemptMessages, error);
|
|
435
|
+
invalidRequestFeedbackAdded = amended !== attemptMessages;
|
|
436
|
+
attemptMessages = amended;
|
|
437
|
+
}
|
|
438
|
+
throw error;
|
|
439
|
+
})) as any;
|
|
430
440
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
431
441
|
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
432
442
|
if (!result.text && !hasToolCall && result.finishReason === 'length') {
|
|
@@ -693,6 +703,22 @@ function repairToolCall(options: ToolCallRepairOptions): any | null {
|
|
|
693
703
|
return repairHarmonyChannel(options);
|
|
694
704
|
}
|
|
695
705
|
|
|
706
|
+
function withInvalidRequestFeedback(messages: ModelMessage[], error: unknown): ModelMessage[] {
|
|
707
|
+
if (!(error instanceof APICallError) || error.statusCode !== 400) return messages;
|
|
708
|
+
tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
|
|
709
|
+
return [
|
|
710
|
+
...messages,
|
|
711
|
+
{
|
|
712
|
+
role: 'user',
|
|
713
|
+
content: dedent`
|
|
714
|
+
The previous request was rejected by the provider as invalid:
|
|
715
|
+
"${error.message}"
|
|
716
|
+
Fix what it describes and re-issue the request.
|
|
717
|
+
`,
|
|
718
|
+
},
|
|
719
|
+
];
|
|
720
|
+
}
|
|
721
|
+
|
|
696
722
|
function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
697
723
|
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
698
724
|
if (markerIndex <= 0) return null;
|
|
@@ -9,6 +9,7 @@ import { diffAriaSnapshots } from '../../utils/aria.ts';
|
|
|
9
9
|
import { extractCodeBlocks } from '../../utils/code-extractor.ts';
|
|
10
10
|
import { tag } from '../../utils/logger.js';
|
|
11
11
|
import { mdq } from '../../utils/markdown-query.ts';
|
|
12
|
+
import { truncate } from '../../utils/strings.ts';
|
|
12
13
|
import type { Provider } from '../provider.js';
|
|
13
14
|
import { getCachedResearch, getPreviousResearch, saveResearch } from './cache.ts';
|
|
14
15
|
import { type Constructor, debugLog } from './mixin.ts';
|
|
@@ -16,6 +17,7 @@ import { type ResearchElement, parseResearchSections } from './parser.ts';
|
|
|
16
17
|
import type { ResearchResult } from './research-result.ts';
|
|
17
18
|
|
|
18
19
|
const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
|
|
20
|
+
const MAX_HTML_DIFF_CHARS = 20_000;
|
|
19
21
|
|
|
20
22
|
export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
21
23
|
return class extends Base {
|
|
@@ -486,6 +488,9 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
486
488
|
`;
|
|
487
489
|
}
|
|
488
490
|
|
|
491
|
+
const cleanedParts = await diff.cleanedHtmlParts();
|
|
492
|
+
const htmlChanges = truncate(cleanedParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n'), MAX_HTML_DIFF_CHARS);
|
|
493
|
+
|
|
489
494
|
const prompt = dedent`
|
|
490
495
|
${intro}
|
|
491
496
|
Analyze the changes and produce a UI map section.
|
|
@@ -494,7 +499,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
494
499
|
${diff.ariaChanged || 'none'}
|
|
495
500
|
|
|
496
501
|
HTML changes:
|
|
497
|
-
${
|
|
502
|
+
${htmlChanges || 'none'}
|
|
498
503
|
${alreadyHint}
|
|
499
504
|
|
|
500
505
|
Respond with a SINGLE section in this format:
|
|
@@ -111,7 +111,6 @@ export function WithSections<T extends Constructor>(Base: T) {
|
|
|
111
111
|
- Do not copy global toolbar, navigation, list, or detail elements into this section unless they are descendants of this section container.
|
|
112
112
|
- Every element with eidx inside this section's container MUST appear in the table.
|
|
113
113
|
- Every row needs CSS; ARIA may be "-" for icon-only buttons.
|
|
114
|
-
- ARIA locator JSON uses keys "role" and "text" (NOT "name").
|
|
115
114
|
- Elements marked data-explorbot-hit="covered" or "offscreen" are not directly actionable; describe the covering or focused UI first.
|
|
116
115
|
- In split-pane pages, entity detail panels are active detail context; include close/back/pin controls in the detail panel section when present.
|
|
117
116
|
</rules>
|
package/src/ai/researcher.ts
CHANGED
|
@@ -403,7 +403,6 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
403
403
|
- If an element has data-explorbot-hit="covered" or "offscreen", do not present it as directly actionable. Prefer the overlay, drawer, dialog, or focused section covering it, and mention what must be dismissed or revealed first.
|
|
404
404
|
- Every element with an eidx attribute MUST appear in exactly one matching UI map section — describe icon-only buttons by their visual role.
|
|
405
405
|
- Every UI map row needs a CSS selector; ARIA may be "-" for icon-only buttons, CSS must never be "-".
|
|
406
|
-
- ARIA locator JSON uses keys "role" and "text" (NOT "name").
|
|
407
406
|
- Mark elements with likely hover interactions (title, aria-describedby, menu items with submenus) as "(hover)".
|
|
408
407
|
</rules>
|
|
409
408
|
|
package/src/ai/rules.ts
CHANGED
|
@@ -71,7 +71,6 @@ const locatorStrategyRule = dedent`
|
|
|
71
71
|
|
|
72
72
|
<bad_aria_locator_example>
|
|
73
73
|
{ "role": "button", "text": "" } // INVALID - empty text is useless, use "-" instead
|
|
74
|
-
{ "role": "button", "name": "Save" } // WRONG key - use "text", not "name"
|
|
75
74
|
</bad_aria_locator_example>
|
|
76
75
|
|
|
77
76
|
NEVER include \`eidx\` attribute in any locator (ARIA, CSS, XPath). It is an internal annotation.
|
package/src/ai/tester.ts
CHANGED
|
@@ -65,6 +65,12 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
65
65
|
private stalledIterations = 0;
|
|
66
66
|
private readonly MAX_STALLED_ITERATIONS = 3;
|
|
67
67
|
|
|
68
|
+
private skipResearch = (err: Error): string => {
|
|
69
|
+
if (err.name === 'AbortError') throw err;
|
|
70
|
+
tag('warning').log(`Research skipped: ${err.message}`);
|
|
71
|
+
return '';
|
|
72
|
+
};
|
|
73
|
+
|
|
68
74
|
constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any) {
|
|
69
75
|
super(deps);
|
|
70
76
|
this.requestStore = deps.requestStore;
|
|
@@ -581,12 +587,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
581
587
|
const alreadySeenUiMap = this.seenUiMapUrls.has(currentUrl);
|
|
582
588
|
let research = '';
|
|
583
589
|
if (!alreadySeenUiMap) {
|
|
584
|
-
|
|
585
|
-
research = await this.researcher.research(currentState);
|
|
586
|
-
} catch (err) {
|
|
587
|
-
if (!(err instanceof ErrorPageError)) throw err;
|
|
588
|
-
tag('warning').log(`Research skipped: ${err.message}`);
|
|
589
|
-
}
|
|
590
|
+
research = await this.researcher.research(currentState).catch(this.skipResearch);
|
|
590
591
|
}
|
|
591
592
|
this.pageStateHash = currentStateHash;
|
|
592
593
|
this.pageActionResult = currentState;
|
|
@@ -627,7 +628,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
627
628
|
}
|
|
628
629
|
|
|
629
630
|
if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
|
|
630
|
-
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
|
|
631
|
+
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
|
|
631
632
|
if (overlaySection) {
|
|
632
633
|
context += dedent`
|
|
633
634
|
|
package/src/ai/tools.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tool } from 'ai';
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import type { ExecutedStep } from '../action.ts';
|
|
4
5
|
import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
|
|
5
6
|
import { type ExperienceTracker, renderExperienceRecipes } from '../experience-tracker.ts';
|
|
6
7
|
import { Stats } from '../stats.ts';
|
|
@@ -411,7 +412,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
411
412
|
const message = errorText(action.lastError);
|
|
412
413
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
413
414
|
|
|
414
|
-
let formSuggestion = '
|
|
415
|
+
let formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
|
|
415
416
|
if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
|
|
416
417
|
const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
417
418
|
if (disambiguated) {
|
|
@@ -421,10 +422,11 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
421
422
|
|
|
422
423
|
return failedToolResult(
|
|
423
424
|
'form',
|
|
424
|
-
`Form execution FAILED! ${message}`,
|
|
425
|
+
`Form execution FAILED! ${message}\n${formatExecutedSteps(action.executedSteps, codeLines.length)}`,
|
|
425
426
|
{
|
|
426
427
|
...toolResult,
|
|
427
428
|
code: codeBlock,
|
|
429
|
+
attempts: action.executedSteps,
|
|
428
430
|
suggestion: formSuggestion,
|
|
429
431
|
},
|
|
430
432
|
action.lastError
|
|
@@ -446,6 +448,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
446
448
|
...toolResult,
|
|
447
449
|
message: `Form completed successfully with ${lines.length} commands.`,
|
|
448
450
|
commandsExecuted: lines.length,
|
|
451
|
+
attempts: action.executedSteps,
|
|
449
452
|
code: codeBlock,
|
|
450
453
|
suggestion: 'Verify the form was filled in correctly using see() tool. If needed to submit: try click() tool or form() with I.pressKey("Enter").',
|
|
451
454
|
},
|
|
@@ -806,21 +809,26 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
806
809
|
const actionResult = ActionResult.fromState(currentState);
|
|
807
810
|
const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
|
|
808
811
|
const success = await navigator.resolveState(instruction, actionResult, { experience });
|
|
812
|
+
const attempts = navigator.executedSteps;
|
|
813
|
+
let stepReport = '';
|
|
814
|
+
if (attempts.length) stepReport = `\n${formatExecutedSteps(attempts)}`;
|
|
809
815
|
|
|
810
816
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, instruction);
|
|
811
817
|
|
|
812
818
|
if (success) {
|
|
813
819
|
return successToolResult('interact', {
|
|
814
820
|
...toolResult,
|
|
815
|
-
message: `Successfully executed: ${instruction}`,
|
|
821
|
+
message: `Successfully executed: ${instruction}${stepReport}`,
|
|
822
|
+
attempts,
|
|
816
823
|
});
|
|
817
824
|
}
|
|
818
825
|
|
|
819
826
|
let reason = '';
|
|
820
827
|
if (navigator.lastFailureReason) reason = `: ${navigator.lastFailureReason}`;
|
|
821
828
|
|
|
822
|
-
return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
|
|
829
|
+
return failedToolResult('interact', `Failed to execute: ${instruction}${reason}${stepReport}`, {
|
|
823
830
|
...toolResult,
|
|
831
|
+
attempts,
|
|
824
832
|
suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
|
|
825
833
|
});
|
|
826
834
|
} catch (error) {
|
|
@@ -1232,6 +1240,14 @@ export function hasFailedRequest(pageDiff: PageDiff): boolean {
|
|
|
1232
1240
|
return (pageDiff.requests ?? []).some((request) => request.status >= 400);
|
|
1233
1241
|
}
|
|
1234
1242
|
|
|
1243
|
+
export function formatExecutedSteps(steps: ExecutedStep[], requestedCount = steps.length): string {
|
|
1244
|
+
if (!steps.length) return `No command ran of ${requestedCount} requested.`;
|
|
1245
|
+
const lines = steps.map((step) => ` ${step.success ? 'OK' : 'FAILED'} ${step.command}`);
|
|
1246
|
+
const notRun = requestedCount - steps.length;
|
|
1247
|
+
if (notRun > 0) lines.push(` NOT RUN ${notRun} more`);
|
|
1248
|
+
return lines.join('\n');
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1235
1251
|
function hasObservablePageChange(data?: Record<string, any>): boolean {
|
|
1236
1252
|
if (!data?.pageDiff) return false;
|
|
1237
1253
|
if (data.pageDiff.urlChanged === true) return true;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import dedent from 'dedent';
|
|
5
4
|
import { ConfigParser, PROVIDERS } from '../config.ts';
|
|
6
5
|
import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from '../global-config.ts';
|
|
7
6
|
import { getCliName } from '../utils/cli-name.ts';
|
|
8
7
|
import { log, tag } from '../utils/logger.js';
|
|
9
8
|
import { relativeToCwd } from '../utils/next-steps.ts';
|
|
10
9
|
|
|
11
|
-
function defaultConfigTemplate(): string {
|
|
10
|
+
function defaultConfigTemplate(provider: string, esm: boolean): string {
|
|
11
|
+
let moduleExport = 'module.exports = config;';
|
|
12
|
+
if (esm) moduleExport = 'export default config;';
|
|
13
|
+
|
|
12
14
|
return `// 'provider/model-id' uses a bundled provider.
|
|
13
|
-
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
15
|
+
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
14
16
|
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
15
17
|
|
|
16
18
|
const config = {
|
|
@@ -20,7 +22,7 @@ const config = {
|
|
|
20
22
|
},
|
|
21
23
|
|
|
22
24
|
ai: {
|
|
23
|
-
${modelLines(
|
|
25
|
+
${modelLines(provider)}
|
|
24
26
|
},
|
|
25
27
|
|
|
26
28
|
reporter: {
|
|
@@ -33,17 +35,18 @@ ${modelLines('openrouter')}
|
|
|
33
35
|
},
|
|
34
36
|
};
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
${moduleExport}
|
|
37
39
|
`;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
function envTemplate(provider: string): string {
|
|
43
|
+
const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
|
|
44
|
+
if (name === provider) return `${envKey}=`;
|
|
45
|
+
return `# ${envKey}=`;
|
|
46
|
+
});
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# GROQ_API_KEY=
|
|
48
|
+
return `# AI provider API keys
|
|
49
|
+
${keyLines.join('\n')}
|
|
47
50
|
|
|
48
51
|
# Langfuse Tracing
|
|
49
52
|
LANGFUSE_SECRET_KEY=
|
|
@@ -51,11 +54,12 @@ LANGFUSE_PUBLIC_KEY=
|
|
|
51
54
|
LANGFUSE_BASE_URL=
|
|
52
55
|
|
|
53
56
|
# Testomat.io API key to publish run results
|
|
54
|
-
TESTOMATIO
|
|
55
|
-
|
|
57
|
+
TESTOMATIO=`;
|
|
58
|
+
}
|
|
56
59
|
|
|
57
60
|
export async function runInit(options: InitCommandOptions): Promise<void> {
|
|
58
|
-
|
|
61
|
+
const localRequested = !!(options.configPath || options.path);
|
|
62
|
+
if (options.global || (options.provider && !localRequested)) {
|
|
59
63
|
await runGlobalInit(options);
|
|
60
64
|
return;
|
|
61
65
|
}
|
|
@@ -66,7 +70,12 @@ export async function runInit(options: InitCommandOptions): Promise<void> {
|
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
const choice = await renderInitWizard('choose');
|
|
69
|
-
if (choice
|
|
73
|
+
if (choice !== 'local') return;
|
|
74
|
+
|
|
75
|
+
const provider = await renderLocalProviderWizard();
|
|
76
|
+
if (!provider) return;
|
|
77
|
+
|
|
78
|
+
runInitCommand({ ...options, provider });
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
export function writeGlobalConfig(provider: string, apiKey?: string): void {
|
|
@@ -97,7 +106,7 @@ export function writeGlobalConfig(provider: string, apiKey?: string): void {
|
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
export function runInitCommand(options: InitCommandOptions): void {
|
|
100
|
-
const
|
|
109
|
+
const provider = options.provider || 'openrouter';
|
|
101
110
|
const force = options.force ?? false;
|
|
102
111
|
const customPath = options.path;
|
|
103
112
|
const originalCwd = process.cwd();
|
|
@@ -112,12 +121,15 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
112
121
|
log(`Working in directory: ${relativeToCwd(dir)}`);
|
|
113
122
|
}
|
|
114
123
|
|
|
124
|
+
const configName = 'explorbot.config.js';
|
|
125
|
+
const configPath = options.configPath ?? `./${configName}`;
|
|
126
|
+
|
|
115
127
|
try {
|
|
116
128
|
let outPath = resolve(configPath);
|
|
117
129
|
if (existsSync(outPath) && statSync(outPath).isDirectory()) {
|
|
118
|
-
outPath = join(outPath,
|
|
130
|
+
outPath = join(outPath, configName);
|
|
119
131
|
} else if (!extname(outPath)) {
|
|
120
|
-
outPath = join(outPath,
|
|
132
|
+
outPath = join(outPath, configName);
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
const dir = dirname(outPath);
|
|
@@ -132,24 +144,30 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
132
144
|
process.exit(1);
|
|
133
145
|
}
|
|
134
146
|
|
|
135
|
-
|
|
147
|
+
const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
|
|
148
|
+
writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
|
|
136
149
|
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
137
150
|
|
|
138
151
|
const envPath = resolve(process.cwd(), '.env');
|
|
139
152
|
if (!existsSync(envPath)) {
|
|
140
|
-
writeFileSync(envPath, `${
|
|
153
|
+
writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
|
|
141
154
|
log(`Created env file: ${relativeToCwd(envPath)}`);
|
|
142
155
|
} else {
|
|
143
156
|
log(`Env file already exists: ${relativeToCwd(envPath)}`);
|
|
144
157
|
}
|
|
145
158
|
|
|
159
|
+
const missing = missingRoles(provider);
|
|
160
|
+
if (missing.length) {
|
|
161
|
+
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
146
164
|
log('');
|
|
147
165
|
log('Next steps:');
|
|
148
166
|
log('1. Configure AI provider in .env');
|
|
149
167
|
log('2. Set AI models config file');
|
|
150
168
|
log('3. Set web application URL in the config file');
|
|
151
169
|
log('4. Add initial knowledge (how to authorize to the application, etc.)');
|
|
152
|
-
tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to
|
|
170
|
+
tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to authorize use these credentials: admin@example.com / secret123'`));
|
|
153
171
|
tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
|
|
154
172
|
|
|
155
173
|
log('5. Launch application on a relative URL');
|
|
@@ -213,6 +231,28 @@ async function renderInitWizard(mode: 'choose' | 'global'): Promise<'local' | 'g
|
|
|
213
231
|
});
|
|
214
232
|
}
|
|
215
233
|
|
|
234
|
+
async function renderLocalProviderWizard(): Promise<string | null> {
|
|
235
|
+
const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
|
|
236
|
+
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
const finish = (provider: string | null) => {
|
|
239
|
+
unmount();
|
|
240
|
+
resolve(provider);
|
|
241
|
+
};
|
|
242
|
+
const { unmount } = render(
|
|
243
|
+
React.createElement(InitWizard, {
|
|
244
|
+
mode: 'local',
|
|
245
|
+
globalConfigExists: !!findGlobalConfig(),
|
|
246
|
+
onLocal: () => finish(null),
|
|
247
|
+
onComplete: () => finish(null),
|
|
248
|
+
onCancel: () => finish(null),
|
|
249
|
+
onLocalProvider: (provider: string) => finish(provider),
|
|
250
|
+
}),
|
|
251
|
+
{ exitOnCtrlC: false, patchConsole: false }
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
216
256
|
function modelLines(provider: string): string {
|
|
217
257
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
218
258
|
const roles: Array<[ModelRoleName, string]> = [
|
|
@@ -245,10 +285,29 @@ ${modelLines(provider)}
|
|
|
245
285
|
},
|
|
246
286
|
};
|
|
247
287
|
|
|
248
|
-
|
|
288
|
+
module.exports = config;
|
|
249
289
|
`;
|
|
250
290
|
}
|
|
251
291
|
|
|
292
|
+
function isModuleProject(configDir: string): boolean {
|
|
293
|
+
let currentDir = resolve(configDir);
|
|
294
|
+
|
|
295
|
+
while (true) {
|
|
296
|
+
const packagePath = join(currentDir, 'package.json');
|
|
297
|
+
if (existsSync(packagePath)) {
|
|
298
|
+
try {
|
|
299
|
+
return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const parentDir = dirname(currentDir);
|
|
306
|
+
if (parentDir === currentDir) return false;
|
|
307
|
+
currentDir = parentDir;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
252
311
|
function missingRoles(provider: string): string[] {
|
|
253
312
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
254
313
|
return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
|
|
@@ -10,14 +10,15 @@ import InputReadline from './InputReadline.js';
|
|
|
10
10
|
const PROVIDER_NAMES = Object.keys(PROVIDERS);
|
|
11
11
|
|
|
12
12
|
interface InitWizardProps {
|
|
13
|
-
mode: 'choose' | 'global';
|
|
13
|
+
mode: 'choose' | 'global' | 'local';
|
|
14
14
|
globalConfigExists: boolean;
|
|
15
15
|
onLocal: () => void;
|
|
16
16
|
onComplete: () => void;
|
|
17
17
|
onCancel: () => void;
|
|
18
|
+
onLocalProvider?: (provider: string) => void;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel }) => {
|
|
21
|
+
const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalProvider }) => {
|
|
21
22
|
const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate'>(mode === 'choose' ? 'target' : 'provider');
|
|
22
23
|
const [targetIndex, setTargetIndex] = useState(0);
|
|
23
24
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
@@ -78,7 +79,10 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
78
79
|
if (step === 'provider') {
|
|
79
80
|
if (key.upArrow) setProviderIndex((index) => Math.max(0, index - 1));
|
|
80
81
|
if (key.downArrow) setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
|
|
81
|
-
if (key.return)
|
|
82
|
+
if (key.return) {
|
|
83
|
+
if (mode === 'local') onLocalProvider?.(provider);
|
|
84
|
+
else setStep('key');
|
|
85
|
+
}
|
|
82
86
|
return;
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -151,7 +155,7 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
151
155
|
|
|
152
156
|
<Box marginTop={1}>
|
|
153
157
|
<Text dimColor>
|
|
154
|
-
Config goes to {globalDir()} | {step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
|
|
158
|
+
Config goes to {mode === 'local' ? 'the current directory' : globalDir()} | {step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
|
|
155
159
|
</Text>
|
|
156
160
|
</Box>
|
|
157
161
|
</Box>
|
package/src/explorer.ts
CHANGED
|
@@ -255,6 +255,7 @@ class Explorer {
|
|
|
255
255
|
const projectRoot = configParser.getProjectRoot();
|
|
256
256
|
(global as any).output_dir = configParser.getStatesDir();
|
|
257
257
|
(global as any).codecept_dir = projectRoot;
|
|
258
|
+
(global as any).codeceptjs = codeceptjs;
|
|
258
259
|
|
|
259
260
|
configParser.validateConfig(this.config);
|
|
260
261
|
|
package/src/knowledge-tracker.ts
CHANGED
|
@@ -95,7 +95,7 @@ export class KnowledgeTracker {
|
|
|
95
95
|
return this.applicationSpec?.renderFor(state) || '';
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
addKnowledge(urlPattern: string, description: string): { filename: string; filePath: string; isNewFile: boolean } {
|
|
98
|
+
addKnowledge(urlPattern: string, description: string, opts?: { replace?: boolean }): { filename: string; filePath: string; isNewFile: boolean } {
|
|
99
99
|
const configParser = ConfigParser.getInstance();
|
|
100
100
|
const configPath = configParser.getConfigPath();
|
|
101
101
|
|
|
@@ -130,10 +130,10 @@ export class KnowledgeTracker {
|
|
|
130
130
|
|
|
131
131
|
// Append new knowledge with separator
|
|
132
132
|
let newContent;
|
|
133
|
-
if (existingDescription) {
|
|
134
|
-
newContent = `${existingDescription}\n\n---\n\n${description}`;
|
|
135
|
-
} else {
|
|
133
|
+
if (opts?.replace || !existingDescription) {
|
|
136
134
|
newContent = description;
|
|
135
|
+
} else {
|
|
136
|
+
newContent = `${existingDescription}\n\n---\n\n${description}`;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
const fileContent = matter.stringify(newContent, frontmatter);
|
package/src/utils/aria.ts
CHANGED
|
@@ -586,7 +586,7 @@ export function parseAriaLocator(ariaStr: string): { role: string; text: string
|
|
|
586
586
|
const trimmed = ariaStr.trim();
|
|
587
587
|
if (trimmed === '-' || trimmed === '' || trimmed === '"-"') return null;
|
|
588
588
|
|
|
589
|
-
const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?text["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
|
|
589
|
+
const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?(?:text|name)["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
|
|
590
590
|
if (!match) return null;
|
|
591
591
|
|
|
592
592
|
return { role: match[1], text: match[2] };
|