explorbot 0.1.21 → 0.1.23
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/dist/package.json +3 -3
- package/dist/src/action-result.js +12 -0
- package/dist/src/ai/captain/web-mode.js +2 -2
- package/dist/src/ai/navigator.js +100 -40
- package/dist/src/ai/pilot.js +15 -7
- package/dist/src/ai/tools.js +39 -37
- package/dist/src/commands/init-command.js +9 -0
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +1 -1
- package/dist/src/reporter.js +5 -7
- package/package.json +3 -3
- package/src/action-result.ts +11 -0
- package/src/ai/captain/web-mode.ts +2 -2
- package/src/ai/navigator.ts +108 -43
- package/src/ai/pilot.ts +14 -7
- package/src/ai/tools.ts +45 -41
- package/src/commands/init-command.ts +9 -0
- package/src/config.ts +2 -0
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +1 -1
- package/src/reporter.ts +4 -7
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"@opentelemetry/sdk-trace-base": "^2.2.0",
|
|
83
83
|
"@opentelemetry/semantic-conventions": "^1.38.0",
|
|
84
84
|
"@scalar/openapi-parser": "^0.25.6",
|
|
85
|
-
"@testomatio/reporter": "^2.
|
|
85
|
+
"@testomatio/reporter": "^2.8.4",
|
|
86
86
|
"ai": "^6.0.6",
|
|
87
87
|
"axe-core": "^4.11.1",
|
|
88
88
|
"bash-tool": "^1.3.15",
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"micromatch": "^4.0.8",
|
|
109
109
|
"ora-classic": "^5.4.2",
|
|
110
110
|
"parse5": "^8.0.0",
|
|
111
|
-
"playwright": "^1.
|
|
111
|
+
"playwright": "^1.60",
|
|
112
112
|
"react": "^19.1.1",
|
|
113
113
|
"strip-ansi": "^7.1.2",
|
|
114
114
|
"turndown": "^7.2.1",
|
|
@@ -151,6 +151,18 @@ export class ActionResult {
|
|
|
151
151
|
this.verifications ??= {};
|
|
152
152
|
this.verifications[assertion] = passed;
|
|
153
153
|
}
|
|
154
|
+
getVerification(message) {
|
|
155
|
+
if (!this.verifications)
|
|
156
|
+
return null;
|
|
157
|
+
if (typeof message === 'string') {
|
|
158
|
+
return this.verifications[message] ?? null;
|
|
159
|
+
}
|
|
160
|
+
for (const [assertion, passed] of Object.entries(this.verifications)) {
|
|
161
|
+
if (message.test(assertion))
|
|
162
|
+
return passed;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
154
166
|
isSameUrl(state) {
|
|
155
167
|
if (!this.url || this.url === '') {
|
|
156
168
|
return false;
|
|
@@ -21,7 +21,7 @@ export function WithWebMode(Base) {
|
|
|
21
21
|
return state ? ActionResult.fromState(state) : null;
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
-
const { see, context, visualClick,
|
|
24
|
+
const { see, context, visualClick, learnExperience } = agentTools;
|
|
25
25
|
return {
|
|
26
26
|
navigate: tool({
|
|
27
27
|
description: 'Navigate to a URL or page description using AI-powered navigation.',
|
|
@@ -98,7 +98,7 @@ export function WithWebMode(Base) {
|
|
|
98
98
|
see,
|
|
99
99
|
context,
|
|
100
100
|
visualClick,
|
|
101
|
-
|
|
101
|
+
learnExperience,
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
webModePrompt() {
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -70,6 +70,12 @@ class Navigator {
|
|
|
70
70
|
this.experienceTracker = experienceTracker || new ExperienceTracker();
|
|
71
71
|
this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
|
|
72
72
|
}
|
|
73
|
+
get verifyAttempts() {
|
|
74
|
+
return this.explorer.getConfig().ai?.agents?.navigator?.verifyAttempts ?? 3;
|
|
75
|
+
}
|
|
76
|
+
get verifyTimeout() {
|
|
77
|
+
return this.explorer.getConfig().ai?.agents?.navigator?.verifyTimeout ?? 1500;
|
|
78
|
+
}
|
|
73
79
|
getBaseOrigin() {
|
|
74
80
|
const baseUrl = this.explorer.getConfig().playwright.url;
|
|
75
81
|
try {
|
|
@@ -442,16 +448,16 @@ class Navigator {
|
|
|
442
448
|
const s = stateManager.getCurrentState();
|
|
443
449
|
return s ? ActionResult.fromState(s) : null;
|
|
444
450
|
};
|
|
445
|
-
const {
|
|
451
|
+
const { learnExperience } = createAgentTools({
|
|
446
452
|
explorer: this.explorer,
|
|
447
453
|
researcher: null,
|
|
448
454
|
navigator: this,
|
|
449
455
|
experienceTracker: this.experienceTracker,
|
|
450
456
|
getState,
|
|
451
457
|
});
|
|
452
|
-
if (!
|
|
458
|
+
if (!learnExperience)
|
|
453
459
|
return undefined;
|
|
454
|
-
return {
|
|
460
|
+
return { learnExperience };
|
|
455
461
|
}
|
|
456
462
|
async freeSail(opts, actionResult) {
|
|
457
463
|
const stateManager = this.explorer.getStateManager();
|
|
@@ -565,6 +571,11 @@ class Navigator {
|
|
|
565
571
|
async verifyState(message, actionResult) {
|
|
566
572
|
tag('info').log('AI Navigator verifying state at', actionResult.url);
|
|
567
573
|
debugLog('Verification message:', message);
|
|
574
|
+
const cachedVerification = actionResult.getVerification(message);
|
|
575
|
+
if (cachedVerification !== null) {
|
|
576
|
+
tag('substep').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
|
|
577
|
+
return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
|
|
578
|
+
}
|
|
568
579
|
let knowledge = '';
|
|
569
580
|
let experience = '';
|
|
570
581
|
const relevantKnowledge = this.knowledgeTracker.getRelevantKnowledge(actionResult);
|
|
@@ -584,6 +595,20 @@ class Navigator {
|
|
|
584
595
|
experience = renderExperienceToc(toc);
|
|
585
596
|
}
|
|
586
597
|
}
|
|
598
|
+
const priorVerifications = Object.entries(actionResult.verifications ?? {});
|
|
599
|
+
let verificationContext = '';
|
|
600
|
+
if (priorVerifications.length > 0) {
|
|
601
|
+
const lines = priorVerifications.map(([claim, passed]) => `- "${claim}" → ${passed ? 'passed' : 'failed'}`).join('\n');
|
|
602
|
+
verificationContext = dedent `
|
|
603
|
+
<already_verified>
|
|
604
|
+
These claims were already checked on this page:
|
|
605
|
+
${lines}
|
|
606
|
+
|
|
607
|
+
If the claim to verify has the same meaning as one above (even if worded differently), do NOT write any assertion code.
|
|
608
|
+
Respond with a single line and nothing else: ALREADY_VERIFIED: <exact text of the matching claim>
|
|
609
|
+
</already_verified>
|
|
610
|
+
`;
|
|
611
|
+
}
|
|
587
612
|
const prompt = dedent `
|
|
588
613
|
<message>
|
|
589
614
|
${message}
|
|
@@ -597,11 +622,13 @@ class Navigator {
|
|
|
597
622
|
</page_html>
|
|
598
623
|
</page>
|
|
599
624
|
|
|
625
|
+
${verificationContext}
|
|
626
|
+
|
|
600
627
|
<task>
|
|
601
628
|
Identify what assertion the user wants to verify on the page.
|
|
602
|
-
Propose
|
|
629
|
+
Propose 2-3 strong, distinct CodeceptJS assertion code blocks that each directly prove the claim.
|
|
603
630
|
Use only data from the <page> context to plan the verification.
|
|
604
|
-
|
|
631
|
+
Prefer the fewest, most specific assertions over many variants of the same locator.
|
|
605
632
|
|
|
606
633
|
IMPORTANT: Each code block must verify the SPECIFIC claim in the message, not just a generic aspect of it.
|
|
607
634
|
Bad: I.seeElement({"role":"button","aria-pressed":"true"}) — matches ANY button, not the specific one
|
|
@@ -620,50 +647,83 @@ class Navigator {
|
|
|
620
647
|
tag('debug').log('Prompt:', prompt);
|
|
621
648
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
622
649
|
conversation.addUserText(prompt);
|
|
650
|
+
let alreadyVerified = false;
|
|
623
651
|
const tools = this.buildExperienceTools();
|
|
624
652
|
let codeBlocks = [];
|
|
625
653
|
const successfulCodes = [];
|
|
626
654
|
const assertionSteps = [];
|
|
627
655
|
const action = this.explorer.createAction();
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
656
|
+
let failures = 0;
|
|
657
|
+
const page = this.explorer.playwrightHelper?.page;
|
|
658
|
+
const originalTimeout = this.explorer.playwrightHelper?.options?.timeout ?? 3000;
|
|
659
|
+
page?.setDefaultTimeout(this.verifyTimeout);
|
|
660
|
+
try {
|
|
661
|
+
await loop(async ({ stop, iteration }) => {
|
|
662
|
+
if (codeBlocks.length === 0) {
|
|
663
|
+
const result = await this.provider.invokeConversation(conversation, tools);
|
|
664
|
+
if (!result)
|
|
665
|
+
return;
|
|
666
|
+
const aiResponse = result?.response?.text ?? '';
|
|
667
|
+
debugLog('Received AI response:', aiResponse.length, 'characters');
|
|
668
|
+
tag('step').log('Verifying assertion...');
|
|
669
|
+
if (this.checkAlreadyVerified(aiResponse, actionResult)) {
|
|
670
|
+
alreadyVerified = true;
|
|
671
|
+
stop();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
codeBlocks = extractCodeBlocks(aiResponse);
|
|
675
|
+
}
|
|
676
|
+
if (codeBlocks.length === 0) {
|
|
632
677
|
return;
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
678
|
+
}
|
|
679
|
+
const codeBlock = codeBlocks[iteration - 1];
|
|
680
|
+
if (!codeBlock) {
|
|
681
|
+
stop();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
await this.explorer.switchToMainFrame();
|
|
685
|
+
const verified = await action.attempt(codeBlock, message, false);
|
|
686
|
+
if (verified) {
|
|
687
|
+
tag('success').log('Verification passed');
|
|
688
|
+
successfulCodes.push(codeBlock);
|
|
689
|
+
assertionSteps.push(...action.assertionSteps);
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
failures++;
|
|
693
|
+
}
|
|
694
|
+
const target = Math.min(codeBlocks.length, this.verifyAttempts);
|
|
695
|
+
const majorityNeeded = Math.floor(target / 2) + 1;
|
|
696
|
+
if (successfulCodes.length >= majorityNeeded || failures > target - majorityNeeded) {
|
|
697
|
+
stop();
|
|
698
|
+
}
|
|
699
|
+
}, {
|
|
700
|
+
maxAttempts: this.verifyAttempts,
|
|
701
|
+
observability: {
|
|
702
|
+
agent: 'navigator',
|
|
703
|
+
},
|
|
704
|
+
catch: async (error) => {
|
|
705
|
+
debugLog(error);
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
page?.setDefaultTimeout(originalTimeout);
|
|
711
|
+
}
|
|
712
|
+
const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
|
|
713
|
+
const majorityNeeded = Math.floor(totalAttempted / 2) + 1;
|
|
714
|
+
let verified = successfulCodes.length >= majorityNeeded;
|
|
715
|
+
if (alreadyVerified)
|
|
716
|
+
verified = true;
|
|
664
717
|
actionResult.addVerification(message, verified);
|
|
665
718
|
this.explorer.getStateManager().updateState(actionResult);
|
|
666
719
|
return { verified, successfulCodes, assertionSteps, totalAttempted };
|
|
667
720
|
}
|
|
721
|
+
checkAlreadyVerified(aiResponse, actionResult) {
|
|
722
|
+
const verifiedMatch = aiResponse.match(/ALREADY_VERIFIED:\s*(.+)/i);
|
|
723
|
+
if (!verifiedMatch)
|
|
724
|
+
return false;
|
|
725
|
+
const claim = verifiedMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
726
|
+
return actionResult.getVerification(claim) === true;
|
|
727
|
+
}
|
|
668
728
|
}
|
|
669
729
|
export { Navigator };
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -279,6 +279,12 @@ export class Pilot {
|
|
|
279
279
|
- "Delete X" → X must be gone. Clicking delete is NOT enough.
|
|
280
280
|
- "Edit X" → updated value must be persisted (visible in list/detail). Opening edit is NOT enough; redirect after save with the new value visible IS enough.
|
|
281
281
|
- Negative tests ("without a name", "invalid", "duplicate", "unauthorized") → success means the system PREVENTED the action with validation/error.
|
|
282
|
+
- Navigation-prefixed titles ("Access/Open/Go to X to <do Y>") → the goal is <do Y>; reaching X is
|
|
283
|
+
only a milestone. A satisfied milestone (tab active, panel/prompt visible, list shown) is NEVER a pass
|
|
284
|
+
if <do Y> did not occur this run.
|
|
285
|
+
- If the page reveals the goal cannot be performed here — required control absent, integration not
|
|
286
|
+
connected, or only a setup/connect/empty-state prompt is shown — vote "skipped" (prerequisites unmet),
|
|
287
|
+
never "pass".
|
|
282
288
|
|
|
283
289
|
PROVENANCE: the entity you cite as proof must appear by name in <notes> or
|
|
284
290
|
<session_log> tool inputs for THIS run. Name absent from tester activity = stale
|
|
@@ -383,12 +389,12 @@ export class Pilot {
|
|
|
383
389
|
the elements needed for the scenario. The page summary does not list every element.
|
|
384
390
|
Prefer interacting with the current page over navigating away.
|
|
385
391
|
|
|
386
|
-
If you load a recipe via
|
|
392
|
+
If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
|
|
387
393
|
raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
|
|
388
394
|
steps 1–3, then…") and call out anywhere your scenario diverges from it.
|
|
389
395
|
|
|
390
396
|
Be concise and specific. Tester will follow your plan.
|
|
391
|
-
`, 'pilot.planTest', { tools: true,
|
|
397
|
+
`, 'pilot.planTest', { tools: true, maxToolRoundtrips: 3, task });
|
|
392
398
|
}
|
|
393
399
|
async reviewNewPage(task, currentState, testerConversation) {
|
|
394
400
|
if (!this.conversation)
|
|
@@ -478,7 +484,7 @@ export class Pilot {
|
|
|
478
484
|
this.conversation.addUserText(finalUserText);
|
|
479
485
|
let tools;
|
|
480
486
|
if (opts.tools) {
|
|
481
|
-
tools =
|
|
487
|
+
tools = this.pickPlanningTools();
|
|
482
488
|
}
|
|
483
489
|
if (opts.tools && opts.task) {
|
|
484
490
|
tools = { ...tools, ...this.buildPreconditionTool(opts.task) };
|
|
@@ -490,7 +496,7 @@ export class Pilot {
|
|
|
490
496
|
experimental_telemetry: { functionId },
|
|
491
497
|
});
|
|
492
498
|
const text = result?.response?.text || '';
|
|
493
|
-
const learned = (result?.toolExecutions || []).filter((e) => e.toolName === '
|
|
499
|
+
const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => e.output.content);
|
|
494
500
|
if (learned.length === 0)
|
|
495
501
|
return text;
|
|
496
502
|
return dedent `
|
|
@@ -515,7 +521,7 @@ export class Pilot {
|
|
|
515
521
|
return renderExperienceToc(toc);
|
|
516
522
|
}
|
|
517
523
|
pickPlanningTools() {
|
|
518
|
-
const { see, context, verify, research, getVisitedStates, xpathCheck,
|
|
524
|
+
const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser } = this.agentTools ?? {};
|
|
519
525
|
const planning = {};
|
|
520
526
|
if (see)
|
|
521
527
|
planning.see = see;
|
|
@@ -529,8 +535,10 @@ export class Pilot {
|
|
|
529
535
|
planning.getVisitedStates = getVisitedStates;
|
|
530
536
|
if (xpathCheck)
|
|
531
537
|
planning.xpathCheck = xpathCheck;
|
|
532
|
-
if (
|
|
533
|
-
planning.
|
|
538
|
+
if (learnExperience)
|
|
539
|
+
planning.learnExperience = learnExperience;
|
|
540
|
+
if (askUser)
|
|
541
|
+
planning.askUser = askUser;
|
|
534
542
|
return planning;
|
|
535
543
|
}
|
|
536
544
|
buildPreconditionTool(task) {
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -391,7 +391,7 @@ export function createSpecialContextTools(explorer, context) {
|
|
|
391
391
|
}),
|
|
392
392
|
};
|
|
393
393
|
}
|
|
394
|
-
export function createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, }) {
|
|
394
|
+
export function createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, supervisor, }) {
|
|
395
395
|
let visionDisabled = false;
|
|
396
396
|
const tools = {
|
|
397
397
|
see: tool({
|
|
@@ -685,41 +685,6 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
685
685
|
}
|
|
686
686
|
},
|
|
687
687
|
}),
|
|
688
|
-
askUser: tool({
|
|
689
|
-
description: dedent `
|
|
690
|
-
Ask the user for help when you're stuck or unsure how to proceed.
|
|
691
|
-
Only available in interactive mode (TUI).
|
|
692
|
-
|
|
693
|
-
Use when:
|
|
694
|
-
- Locator-based clicks keep failing
|
|
695
|
-
- You can't find an element that should exist
|
|
696
|
-
- Form interaction isn't working as expected
|
|
697
|
-
- You need clarification on what action to take
|
|
698
|
-
`,
|
|
699
|
-
inputSchema: z.object({
|
|
700
|
-
question: z.string().describe('What you need help with - be specific about what failed'),
|
|
701
|
-
context: z.string().optional().describe('Relevant context like locators tried, errors received'),
|
|
702
|
-
}),
|
|
703
|
-
execute: async ({ question, context }) => {
|
|
704
|
-
if (!isInteractive()) {
|
|
705
|
-
return {
|
|
706
|
-
success: false,
|
|
707
|
-
message: 'User input not available in non-interactive mode',
|
|
708
|
-
suggestion: 'Continue with automated recovery',
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
const prompt = context ? `${question}\n\nContext: ${context}\n\nYour suggestion ("skip" to continue):` : `${question}\n\nYour suggestion ("skip" to continue):`;
|
|
712
|
-
const userInput = await pause(prompt);
|
|
713
|
-
if (!userInput || userInput.toLowerCase() === 'skip') {
|
|
714
|
-
return { success: false, message: 'User skipped' };
|
|
715
|
-
}
|
|
716
|
-
return {
|
|
717
|
-
success: true,
|
|
718
|
-
userSuggestion: userInput,
|
|
719
|
-
instruction: 'Follow the user suggestion. Use interact() tool to execute.',
|
|
720
|
-
};
|
|
721
|
-
},
|
|
722
|
-
}),
|
|
723
688
|
back: tool({
|
|
724
689
|
description: dedent `
|
|
725
690
|
Navigate back to the previous page (most recent URL different from current).
|
|
@@ -834,7 +799,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
834
799
|
}),
|
|
835
800
|
};
|
|
836
801
|
if (experienceTracker && getState) {
|
|
837
|
-
tools.
|
|
802
|
+
tools.learnExperience = tool({
|
|
838
803
|
description: dedent `
|
|
839
804
|
Read the full body of a specific experience section listed in <experience>.
|
|
840
805
|
The TOC shows entries like "A.1 ## FLOW: ..." or "A.2 ## ACTION: ...". Pass the fileTag and sectionIndex.
|
|
@@ -857,6 +822,43 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
857
822
|
},
|
|
858
823
|
});
|
|
859
824
|
}
|
|
825
|
+
if (supervisor) {
|
|
826
|
+
tools.askUser = tool({
|
|
827
|
+
description: dedent `
|
|
828
|
+
Ask the user for help when automated recovery is stuck or the next step is unclear.
|
|
829
|
+
Only available in interactive mode (TUI).
|
|
830
|
+
|
|
831
|
+
Use when:
|
|
832
|
+
- The Tester keeps failing the same locator/element
|
|
833
|
+
- An element that should exist cannot be found
|
|
834
|
+
- Form interaction isn't working as expected
|
|
835
|
+
- You need a human decision on how to proceed
|
|
836
|
+
`,
|
|
837
|
+
inputSchema: z.object({
|
|
838
|
+
question: z.string().describe('What you need help with - be specific about what failed'),
|
|
839
|
+
context: z.string().optional().describe('Relevant context like locators tried, errors received'),
|
|
840
|
+
}),
|
|
841
|
+
execute: async ({ question, context }) => {
|
|
842
|
+
if (!isInteractive()) {
|
|
843
|
+
return {
|
|
844
|
+
success: false,
|
|
845
|
+
message: 'User input not available in non-interactive mode',
|
|
846
|
+
suggestion: 'Continue with automated recovery',
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
const prompt = context ? `${question}\n\nContext: ${context}\n\nYour suggestion ("skip" to continue):` : `${question}\n\nYour suggestion ("skip" to continue):`;
|
|
850
|
+
const userInput = await pause(prompt);
|
|
851
|
+
if (!userInput || userInput.toLowerCase() === 'skip') {
|
|
852
|
+
return { success: false, message: 'User skipped' };
|
|
853
|
+
}
|
|
854
|
+
return {
|
|
855
|
+
success: true,
|
|
856
|
+
userSuggestion: userInput,
|
|
857
|
+
instruction: 'Relay this suggestion to the Tester as the next concrete step.',
|
|
858
|
+
};
|
|
859
|
+
},
|
|
860
|
+
});
|
|
861
|
+
}
|
|
860
862
|
return tools;
|
|
861
863
|
}
|
|
862
864
|
const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
|
|
@@ -29,6 +29,15 @@ const config = {
|
|
|
29
29
|
// agentic model for decision making
|
|
30
30
|
agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
|
|
31
31
|
},
|
|
32
|
+
|
|
33
|
+
reporter: {
|
|
34
|
+
// Save a local HTML report after each run.
|
|
35
|
+
html: true,
|
|
36
|
+
// Save a local markdown report after each run.
|
|
37
|
+
markdown: true,
|
|
38
|
+
// Group runs by title in Testomat.io / HTML reports. Defaults to today's date — customize or remove.
|
|
39
|
+
runGroup: new Date().toISOString().slice(0, 10),
|
|
40
|
+
},
|
|
32
41
|
};
|
|
33
42
|
|
|
34
43
|
export default config;
|
|
@@ -455,7 +455,7 @@ export function renderExperienceToc(toc) {
|
|
|
455
455
|
lines.push('Locators and step ordering worked then; the page may have changed since.');
|
|
456
456
|
lines.push('Treat as a starting hypothesis, not ground truth. If a step fails, fall back to ARIA/UI-map.');
|
|
457
457
|
lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
|
|
458
|
-
lines.push('Call
|
|
458
|
+
lines.push('Call learnExperience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
|
|
459
459
|
lines.push('');
|
|
460
460
|
for (const entry of toc) {
|
|
461
461
|
lines.push(`File ${entry.fileTag} ${entry.url}:`);
|
package/dist/src/explorbot.js
CHANGED
|
@@ -170,7 +170,7 @@ export class ExplorBot {
|
|
|
170
170
|
const state = stateManager.getCurrentState();
|
|
171
171
|
return state ? ActionResult.fromState(state) : null;
|
|
172
172
|
};
|
|
173
|
-
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
|
|
173
|
+
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, supervisor: true });
|
|
174
174
|
return new Pilot(ai, tools, researcher, explorer, experienceTracker);
|
|
175
175
|
}));
|
|
176
176
|
}
|
package/dist/src/reporter.js
CHANGED
|
@@ -11,7 +11,7 @@ export class Reporter {
|
|
|
11
11
|
constructor(config, stateManager) {
|
|
12
12
|
this.reporterEnabled = Reporter.resolveEnabled(config);
|
|
13
13
|
this.stateManager = stateManager;
|
|
14
|
-
if (this.reporterEnabled &&
|
|
14
|
+
if (this.reporterEnabled && config?.html) {
|
|
15
15
|
this.configureHtmlPipe();
|
|
16
16
|
}
|
|
17
17
|
if (this.reporterEnabled && config?.markdown) {
|
|
@@ -45,6 +45,8 @@ export class Reporter {
|
|
|
45
45
|
return true;
|
|
46
46
|
if (config?.enabled === false)
|
|
47
47
|
return false;
|
|
48
|
+
if (config?.html || config?.markdown)
|
|
49
|
+
return true;
|
|
48
50
|
return Boolean(process.env.TESTOMATIO);
|
|
49
51
|
}
|
|
50
52
|
configureHtmlPipe() {
|
|
@@ -68,13 +70,9 @@ export class Reporter {
|
|
|
68
70
|
configureRunGroup(runGroup) {
|
|
69
71
|
if (process.env.TESTOMATIO_RUNGROUP_TITLE)
|
|
70
72
|
return;
|
|
71
|
-
if (runGroup
|
|
72
|
-
return;
|
|
73
|
-
if (runGroup) {
|
|
74
|
-
process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
|
|
73
|
+
if (!runGroup)
|
|
75
74
|
return;
|
|
76
|
-
|
|
77
|
-
process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
|
|
75
|
+
process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
|
|
78
76
|
}
|
|
79
77
|
async startRun() {
|
|
80
78
|
if (this.isRunStarted) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"@opentelemetry/sdk-trace-base": "^2.2.0",
|
|
83
83
|
"@opentelemetry/semantic-conventions": "^1.38.0",
|
|
84
84
|
"@scalar/openapi-parser": "^0.25.6",
|
|
85
|
-
"@testomatio/reporter": "^2.
|
|
85
|
+
"@testomatio/reporter": "^2.8.4",
|
|
86
86
|
"ai": "^6.0.6",
|
|
87
87
|
"axe-core": "^4.11.1",
|
|
88
88
|
"bash-tool": "^1.3.15",
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"micromatch": "^4.0.8",
|
|
109
109
|
"ora-classic": "^5.4.2",
|
|
110
110
|
"parse5": "^8.0.0",
|
|
111
|
-
"playwright": "^1.
|
|
111
|
+
"playwright": "^1.60",
|
|
112
112
|
"react": "^19.1.1",
|
|
113
113
|
"strip-ansi": "^7.1.2",
|
|
114
114
|
"turndown": "^7.2.1",
|
package/src/action-result.ts
CHANGED
|
@@ -207,6 +207,17 @@ export class ActionResult implements ActionResultData {
|
|
|
207
207
|
this.verifications[assertion] = passed;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
getVerification(message: string | RegExp): boolean | null {
|
|
211
|
+
if (!this.verifications) return null;
|
|
212
|
+
if (typeof message === 'string') {
|
|
213
|
+
return this.verifications[message] ?? null;
|
|
214
|
+
}
|
|
215
|
+
for (const [assertion, passed] of Object.entries(this.verifications)) {
|
|
216
|
+
if (message.test(assertion)) return passed;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
210
221
|
isSameUrl(state: WebPageState): boolean {
|
|
211
222
|
if (!this.url || this.url === '') {
|
|
212
223
|
return false;
|
|
@@ -22,7 +22,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
|
|
|
22
22
|
return state ? ActionResult.fromState(state) : null;
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
|
-
const { see, context, visualClick,
|
|
25
|
+
const { see, context, visualClick, learnExperience } = agentTools;
|
|
26
26
|
|
|
27
27
|
return {
|
|
28
28
|
navigate: tool({
|
|
@@ -103,7 +103,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
|
|
|
103
103
|
see,
|
|
104
104
|
context,
|
|
105
105
|
visualClick,
|
|
106
|
-
|
|
106
|
+
learnExperience,
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
109
|
|
package/src/ai/navigator.ts
CHANGED
|
@@ -82,6 +82,14 @@ class Navigator implements Agent {
|
|
|
82
82
|
this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
private get verifyAttempts(): number {
|
|
86
|
+
return this.explorer.getConfig().ai?.agents?.navigator?.verifyAttempts ?? 3;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private get verifyTimeout(): number {
|
|
90
|
+
return this.explorer.getConfig().ai?.agents?.navigator?.verifyTimeout ?? 1500;
|
|
91
|
+
}
|
|
92
|
+
|
|
85
93
|
private getBaseOrigin(): string | null {
|
|
86
94
|
const baseUrl = this.explorer.getConfig().playwright.url;
|
|
87
95
|
try {
|
|
@@ -473,21 +481,21 @@ class Navigator implements Agent {
|
|
|
473
481
|
return resolved;
|
|
474
482
|
}
|
|
475
483
|
|
|
476
|
-
private buildExperienceTools(): {
|
|
484
|
+
private buildExperienceTools(): { learnExperience: unknown } | undefined {
|
|
477
485
|
const stateManager = this.explorer.getStateManager();
|
|
478
486
|
const getState = () => {
|
|
479
487
|
const s = stateManager.getCurrentState();
|
|
480
488
|
return s ? ActionResult.fromState(s) : null;
|
|
481
489
|
};
|
|
482
|
-
const {
|
|
490
|
+
const { learnExperience } = createAgentTools({
|
|
483
491
|
explorer: this.explorer,
|
|
484
492
|
researcher: null as unknown as Researcher,
|
|
485
493
|
navigator: this,
|
|
486
494
|
experienceTracker: this.experienceTracker,
|
|
487
495
|
getState,
|
|
488
496
|
});
|
|
489
|
-
if (!
|
|
490
|
-
return {
|
|
497
|
+
if (!learnExperience) return undefined;
|
|
498
|
+
return { learnExperience };
|
|
491
499
|
}
|
|
492
500
|
|
|
493
501
|
async freeSail(opts?: { strategy?: 'deep' | 'shallow'; scope?: string; visitedUrls?: Set<string> }, actionResult?: ActionResult): Promise<{ target: string; reason: string } | null> {
|
|
@@ -623,6 +631,12 @@ class Navigator implements Agent {
|
|
|
623
631
|
tag('info').log('AI Navigator verifying state at', actionResult.url);
|
|
624
632
|
debugLog('Verification message:', message);
|
|
625
633
|
|
|
634
|
+
const cachedVerification = actionResult.getVerification(message);
|
|
635
|
+
if (cachedVerification !== null) {
|
|
636
|
+
tag('substep').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
|
|
637
|
+
return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
|
|
638
|
+
}
|
|
639
|
+
|
|
626
640
|
let knowledge = '';
|
|
627
641
|
let experience = '';
|
|
628
642
|
|
|
@@ -645,6 +659,21 @@ class Navigator implements Agent {
|
|
|
645
659
|
}
|
|
646
660
|
}
|
|
647
661
|
|
|
662
|
+
const priorVerifications = Object.entries(actionResult.verifications ?? {});
|
|
663
|
+
let verificationContext = '';
|
|
664
|
+
if (priorVerifications.length > 0) {
|
|
665
|
+
const lines = priorVerifications.map(([claim, passed]) => `- "${claim}" → ${passed ? 'passed' : 'failed'}`).join('\n');
|
|
666
|
+
verificationContext = dedent`
|
|
667
|
+
<already_verified>
|
|
668
|
+
These claims were already checked on this page:
|
|
669
|
+
${lines}
|
|
670
|
+
|
|
671
|
+
If the claim to verify has the same meaning as one above (even if worded differently), do NOT write any assertion code.
|
|
672
|
+
Respond with a single line and nothing else: ALREADY_VERIFIED: <exact text of the matching claim>
|
|
673
|
+
</already_verified>
|
|
674
|
+
`;
|
|
675
|
+
}
|
|
676
|
+
|
|
648
677
|
const prompt = dedent`
|
|
649
678
|
<message>
|
|
650
679
|
${message}
|
|
@@ -658,11 +687,13 @@ class Navigator implements Agent {
|
|
|
658
687
|
</page_html>
|
|
659
688
|
</page>
|
|
660
689
|
|
|
690
|
+
${verificationContext}
|
|
691
|
+
|
|
661
692
|
<task>
|
|
662
693
|
Identify what assertion the user wants to verify on the page.
|
|
663
|
-
Propose
|
|
694
|
+
Propose 2-3 strong, distinct CodeceptJS assertion code blocks that each directly prove the claim.
|
|
664
695
|
Use only data from the <page> context to plan the verification.
|
|
665
|
-
|
|
696
|
+
Prefer the fewest, most specific assertions over many variants of the same locator.
|
|
666
697
|
|
|
667
698
|
IMPORTANT: Each code block must verify the SPECIFIC claim in the message, not just a generic aspect of it.
|
|
668
699
|
Bad: I.seeElement({"role":"button","aria-pressed":"true"}) — matches ANY button, not the specific one
|
|
@@ -684,6 +715,7 @@ class Navigator implements Agent {
|
|
|
684
715
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
685
716
|
conversation.addUserText(prompt);
|
|
686
717
|
|
|
718
|
+
let alreadyVerified = false;
|
|
687
719
|
const tools = this.buildExperienceTools();
|
|
688
720
|
|
|
689
721
|
let codeBlocks: string[] = [];
|
|
@@ -691,57 +723,90 @@ class Navigator implements Agent {
|
|
|
691
723
|
const assertionSteps: Array<{ name: string; args: any[] }> = [];
|
|
692
724
|
|
|
693
725
|
const action = this.explorer.createAction();
|
|
726
|
+
let failures = 0;
|
|
694
727
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const result = await this.provider.invokeConversation(conversation, tools);
|
|
699
|
-
if (!result) return;
|
|
700
|
-
const aiResponse = result?.response?.text;
|
|
701
|
-
debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
|
|
702
|
-
tag('step').log('Verifying assertion...');
|
|
703
|
-
codeBlocks = extractCodeBlocks(aiResponse ?? '');
|
|
704
|
-
}
|
|
728
|
+
const page = this.explorer.playwrightHelper?.page;
|
|
729
|
+
const originalTimeout = this.explorer.playwrightHelper?.options?.timeout ?? 3000;
|
|
730
|
+
page?.setDefaultTimeout(this.verifyTimeout);
|
|
705
731
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
732
|
+
try {
|
|
733
|
+
await loop(
|
|
734
|
+
async ({ stop, iteration }) => {
|
|
735
|
+
if (codeBlocks.length === 0) {
|
|
736
|
+
const result = await this.provider.invokeConversation(conversation, tools);
|
|
737
|
+
if (!result) return;
|
|
738
|
+
const aiResponse = result?.response?.text ?? '';
|
|
739
|
+
debugLog('Received AI response:', aiResponse.length, 'characters');
|
|
740
|
+
tag('step').log('Verifying assertion...');
|
|
741
|
+
|
|
742
|
+
if (this.checkAlreadyVerified(aiResponse, actionResult)) {
|
|
743
|
+
alreadyVerified = true;
|
|
744
|
+
stop();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
709
747
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
stop();
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
748
|
+
codeBlocks = extractCodeBlocks(aiResponse);
|
|
749
|
+
}
|
|
715
750
|
|
|
716
|
-
|
|
751
|
+
if (codeBlocks.length === 0) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
717
754
|
|
|
718
|
-
|
|
755
|
+
const codeBlock = codeBlocks[iteration - 1];
|
|
756
|
+
if (!codeBlock) {
|
|
757
|
+
stop();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
719
760
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
761
|
+
await this.explorer.switchToMainFrame();
|
|
762
|
+
|
|
763
|
+
const verified = await action.attempt(codeBlock, message, false);
|
|
764
|
+
|
|
765
|
+
if (verified) {
|
|
766
|
+
tag('success').log('Verification passed');
|
|
767
|
+
successfulCodes.push(codeBlock);
|
|
768
|
+
assertionSteps.push(...action.assertionSteps);
|
|
769
|
+
} else {
|
|
770
|
+
failures++;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const target = Math.min(codeBlocks.length, this.verifyAttempts);
|
|
774
|
+
const majorityNeeded = Math.floor(target / 2) + 1;
|
|
775
|
+
if (successfulCodes.length >= majorityNeeded || failures > target - majorityNeeded) {
|
|
776
|
+
stop();
|
|
777
|
+
}
|
|
733
778
|
},
|
|
734
|
-
|
|
735
|
-
|
|
779
|
+
{
|
|
780
|
+
maxAttempts: this.verifyAttempts,
|
|
781
|
+
observability: {
|
|
782
|
+
agent: 'navigator',
|
|
783
|
+
},
|
|
784
|
+
catch: async (error) => {
|
|
785
|
+
debugLog(error);
|
|
786
|
+
},
|
|
787
|
+
}
|
|
788
|
+
);
|
|
789
|
+
} finally {
|
|
790
|
+
page?.setDefaultTimeout(originalTimeout);
|
|
791
|
+
}
|
|
736
792
|
|
|
737
|
-
const totalAttempted = Math.min(codeBlocks.length, this.
|
|
738
|
-
const
|
|
793
|
+
const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
|
|
794
|
+
const majorityNeeded = Math.floor(totalAttempted / 2) + 1;
|
|
795
|
+
let verified = successfulCodes.length >= majorityNeeded;
|
|
796
|
+
if (alreadyVerified) verified = true;
|
|
739
797
|
|
|
740
798
|
actionResult.addVerification(message, verified);
|
|
741
799
|
this.explorer.getStateManager().updateState(actionResult);
|
|
742
800
|
|
|
743
801
|
return { verified, successfulCodes, assertionSteps, totalAttempted };
|
|
744
802
|
}
|
|
803
|
+
|
|
804
|
+
private checkAlreadyVerified(aiResponse: string, actionResult: ActionResult): boolean {
|
|
805
|
+
const verifiedMatch = aiResponse.match(/ALREADY_VERIFIED:\s*(.+)/i);
|
|
806
|
+
if (!verifiedMatch) return false;
|
|
807
|
+
const claim = verifiedMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
808
|
+
return actionResult.getVerification(claim) === true;
|
|
809
|
+
}
|
|
745
810
|
}
|
|
746
811
|
|
|
747
812
|
export { Navigator };
|
package/src/ai/pilot.ts
CHANGED
|
@@ -322,6 +322,12 @@ export class Pilot implements Agent {
|
|
|
322
322
|
- "Delete X" → X must be gone. Clicking delete is NOT enough.
|
|
323
323
|
- "Edit X" → updated value must be persisted (visible in list/detail). Opening edit is NOT enough; redirect after save with the new value visible IS enough.
|
|
324
324
|
- Negative tests ("without a name", "invalid", "duplicate", "unauthorized") → success means the system PREVENTED the action with validation/error.
|
|
325
|
+
- Navigation-prefixed titles ("Access/Open/Go to X to <do Y>") → the goal is <do Y>; reaching X is
|
|
326
|
+
only a milestone. A satisfied milestone (tab active, panel/prompt visible, list shown) is NEVER a pass
|
|
327
|
+
if <do Y> did not occur this run.
|
|
328
|
+
- If the page reveals the goal cannot be performed here — required control absent, integration not
|
|
329
|
+
connected, or only a setup/connect/empty-state prompt is shown — vote "skipped" (prerequisites unmet),
|
|
330
|
+
never "pass".
|
|
325
331
|
|
|
326
332
|
PROVENANCE: the entity you cite as proof must appear by name in <notes> or
|
|
327
333
|
<session_log> tool inputs for THIS run. Name absent from tester activity = stale
|
|
@@ -433,14 +439,14 @@ export class Pilot implements Agent {
|
|
|
433
439
|
the elements needed for the scenario. The page summary does not list every element.
|
|
434
440
|
Prefer interacting with the current page over navigating away.
|
|
435
441
|
|
|
436
|
-
If you load a recipe via
|
|
442
|
+
If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
|
|
437
443
|
raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
|
|
438
444
|
steps 1–3, then…") and call out anywhere your scenario diverges from it.
|
|
439
445
|
|
|
440
446
|
Be concise and specific. Tester will follow your plan.
|
|
441
447
|
`,
|
|
442
448
|
'pilot.planTest',
|
|
443
|
-
{ tools: true,
|
|
449
|
+
{ tools: true, maxToolRoundtrips: 3, task }
|
|
444
450
|
);
|
|
445
451
|
}
|
|
446
452
|
|
|
@@ -541,7 +547,7 @@ export class Pilot implements Agent {
|
|
|
541
547
|
return `CHECKED: ${checked.length > 0 ? checked.join(', ') : 'none'}\nREMAINING: ${remaining.length > 0 ? remaining.join(', ') : 'none'}`;
|
|
542
548
|
}
|
|
543
549
|
|
|
544
|
-
private async sendToPilot(userText: string, functionId: string, opts: { tools?: boolean;
|
|
550
|
+
private async sendToPilot(userText: string, functionId: string, opts: { tools?: boolean; maxToolRoundtrips?: number; task?: Test } = {}): Promise<string> {
|
|
545
551
|
debugLog(`sendToPilot: ${functionId}, tools: ${!!opts.tools}, roundtrips: ${opts.maxToolRoundtrips ?? 0}`);
|
|
546
552
|
|
|
547
553
|
let finalUserText = userText;
|
|
@@ -554,7 +560,7 @@ export class Pilot implements Agent {
|
|
|
554
560
|
this.conversation!.addUserText(finalUserText);
|
|
555
561
|
let tools: any;
|
|
556
562
|
if (opts.tools) {
|
|
557
|
-
tools =
|
|
563
|
+
tools = this.pickPlanningTools();
|
|
558
564
|
}
|
|
559
565
|
|
|
560
566
|
if (opts.tools && opts.task) {
|
|
@@ -568,7 +574,7 @@ export class Pilot implements Agent {
|
|
|
568
574
|
experimental_telemetry: { functionId },
|
|
569
575
|
});
|
|
570
576
|
const text = result?.response?.text || '';
|
|
571
|
-
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === '
|
|
577
|
+
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => e.output.content);
|
|
572
578
|
if (learned.length === 0) return text;
|
|
573
579
|
return dedent`
|
|
574
580
|
${text}
|
|
@@ -592,7 +598,7 @@ export class Pilot implements Agent {
|
|
|
592
598
|
}
|
|
593
599
|
|
|
594
600
|
private pickPlanningTools() {
|
|
595
|
-
const { see, context, verify, research, getVisitedStates, xpathCheck,
|
|
601
|
+
const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser } = this.agentTools ?? {};
|
|
596
602
|
const planning: Record<string, unknown> = {};
|
|
597
603
|
if (see) planning.see = see;
|
|
598
604
|
if (context) planning.context = context;
|
|
@@ -600,7 +606,8 @@ export class Pilot implements Agent {
|
|
|
600
606
|
if (research) planning.research = research;
|
|
601
607
|
if (getVisitedStates) planning.getVisitedStates = getVisitedStates;
|
|
602
608
|
if (xpathCheck) planning.xpathCheck = xpathCheck;
|
|
603
|
-
if (
|
|
609
|
+
if (learnExperience) planning.learnExperience = learnExperience;
|
|
610
|
+
if (askUser) planning.askUser = askUser;
|
|
604
611
|
return planning;
|
|
605
612
|
}
|
|
606
613
|
|
package/src/ai/tools.ts
CHANGED
|
@@ -466,12 +466,14 @@ export function createAgentTools({
|
|
|
466
466
|
navigator,
|
|
467
467
|
experienceTracker,
|
|
468
468
|
getState,
|
|
469
|
+
supervisor,
|
|
469
470
|
}: {
|
|
470
471
|
explorer: Explorer;
|
|
471
472
|
researcher: Researcher;
|
|
472
473
|
navigator: Navigator;
|
|
473
474
|
experienceTracker?: ExperienceTracker;
|
|
474
475
|
getState?: () => ActionResult | null;
|
|
476
|
+
supervisor?: boolean;
|
|
475
477
|
}): any {
|
|
476
478
|
let visionDisabled = false;
|
|
477
479
|
|
|
@@ -803,46 +805,6 @@ export function createAgentTools({
|
|
|
803
805
|
},
|
|
804
806
|
}),
|
|
805
807
|
|
|
806
|
-
askUser: tool({
|
|
807
|
-
description: dedent`
|
|
808
|
-
Ask the user for help when you're stuck or unsure how to proceed.
|
|
809
|
-
Only available in interactive mode (TUI).
|
|
810
|
-
|
|
811
|
-
Use when:
|
|
812
|
-
- Locator-based clicks keep failing
|
|
813
|
-
- You can't find an element that should exist
|
|
814
|
-
- Form interaction isn't working as expected
|
|
815
|
-
- You need clarification on what action to take
|
|
816
|
-
`,
|
|
817
|
-
inputSchema: z.object({
|
|
818
|
-
question: z.string().describe('What you need help with - be specific about what failed'),
|
|
819
|
-
context: z.string().optional().describe('Relevant context like locators tried, errors received'),
|
|
820
|
-
}),
|
|
821
|
-
execute: async ({ question, context }) => {
|
|
822
|
-
if (!isInteractive()) {
|
|
823
|
-
return {
|
|
824
|
-
success: false,
|
|
825
|
-
message: 'User input not available in non-interactive mode',
|
|
826
|
-
suggestion: 'Continue with automated recovery',
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
const prompt = context ? `${question}\n\nContext: ${context}\n\nYour suggestion ("skip" to continue):` : `${question}\n\nYour suggestion ("skip" to continue):`;
|
|
831
|
-
|
|
832
|
-
const userInput = await pause(prompt);
|
|
833
|
-
|
|
834
|
-
if (!userInput || userInput.toLowerCase() === 'skip') {
|
|
835
|
-
return { success: false, message: 'User skipped' };
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
return {
|
|
839
|
-
success: true,
|
|
840
|
-
userSuggestion: userInput,
|
|
841
|
-
instruction: 'Follow the user suggestion. Use interact() tool to execute.',
|
|
842
|
-
};
|
|
843
|
-
},
|
|
844
|
-
}),
|
|
845
|
-
|
|
846
808
|
back: tool({
|
|
847
809
|
description: dedent`
|
|
848
810
|
Navigate back to the previous page (most recent URL different from current).
|
|
@@ -973,7 +935,7 @@ export function createAgentTools({
|
|
|
973
935
|
};
|
|
974
936
|
|
|
975
937
|
if (experienceTracker && getState) {
|
|
976
|
-
tools.
|
|
938
|
+
tools.learnExperience = tool({
|
|
977
939
|
description: dedent`
|
|
978
940
|
Read the full body of a specific experience section listed in <experience>.
|
|
979
941
|
The TOC shows entries like "A.1 ## FLOW: ..." or "A.2 ## ACTION: ...". Pass the fileTag and sectionIndex.
|
|
@@ -997,6 +959,48 @@ export function createAgentTools({
|
|
|
997
959
|
});
|
|
998
960
|
}
|
|
999
961
|
|
|
962
|
+
if (supervisor) {
|
|
963
|
+
tools.askUser = tool({
|
|
964
|
+
description: dedent`
|
|
965
|
+
Ask the user for help when automated recovery is stuck or the next step is unclear.
|
|
966
|
+
Only available in interactive mode (TUI).
|
|
967
|
+
|
|
968
|
+
Use when:
|
|
969
|
+
- The Tester keeps failing the same locator/element
|
|
970
|
+
- An element that should exist cannot be found
|
|
971
|
+
- Form interaction isn't working as expected
|
|
972
|
+
- You need a human decision on how to proceed
|
|
973
|
+
`,
|
|
974
|
+
inputSchema: z.object({
|
|
975
|
+
question: z.string().describe('What you need help with - be specific about what failed'),
|
|
976
|
+
context: z.string().optional().describe('Relevant context like locators tried, errors received'),
|
|
977
|
+
}),
|
|
978
|
+
execute: async ({ question, context }) => {
|
|
979
|
+
if (!isInteractive()) {
|
|
980
|
+
return {
|
|
981
|
+
success: false,
|
|
982
|
+
message: 'User input not available in non-interactive mode',
|
|
983
|
+
suggestion: 'Continue with automated recovery',
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const prompt = context ? `${question}\n\nContext: ${context}\n\nYour suggestion ("skip" to continue):` : `${question}\n\nYour suggestion ("skip" to continue):`;
|
|
988
|
+
|
|
989
|
+
const userInput = await pause(prompt);
|
|
990
|
+
|
|
991
|
+
if (!userInput || userInput.toLowerCase() === 'skip') {
|
|
992
|
+
return { success: false, message: 'User skipped' };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return {
|
|
996
|
+
success: true,
|
|
997
|
+
userSuggestion: userInput,
|
|
998
|
+
instruction: 'Relay this suggestion to the Tester as the next concrete step.',
|
|
999
|
+
};
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1000
1004
|
return tools;
|
|
1001
1005
|
}
|
|
1002
1006
|
|
|
@@ -30,6 +30,15 @@ const config = {
|
|
|
30
30
|
// agentic model for decision making
|
|
31
31
|
agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
|
|
32
32
|
},
|
|
33
|
+
|
|
34
|
+
reporter: {
|
|
35
|
+
// Save a local HTML report after each run.
|
|
36
|
+
html: true,
|
|
37
|
+
// Save a local markdown report after each run.
|
|
38
|
+
markdown: true,
|
|
39
|
+
// Group runs by title in Testomat.io / HTML reports. Defaults to today's date — customize or remove.
|
|
40
|
+
runGroup: new Date().toISOString().slice(0, 10),
|
|
41
|
+
},
|
|
33
42
|
};
|
|
34
43
|
|
|
35
44
|
export default config;
|
package/src/config.ts
CHANGED
|
@@ -84,6 +84,8 @@ interface PilotAgentConfig extends AgentConfig {
|
|
|
84
84
|
interface NavigatorAgentConfig extends AgentConfig {
|
|
85
85
|
addHtmlOnTry?: number;
|
|
86
86
|
maxAttempts?: number;
|
|
87
|
+
verifyAttempts?: number;
|
|
88
|
+
verifyTimeout?: number;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
type HealFn = (ctx: { I: any }) => Promise<void> | void;
|
|
@@ -499,7 +499,7 @@ export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
|
|
|
499
499
|
lines.push('Locators and step ordering worked then; the page may have changed since.');
|
|
500
500
|
lines.push('Treat as a starting hypothesis, not ground truth. If a step fails, fall back to ARIA/UI-map.');
|
|
501
501
|
lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
|
|
502
|
-
lines.push('Call
|
|
502
|
+
lines.push('Call learnExperience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
|
|
503
503
|
lines.push('');
|
|
504
504
|
for (const entry of toc) {
|
|
505
505
|
lines.push(`File ${entry.fileTag} ${entry.url}:`);
|
package/src/explorbot.ts
CHANGED
|
@@ -210,7 +210,7 @@ export class ExplorBot {
|
|
|
210
210
|
const state = stateManager.getCurrentState();
|
|
211
211
|
return state ? ActionResult.fromState(state) : null;
|
|
212
212
|
};
|
|
213
|
-
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
|
|
213
|
+
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, supervisor: true });
|
|
214
214
|
return new Pilot(ai, tools, researcher, explorer, experienceTracker);
|
|
215
215
|
}));
|
|
216
216
|
}
|
package/src/reporter.ts
CHANGED
|
@@ -29,7 +29,7 @@ export class Reporter {
|
|
|
29
29
|
this.reporterEnabled = Reporter.resolveEnabled(config);
|
|
30
30
|
this.stateManager = stateManager;
|
|
31
31
|
|
|
32
|
-
if (this.reporterEnabled &&
|
|
32
|
+
if (this.reporterEnabled && config?.html) {
|
|
33
33
|
this.configureHtmlPipe();
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -63,6 +63,7 @@ export class Reporter {
|
|
|
63
63
|
static resolveEnabled(config?: ReporterConfig): boolean {
|
|
64
64
|
if (config?.enabled === true) return true;
|
|
65
65
|
if (config?.enabled === false) return false;
|
|
66
|
+
if (config?.html || config?.markdown) return true;
|
|
66
67
|
return Boolean(process.env.TESTOMATIO);
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -88,12 +89,8 @@ export class Reporter {
|
|
|
88
89
|
|
|
89
90
|
private configureRunGroup(runGroup: string | null | undefined): void {
|
|
90
91
|
if (process.env.TESTOMATIO_RUNGROUP_TITLE) return;
|
|
91
|
-
if (runGroup
|
|
92
|
-
|
|
93
|
-
process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
|
|
92
|
+
if (!runGroup) return;
|
|
93
|
+
process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
|
|
97
94
|
}
|
|
98
95
|
|
|
99
96
|
async startRun(): Promise<void> {
|