explorbot 0.1.4 → 0.1.6
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/rules/planner/styles/curious.md +18 -5
- package/dist/rules/planner/styles/normal.md +4 -4
- package/dist/rules/planner/styles/psycho.md +14 -11
- package/dist/rules/researcher/container-rules.md +15 -0
- package/dist/rules/researcher/section-example.md +12 -0
- package/dist/src/ai/captain/web-mode.js +9 -1
- package/dist/src/ai/historian.js +6 -0
- package/dist/src/ai/pilot.js +23 -2
- package/dist/src/ai/researcher/cache.js +6 -0
- package/dist/src/ai/researcher/deep-analysis.js +65 -9
- package/dist/src/ai/researcher/sections.js +103 -0
- package/dist/src/ai/researcher.js +30 -103
- package/dist/src/ai/task-agent.js +7 -27
- package/dist/src/ai/tester.js +19 -4
- package/dist/src/ai/tools.js +27 -2
- package/dist/src/commands/explore-command.js +4 -9
- package/dist/src/experience-tracker.js +126 -1
- package/dist/src/explorbot.js +9 -2
- package/dist/src/explorer.js +12 -1
- package/dist/src/utils/aria.js +39 -2
- package/package.json +1 -1
- package/rules/planner/styles/curious.md +18 -5
- package/rules/planner/styles/normal.md +4 -4
- package/rules/planner/styles/psycho.md +14 -11
- package/rules/researcher/container-rules.md +15 -0
- package/rules/researcher/section-example.md +12 -0
- package/src/ai/captain/web-mode.ts +9 -1
- package/src/ai/historian.ts +7 -0
- package/src/ai/pilot.ts +23 -3
- package/src/ai/researcher/cache.ts +5 -0
- package/src/ai/researcher/deep-analysis.ts +74 -9
- package/src/ai/researcher/sections.ts +122 -0
- package/src/ai/researcher.ts +31 -105
- package/src/ai/task-agent.ts +7 -31
- package/src/ai/tester.ts +20 -4
- package/src/ai/tools.ts +33 -1
- package/src/commands/explore-command.ts +4 -9
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +136 -1
- package/src/explorbot.ts +9 -2
- package/src/explorer.ts +10 -1
- package/src/utils/aria.ts +40 -2
package/src/ai/researcher.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { detectFocusFromAria, hasFocusedSection, markSectionAsFocused, pickDefau
|
|
|
28
28
|
import { type LocatorMethods, WithLocators } from './researcher/locators.ts';
|
|
29
29
|
import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts';
|
|
30
30
|
import { ResearchResult } from './researcher/research-result.ts';
|
|
31
|
+
import { type SectionMethods, WithSections } from './researcher/sections.ts';
|
|
31
32
|
import { locatorRule as generalLocatorRuleText } from './rules.js';
|
|
32
33
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
33
34
|
import { TaskAgent } from './task-agent.ts';
|
|
@@ -46,9 +47,9 @@ export const POSSIBLE_SECTIONS = {
|
|
|
46
47
|
navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
|
|
47
48
|
};
|
|
48
49
|
|
|
49
|
-
const ResearcherBase = WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent)));
|
|
50
|
+
const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent))));
|
|
50
51
|
|
|
51
|
-
export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods {}
|
|
52
|
+
export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods {}
|
|
52
53
|
|
|
53
54
|
export class Researcher extends ResearcherBase implements Agent {
|
|
54
55
|
protected readonly ACTION_TOOLS = ['click'];
|
|
@@ -98,15 +99,11 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
98
99
|
You are senior QA focused on exploritary testig of web application.
|
|
99
100
|
</role>
|
|
100
101
|
|
|
101
|
-
<wording>
|
|
102
|
-
In the UI map and all descriptions, name concrete UI parts (visible labels, headings, regions, ARIA roles). Do not use vague placeholders like "the page", "the element", "the button", "the input", "the link", "the form", "the table", "the list", or "the item". Do not use filler such as "comprehensive", "All required", "All elements", or "All necessary".
|
|
103
|
-
</wording>
|
|
104
|
-
|
|
105
102
|
${customPrompt || ''}
|
|
106
103
|
`;
|
|
107
104
|
}
|
|
108
105
|
|
|
109
|
-
async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number
|
|
106
|
+
async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number } = {}): Promise<string> {
|
|
110
107
|
const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
|
|
111
108
|
const maxRetries = (this.explorer.getConfig().ai?.agents?.researcher as any)?.retries ?? 2;
|
|
112
109
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
@@ -174,9 +171,12 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
174
171
|
const prompt = await this.buildResearchPrompt();
|
|
175
172
|
conversation.addUserText(prompt);
|
|
176
173
|
|
|
177
|
-
let
|
|
174
|
+
let researchText: string;
|
|
175
|
+
let activeConversation = conversation;
|
|
178
176
|
try {
|
|
179
|
-
invocationResult = await this.provider.invokeConversation(conversation, undefined, { agentName: 'researcher' });
|
|
177
|
+
const invocationResult = await this.provider.invokeConversation(conversation, undefined, { agentName: 'researcher' });
|
|
178
|
+
if (!invocationResult) throw new Error('Failed to get response from provider');
|
|
179
|
+
researchText = invocationResult.response.text;
|
|
180
180
|
} catch (error) {
|
|
181
181
|
if (!(error instanceof ContextLengthError) || retriesLeft <= 0) {
|
|
182
182
|
if (error instanceof ContextLengthError) {
|
|
@@ -184,30 +184,14 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
184
184
|
}
|
|
185
185
|
throw error;
|
|
186
186
|
}
|
|
187
|
-
tag('warning').log('Output truncated, retrying with focused instructions...');
|
|
188
187
|
retriesLeft = 0;
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
researchText = await this.researchBySections();
|
|
189
|
+
activeConversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
|
|
191
190
|
}
|
|
192
|
-
if (!invocationResult) throw new Error('Failed to get response from provider');
|
|
193
191
|
|
|
194
|
-
const result = new ResearchResult(
|
|
192
|
+
const result = new ResearchResult(researchText, state.url);
|
|
195
193
|
debugLog(`Original research response length: ${result.text.length} chars`);
|
|
196
194
|
|
|
197
|
-
const errorSection = mdq(result.text).query('section("Error Page Detected")');
|
|
198
|
-
if (errorSection.count() > 0) {
|
|
199
|
-
if (result.text.length < 500) {
|
|
200
|
-
if (!opts._skipErrorPageRetry && (await this.waitForPageLoad(screenshot))) {
|
|
201
|
-
return this.research(state, { ...opts, force: true, _skipErrorPageRetry: true });
|
|
202
|
-
}
|
|
203
|
-
tag('warning').log(`AI detected error page at ${state.url}`);
|
|
204
|
-
if (stateHash) saveResearch(stateHash, result.text);
|
|
205
|
-
await this.hooksRunner.runAfterHook('researcher', state.url);
|
|
206
|
-
return result.text;
|
|
207
|
-
}
|
|
208
|
-
result.text = errorSection.replace('');
|
|
209
|
-
}
|
|
210
|
-
|
|
211
195
|
const interrupted = () => executionController.isInterrupted();
|
|
212
196
|
|
|
213
197
|
// Stage 2: Test containers + locators
|
|
@@ -251,7 +235,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
251
235
|
|
|
252
236
|
// Stage 3: Fix broken sections via AI conversation continuation
|
|
253
237
|
if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
|
|
254
|
-
await this.fixBrokenSections(result,
|
|
238
|
+
await this.fixBrokenSections(result, activeConversation);
|
|
255
239
|
}
|
|
256
240
|
|
|
257
241
|
// Focused section: parse AI declaration, then ARIA fallback
|
|
@@ -417,32 +401,29 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
417
401
|
|
|
418
402
|
private researchRules(): string {
|
|
419
403
|
const sections = this.getConfiguredSections();
|
|
404
|
+
const currentUrl = this.stateManager.getCurrentState()?.url || '';
|
|
420
405
|
return dedent`
|
|
421
406
|
<task>
|
|
422
|
-
Examine the
|
|
423
|
-
Identify the
|
|
424
|
-
|
|
425
|
-
Provide a comprehensive UI map report in markdown format.
|
|
407
|
+
Examine the page and explain its main purpose from the user perspective.
|
|
408
|
+
Identify the primary user actions and break the page into sections.
|
|
409
|
+
Provide a UI map report in markdown.
|
|
426
410
|
</task>
|
|
427
411
|
|
|
428
412
|
<rules>
|
|
429
413
|
- Explain what the user can achieve on this page.
|
|
430
414
|
- Focus on primary user actions and interactive elements only.
|
|
431
415
|
- Research all menus and navigational areas.
|
|
432
|
-
- Ignore
|
|
416
|
+
- Ignore decorative sidebars, footer-only links, and external links.
|
|
433
417
|
- Detect layout patterns: list/detail split, 2-pane, or 3-pane layouts.
|
|
434
|
-
-
|
|
435
|
-
- UI map
|
|
436
|
-
-
|
|
437
|
-
-
|
|
438
|
-
- NEVER skip elements that have an eidx attribute. Every element with eidx MUST appear in the UI map table, even if it has no text or accessible name. Describe icon-only elements using their SVG class (e.g., md-icon-dots-horizontal → "More actions (ellipsis)") or their visual appearance.
|
|
439
|
-
- ARIA locator must be JSON with role and text keys (NOT "name").
|
|
440
|
-
- Note elements likely to have hover interactions (elements with title attribute, aria-describedby, navigation menu items with submenus) and mark them with "(hover)" in the UI map.
|
|
418
|
+
- Every element with an eidx attribute MUST appear in the UI map — describe icon-only buttons by their visual role.
|
|
419
|
+
- Every UI map row needs a CSS selector; ARIA may be "-" for icon-only buttons, CSS must never be "-".
|
|
420
|
+
- ARIA locator JSON uses keys "role" and "text" (NOT "name").
|
|
421
|
+
- Mark elements with likely hover interactions (title, aria-describedby, menu items with submenus) as "(hover)".
|
|
441
422
|
</rules>
|
|
442
423
|
|
|
443
424
|
${generalLocatorRuleText}
|
|
444
425
|
|
|
445
|
-
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element'
|
|
426
|
+
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
|
|
446
427
|
|
|
447
428
|
<section_identification>
|
|
448
429
|
Identify page sections in this priority order:
|
|
@@ -450,23 +431,12 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
450
431
|
.map(([name, description]) => `* ${name}: ${description}`)
|
|
451
432
|
.join('\n')}
|
|
452
433
|
|
|
453
|
-
- Sections can overlap
|
|
454
|
-
- Never name a section "Focus" or "Focused" —
|
|
455
|
-
-
|
|
456
|
-
- Each section
|
|
457
|
-
- UI map CSS locators must be relative to the section container.
|
|
434
|
+
- Sections can overlap; prefer more detailed sections over broader ones.
|
|
435
|
+
- Never name a section "Focus" or "Focused" — use what it contains (Detail, Modal, Form, Content, List).
|
|
436
|
+
- Omit sections that are not present or not relevant.
|
|
437
|
+
- Each section needs a container CSS locator; UI map CSS locators are relative to it.
|
|
458
438
|
</section_identification>
|
|
459
439
|
|
|
460
|
-
<container_rules>
|
|
461
|
-
CRITICAL: Container CSS must be a SINGLE selector — one class, one ID, or one attribute.
|
|
462
|
-
No spaces, no >, no combinators, no nesting.
|
|
463
|
-
- INVALID: '.filterbar-filter-btn-div button', 'div.static nav', 'div > .content'
|
|
464
|
-
- INVALID: 'div', 'section', 'nav', 'div:first' (bare tags are not containers)
|
|
465
|
-
- INVALID: Tailwind/Bootstrap utility classes that describe layout or styling (e.g. flex-none, d-flex, col-md-6, items-center, mt-4, p-2, bg-white, text-sm, rounded-lg). These are visual, not semantic.
|
|
466
|
-
- VALID: Semantic class names that describe WHAT the section IS — e.g. '.product-list', '.sidebar-menu', '.user-profile', '[role="dialog"]', '.search-results'
|
|
467
|
-
Container must uniquely identify a semantic wrapper, not a path through the DOM.
|
|
468
|
-
</container_rules>
|
|
469
|
-
|
|
470
440
|
<section_format>
|
|
471
441
|
## Section Name
|
|
472
442
|
|
|
@@ -476,40 +446,16 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
476
446
|
|
|
477
447
|
| Element | ARIA | CSS | eidx |
|
|
478
448
|
</section_format>
|
|
479
|
-
<section_example>
|
|
480
|
-
## List
|
|
481
|
-
|
|
482
|
-
Product catalog showing available items with sorting and filtering.
|
|
483
|
-
|
|
484
|
-
> Container: '.product-list'
|
|
485
|
-
|
|
486
|
-
| Element | ARIA | CSS | eidx |
|
|
487
|
-
| 'Sort by price' | { role: 'button', text: 'Sort by price' } | '.sort-btn' | 3 |
|
|
488
|
-
| 'Add to cart' | { role: 'button', text: 'Add to cart' } | '.add-btn' | 4 |
|
|
489
|
-
| 'Product name' | { role: 'link', text: 'Widget Pro' } | 'a.product-link' | 5 |
|
|
490
|
-
</section_example>
|
|
491
449
|
|
|
492
450
|
<focused_section>
|
|
493
|
-
At the
|
|
451
|
+
At the end of your output, declare the primary focus area on a single line:
|
|
494
452
|
|
|
495
453
|
> Focused: <exact section name>
|
|
496
454
|
|
|
497
|
-
|
|
498
|
-
-
|
|
499
|
-
-
|
|
500
|
-
- Navigation is NEVER focused — it exists on every page
|
|
501
|
-
- Menu/toolbar is NEVER focused — it contains actions, not the main content
|
|
502
|
-
- The focused section is the one the user came to this page to interact with
|
|
455
|
+
- If a dialog/modal/drawer/overlay exists, it is focused.
|
|
456
|
+
- Otherwise pick the section where the main business action happens (list for catalog, detail for item page, content for article).
|
|
457
|
+
- Navigation and menu/toolbar are never focused.
|
|
503
458
|
</focused_section>
|
|
504
|
-
|
|
505
|
-
<css_selector_rules>
|
|
506
|
-
CSS selectors MUST point to the actual interactive element (input, button, a, select), NOT to container divs.
|
|
507
|
-
- If a submit button is inside a wrapper div, target the input/button directly
|
|
508
|
-
- Bad: '#submit-wrapper' (div container)
|
|
509
|
-
- Good: '#submit-wrapper input[type="submit"]' or 'input[type="submit"][value="Submit"]'
|
|
510
|
-
- For buttons with similar text, include distinguishing attributes like type, value, or form context
|
|
511
|
-
|
|
512
|
-
</css_selector_rules>
|
|
513
459
|
`;
|
|
514
460
|
}
|
|
515
461
|
|
|
@@ -540,17 +486,6 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
540
486
|
return dedent`
|
|
541
487
|
Analyze this web page and provide a comprehensive research report in markdown format.
|
|
542
488
|
|
|
543
|
-
<error_detection>
|
|
544
|
-
IMPORTANT: First check if this looks like an error page (404, 500, access denied,
|
|
545
|
-
not found, server error, forbidden, or similar). If so, respond ONLY with:
|
|
546
|
-
|
|
547
|
-
## Error Page Detected
|
|
548
|
-
Type: [error type]
|
|
549
|
-
Reason: [what indicates this is an error page]
|
|
550
|
-
|
|
551
|
-
Then stop - do not provide normal research output for error pages.
|
|
552
|
-
</error_detection>
|
|
553
|
-
|
|
554
489
|
${this.researchRules()}
|
|
555
490
|
|
|
556
491
|
URL: ${this.actionResult.url || 'Unknown'}
|
|
@@ -602,15 +537,6 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
602
537
|
`;
|
|
603
538
|
}
|
|
604
539
|
|
|
605
|
-
private buildFocusedRetryPrompt(): string {
|
|
606
|
-
return dedent`
|
|
607
|
-
Your previous response was truncated and could not be parsed.
|
|
608
|
-
|
|
609
|
-
Please retry with a shorter output. Focus ONLY on the main interactive section of the page.
|
|
610
|
-
Skip navigation, sidebar, and footer sections. Output ONE section only with max 15 elements.
|
|
611
|
-
`;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
540
|
async textContent(state: WebPageState): Promise<string> {
|
|
615
541
|
const actionResult = ActionResult.fromState(state);
|
|
616
542
|
const html = await actionResult.combinedHtml();
|
package/src/ai/task-agent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import type { ActionResult } from '../action-result.js';
|
|
3
|
-
import type
|
|
3
|
+
import { renderExperienceToc, type ExperienceTracker } from '../experience-tracker.js';
|
|
4
4
|
import type { KnowledgeTracker } from '../knowledge-tracker.js';
|
|
5
5
|
import { createDebug, pluralize, tag } from '../utils/logger.js';
|
|
6
6
|
|
|
@@ -56,37 +56,13 @@ export abstract class TaskAgent {
|
|
|
56
56
|
|
|
57
57
|
protected getExperience(actionResult: ActionResult): string {
|
|
58
58
|
const tracker = this.getExperienceTracker();
|
|
59
|
-
const
|
|
59
|
+
const toc = tracker.getExperienceTableOfContents(actionResult);
|
|
60
|
+
if (toc.length === 0) return '';
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.filter((e) => !!e)
|
|
66
|
-
.join('\n\n---\n\n');
|
|
67
|
-
|
|
68
|
-
const totalChars = allContent.length;
|
|
69
|
-
let experienceContent: string;
|
|
70
|
-
|
|
71
|
-
if (totalChars <= 10_000) {
|
|
72
|
-
debugLog(`injecting all experience (${Math.round(totalChars / 1000)}k chars)`);
|
|
73
|
-
experienceContent = allContent;
|
|
74
|
-
} else {
|
|
75
|
-
experienceContent = tracker.getSuccessfulExperience(actionResult).join('\n\n---\n\n');
|
|
76
|
-
debugLog(`injecting success-only experience (${Math.round(experienceContent.length / 1000)}k chars, filtered from ${Math.round(totalChars / 1000)}k)`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (!experienceContent) return '';
|
|
80
|
-
|
|
81
|
-
tag('substep').log(`Found ${relevantExperience.length} experience ${pluralize(relevantExperience.length, 'file')}`);
|
|
82
|
-
return dedent`
|
|
83
|
-
<experience>
|
|
84
|
-
Here is past experience of interacting with this page.
|
|
85
|
-
Use successful solutions first. Avoid repeating failed actions.
|
|
86
|
-
|
|
87
|
-
${experienceContent}
|
|
88
|
-
</experience>
|
|
89
|
-
`;
|
|
62
|
+
const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
|
|
63
|
+
debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
|
|
64
|
+
tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
|
|
65
|
+
return renderExperienceToc(toc);
|
|
90
66
|
}
|
|
91
67
|
|
|
92
68
|
setHistorian(historian: Historian): void {
|
package/src/ai/tester.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type Explorer from '../explorer.ts';
|
|
|
11
11
|
import type { StateTransition, WebPageState } from '../state-manager.ts';
|
|
12
12
|
import { Stats } from '../stats.ts';
|
|
13
13
|
import { type Note, type Test, TestResult, type TestResultType } from '../test-plan.ts';
|
|
14
|
-
import { extractFocusedElement } from '../utils/aria.ts';
|
|
14
|
+
import { detectFocusArea, extractFocusedElement } from '../utils/aria.ts';
|
|
15
15
|
import { HooksRunner } from '../utils/hooks-runner.ts';
|
|
16
16
|
import { codeToMarkdown } from '../utils/html.ts';
|
|
17
17
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
@@ -59,6 +59,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
59
59
|
executionLogFile: string | null = null;
|
|
60
60
|
private previousUrl: string | null = null;
|
|
61
61
|
private previousStateHash: string | null = null;
|
|
62
|
+
private pageStateHash: string | null = null;
|
|
63
|
+
private pageActionResult: ActionResult | null = null;
|
|
62
64
|
private hooksRunner: HooksRunner;
|
|
63
65
|
|
|
64
66
|
constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) {
|
|
@@ -117,6 +119,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
117
119
|
|
|
118
120
|
this.previousUrl = null;
|
|
119
121
|
this.previousStateHash = null;
|
|
122
|
+
this.pageStateHash = null;
|
|
123
|
+
this.pageActionResult = null;
|
|
120
124
|
this.explorer.getStateManager().clearHistory();
|
|
121
125
|
this.resetFailureCount();
|
|
122
126
|
this.pilot?.reset();
|
|
@@ -441,7 +445,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
441
445
|
|
|
442
446
|
if (isNewUrl) {
|
|
443
447
|
const research = await this.researcher.research(currentState);
|
|
444
|
-
|
|
448
|
+
this.pageStateHash = currentStateHash;
|
|
449
|
+
this.pageActionResult = currentState;
|
|
445
450
|
let uiMapSection = '';
|
|
446
451
|
if (research) {
|
|
447
452
|
uiMapSection = dedent`
|
|
@@ -467,8 +472,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
467
472
|
</page_aria>
|
|
468
473
|
${uiMapSection}
|
|
469
474
|
|
|
470
|
-
${experience}
|
|
471
|
-
|
|
472
475
|
Use <page_ui_map> to understand the page structure and its main elements.
|
|
473
476
|
However, <page_ui_map> is not always up to date, use <page_aria> and <page_html> to understand the ACTUAL state of the page
|
|
474
477
|
Do not interact with elements that are not listed in <page_aria> and <page_html>
|
|
@@ -477,6 +480,19 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
477
480
|
return context;
|
|
478
481
|
}
|
|
479
482
|
|
|
483
|
+
const focusArea = detectFocusArea(currentState.ariaSnapshot);
|
|
484
|
+
if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
|
|
485
|
+
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
|
|
486
|
+
if (overlaySection) {
|
|
487
|
+
context += dedent`
|
|
488
|
+
|
|
489
|
+
<page_ui_map_overlay>
|
|
490
|
+
${overlaySection}
|
|
491
|
+
</page_ui_map_overlay>
|
|
492
|
+
`;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
480
496
|
// if (isStateChanged) {
|
|
481
497
|
// const combinedHtml = await currentState.combinedHtml();
|
|
482
498
|
// context += dedent`
|
package/src/ai/tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { tool } from 'ai';
|
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult, type ToolResultMetadata } from '../action-result.ts';
|
|
5
|
+
import type { ExperienceTracker } from '../experience-tracker.ts';
|
|
5
6
|
import type Explorer from '../explorer.ts';
|
|
6
7
|
import { type Task, TestResult } from '../test-plan.js';
|
|
7
8
|
import { extractFocusedElement } from '../utils/aria.ts';
|
|
@@ -449,14 +450,18 @@ export function createAgentTools({
|
|
|
449
450
|
explorer,
|
|
450
451
|
researcher,
|
|
451
452
|
navigator,
|
|
453
|
+
experienceTracker,
|
|
454
|
+
getState,
|
|
452
455
|
}: {
|
|
453
456
|
explorer: Explorer;
|
|
454
457
|
researcher: Researcher;
|
|
455
458
|
navigator: Navigator;
|
|
459
|
+
experienceTracker?: ExperienceTracker;
|
|
460
|
+
getState?: () => ActionResult | null;
|
|
456
461
|
}): any {
|
|
457
462
|
let visionDisabled = false;
|
|
458
463
|
|
|
459
|
-
|
|
464
|
+
const tools: Record<string, any> = {
|
|
460
465
|
see: tool({
|
|
461
466
|
description: dedent`
|
|
462
467
|
Check the page contents based on current page state and screenshot.
|
|
@@ -947,6 +952,33 @@ export function createAgentTools({
|
|
|
947
952
|
},
|
|
948
953
|
}),
|
|
949
954
|
};
|
|
955
|
+
|
|
956
|
+
if (experienceTracker && getState) {
|
|
957
|
+
tools.learn_experience = tool({
|
|
958
|
+
description: dedent`
|
|
959
|
+
Read the full body of a specific experience section listed in <experience>.
|
|
960
|
+
The TOC shows entries like "A.1 ## Successful Flow: ...". Pass the fileTag and sectionIndex.
|
|
961
|
+
Only call when a TOC entry looks directly relevant to the current step.
|
|
962
|
+
`,
|
|
963
|
+
inputSchema: z.object({
|
|
964
|
+
fileTag: z.string().describe('File tag from the TOC, e.g. "A", "B", "AA"'),
|
|
965
|
+
sectionIndex: z.number().int().positive().describe('1-based section index within that file'),
|
|
966
|
+
}),
|
|
967
|
+
execute: async ({ fileTag, sectionIndex }) => {
|
|
968
|
+
const state = getState();
|
|
969
|
+
if (!state) {
|
|
970
|
+
return { error: 'No current page state available.' };
|
|
971
|
+
}
|
|
972
|
+
const section = experienceTracker.getExperienceSection(fileTag, sectionIndex, state);
|
|
973
|
+
if (!section) {
|
|
974
|
+
return { error: 'Section not found. Experience may have been updated; re-read the latest TOC.' };
|
|
975
|
+
}
|
|
976
|
+
return section;
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return tools;
|
|
950
982
|
}
|
|
951
983
|
|
|
952
984
|
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.';
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
2
1
|
import figureSet from 'figures';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { getStyles } from '../ai/planner/styles.js';
|
|
5
|
-
import { ConfigParser } from '../config.ts';
|
|
6
4
|
import { getCliName } from '../utils/cli-name.ts';
|
|
7
5
|
import type { Plan } from '../test-plan.js';
|
|
8
6
|
import { jsonToTable } from '../utils/markdown-parser.js';
|
|
@@ -116,14 +114,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
116
114
|
}
|
|
117
115
|
|
|
118
116
|
private printRerunSuggestions(): void {
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
117
|
+
const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
|
|
118
|
+
if (savedFiles.length === 0) return;
|
|
121
119
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
for (const file of testFiles) {
|
|
126
|
-
tag('info').log(`Generated: ${file}`);
|
|
120
|
+
for (const filePath of savedFiles) {
|
|
121
|
+
tag('info').log(`Generated: ${path.basename(filePath)}`);
|
|
127
122
|
}
|
|
128
123
|
tag('info').log(`List tests: ${getCliName()} runs`);
|
|
129
124
|
tag('info').log(`Re-run with healing: ${getCliName()} rerun <filename> [index]`);
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
|
+
import { marked, type Tokens } from 'marked';
|
|
4
5
|
import type { ActionResult } from './action-result.js';
|
|
5
6
|
import { ConfigParser } from './config.js';
|
|
6
7
|
import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
@@ -332,6 +333,133 @@ ${filteredCode}
|
|
|
332
333
|
|
|
333
334
|
return results;
|
|
334
335
|
}
|
|
336
|
+
|
|
337
|
+
getExperienceTableOfContents(state: ActionResult, options?: { includeDescendantExperience?: boolean }): ExperienceTocEntry[] {
|
|
338
|
+
const records = this.getRelevantExperience(state, options);
|
|
339
|
+
if (records.length === 0) return [];
|
|
340
|
+
|
|
341
|
+
const sorted = [...records].sort((a, b) => {
|
|
342
|
+
const aHash = basename(a.filePath, '.md');
|
|
343
|
+
const bHash = basename(b.filePath, '.md');
|
|
344
|
+
return aHash.localeCompare(bHash);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const toc: ExperienceTocEntry[] = [];
|
|
348
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
349
|
+
const record = sorted[i];
|
|
350
|
+
const fileHash = basename(record.filePath, '.md');
|
|
351
|
+
const url = (record.data as WebPageState)?.url || '';
|
|
352
|
+
const sections = listTocHeadings(record.content);
|
|
353
|
+
if (sections.length === 0) continue;
|
|
354
|
+
toc.push({
|
|
355
|
+
fileTag: indexToLetters(i),
|
|
356
|
+
fileHash,
|
|
357
|
+
url,
|
|
358
|
+
sections,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return toc;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: { includeDescendantExperience?: boolean }): { title: string; url: string; content: string } | null {
|
|
365
|
+
const toc = this.getExperienceTableOfContents(state, options);
|
|
366
|
+
const entry = toc.find((e) => e.fileTag === fileTag);
|
|
367
|
+
if (!entry) return null;
|
|
368
|
+
|
|
369
|
+
const filePath = this.findExperienceFileByHash(entry.fileHash);
|
|
370
|
+
if (!filePath) return null;
|
|
371
|
+
|
|
372
|
+
const { content } = this.readExperienceFile(entry.fileHash);
|
|
373
|
+
const extracted = extractHeadingSection(content, sectionIndex);
|
|
374
|
+
if (!extracted) return null;
|
|
375
|
+
|
|
376
|
+
return { title: extracted.title, url: entry.url, content: extracted.body };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private findExperienceFileByHash(fileHash: string): string | null {
|
|
380
|
+
for (const dir of this.getExperienceDirectories()) {
|
|
381
|
+
const candidate = join(dir, `${fileHash}.md`);
|
|
382
|
+
if (existsSync(candidate)) return candidate;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function listTocHeadings(content: string): { index: number; level: 2 | 3; title: string }[] {
|
|
389
|
+
const tokens = marked.lexer(content);
|
|
390
|
+
const result: { index: number; level: 2 | 3; title: string }[] = [];
|
|
391
|
+
let index = 0;
|
|
392
|
+
for (const token of tokens) {
|
|
393
|
+
if (token.type !== 'heading') continue;
|
|
394
|
+
const heading = token as Tokens.Heading;
|
|
395
|
+
if (heading.depth !== 2 && heading.depth !== 3) continue;
|
|
396
|
+
index++;
|
|
397
|
+
result.push({ index, level: heading.depth as 2 | 3, title: heading.text });
|
|
398
|
+
}
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function extractHeadingSection(content: string, sectionIndex: number): { title: string; body: string } | null {
|
|
403
|
+
const tokens = marked.lexer(content);
|
|
404
|
+
const matching: { tokenIdx: number; depth: number; text: string }[] = [];
|
|
405
|
+
|
|
406
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
407
|
+
const token = tokens[i];
|
|
408
|
+
if (token.type !== 'heading') continue;
|
|
409
|
+
const heading = token as Tokens.Heading;
|
|
410
|
+
if (heading.depth !== 2 && heading.depth !== 3) continue;
|
|
411
|
+
matching.push({ tokenIdx: i, depth: heading.depth, text: heading.text });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (sectionIndex < 1 || sectionIndex > matching.length) return null;
|
|
415
|
+
|
|
416
|
+
const target = matching[sectionIndex - 1];
|
|
417
|
+
let endTokenIdx = tokens.length;
|
|
418
|
+
for (let j = target.tokenIdx + 1; j < tokens.length; j++) {
|
|
419
|
+
const token = tokens[j];
|
|
420
|
+
if (token.type !== 'heading') continue;
|
|
421
|
+
if ((token as Tokens.Heading).depth <= target.depth) {
|
|
422
|
+
endTokenIdx = j;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const body = tokens
|
|
428
|
+
.slice(target.tokenIdx, endTokenIdx)
|
|
429
|
+
.map((t) => (t as any).raw || '')
|
|
430
|
+
.join('');
|
|
431
|
+
return { title: target.text, body };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function indexToLetters(index: number): string {
|
|
435
|
+
let n = index;
|
|
436
|
+
let result = '';
|
|
437
|
+
while (true) {
|
|
438
|
+
result = String.fromCharCode(65 + (n % 26)) + result;
|
|
439
|
+
n = Math.floor(n / 26);
|
|
440
|
+
if (n === 0) break;
|
|
441
|
+
n -= 1;
|
|
442
|
+
}
|
|
443
|
+
return result;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
|
|
447
|
+
if (toc.length === 0) return '';
|
|
448
|
+
|
|
449
|
+
const lines: string[] = [];
|
|
450
|
+
lines.push('<experience>');
|
|
451
|
+
lines.push('Past experience for this page. Call learn_experience({ fileTag, sectionIndex }) to read a section.');
|
|
452
|
+
lines.push('');
|
|
453
|
+
for (const entry of toc) {
|
|
454
|
+
lines.push(`File ${entry.fileTag} ${entry.url}:`);
|
|
455
|
+
for (const section of entry.sections) {
|
|
456
|
+
const prefix = '#'.repeat(section.level);
|
|
457
|
+
lines.push(` ${entry.fileTag}.${section.index} ${prefix} ${section.title}`);
|
|
458
|
+
}
|
|
459
|
+
lines.push('');
|
|
460
|
+
}
|
|
461
|
+
lines.push('</experience>');
|
|
462
|
+
return lines.join('\n');
|
|
335
463
|
}
|
|
336
464
|
|
|
337
465
|
export interface SessionStep {
|
|
@@ -348,3 +476,10 @@ export interface SessionExperienceEntry {
|
|
|
348
476
|
steps: SessionStep[];
|
|
349
477
|
relatedUrls?: string[];
|
|
350
478
|
}
|
|
479
|
+
|
|
480
|
+
export interface ExperienceTocEntry {
|
|
481
|
+
fileTag: string;
|
|
482
|
+
fileHash: string;
|
|
483
|
+
url: string;
|
|
484
|
+
sections: { index: number; level: 2 | 3; title: string }[];
|
|
485
|
+
}
|
package/src/explorbot.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { ActionResult } from './action-result.ts';
|
|
3
4
|
import { ApiClient } from './api/api-client.ts';
|
|
4
5
|
import { RequestStore } from './api/request-store.ts';
|
|
5
6
|
import { loadSpec } from './api/spec-reader.ts';
|
|
@@ -182,8 +183,14 @@ export class ExplorBot {
|
|
|
182
183
|
return (this.agents.pilot ||= this.createAgent(({ ai, explorer }) => {
|
|
183
184
|
const researcher = this.agentResearcher();
|
|
184
185
|
const navigator = this.agentNavigator();
|
|
185
|
-
const
|
|
186
|
-
|
|
186
|
+
const stateManager = explorer.getStateManager();
|
|
187
|
+
const experienceTracker = stateManager.getExperienceTracker();
|
|
188
|
+
const getState = () => {
|
|
189
|
+
const state = stateManager.getCurrentState();
|
|
190
|
+
return state ? ActionResult.fromState(state) : null;
|
|
191
|
+
};
|
|
192
|
+
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
|
|
193
|
+
return new Pilot(ai, tools, researcher, explorer, experienceTracker);
|
|
187
194
|
}));
|
|
188
195
|
}
|
|
189
196
|
|
package/src/explorer.ts
CHANGED
|
@@ -37,6 +37,7 @@ declare namespace CodeceptJS {
|
|
|
37
37
|
|
|
38
38
|
const debugLog = createDebug('explorbot:explorer');
|
|
39
39
|
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
40
|
+
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
40
41
|
|
|
41
42
|
interface TabInfo {
|
|
42
43
|
url: string;
|
|
@@ -289,7 +290,15 @@ class Explorer {
|
|
|
289
290
|
if (statePush) {
|
|
290
291
|
await action.execute(`I.executeScript(() => { window.history.pushState({}, '', ${serializedUrl}); window.dispatchEvent(new PopStateEvent('popstate')); })`);
|
|
291
292
|
} else {
|
|
292
|
-
|
|
293
|
+
try {
|
|
294
|
+
await action.execute(`I.amOnPage(${serializedUrl})`);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
297
|
+
if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg)) throw err;
|
|
298
|
+
tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
|
|
299
|
+
await this.playwrightHelper.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {});
|
|
300
|
+
await action.capturePageState();
|
|
301
|
+
}
|
|
293
302
|
}
|
|
294
303
|
|
|
295
304
|
if (wait !== undefined) {
|