explorbot 0.5.9 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/explorbot-cli.ts +495 -482
- package/boat/api-tester/src/ai/curler.ts +1 -1
- package/boat/api-tester/src/cli.ts +4 -4
- package/boat/api-tester/src/commands/init-command.ts +2 -0
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
- package/boat/doc-collector/src/screenshots.ts +1 -1
- package/boat/prima/bin/prima-cli.ts +2 -1
- package/dist/bin/explorbot-cli.js +456 -444
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
- package/dist/boat/api-tester/src/ai/curler.js +1 -1
- package/dist/boat/api-tester/src/cli.js +4 -4
- package/dist/boat/api-tester/src/commands/init-command.js +2 -0
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
- package/dist/boat/prima/bin/prima-cli.js +2 -1
- package/dist/models.json +1 -1
- package/dist/package.json +2 -1
- package/dist/rules/navigator/output.md +2 -8
- package/dist/rules/navigator/verification-actions.md +6 -2
- package/dist/rules/planner/ignored-pages.md +1 -0
- package/dist/src/action-result.js +1 -1
- package/dist/src/action.d.ts +0 -1
- package/dist/src/action.js +0 -4
- package/dist/src/ai/captain/idle-mode.js +42 -26
- package/dist/src/ai/captain/mixin.d.ts +2 -0
- package/dist/src/ai/captain/web-mode.js +11 -0
- package/dist/src/ai/captain.d.ts +6 -4
- package/dist/src/ai/captain.js +25 -1
- package/dist/src/ai/driller.js +1 -1
- package/dist/src/ai/experience-compactor.js +2 -2
- package/dist/src/ai/judge.d.ts +7 -0
- package/dist/src/ai/judge.js +8 -1
- package/dist/src/ai/navigator.d.ts +1 -0
- package/dist/src/ai/navigator.js +11 -7
- package/dist/src/ai/planner/subpages.js +1 -1
- package/dist/src/ai/planner.d.ts +12 -5
- package/dist/src/ai/planner.js +138 -86
- package/dist/src/ai/provider.d.ts +1 -1
- package/dist/src/ai/provider.js +16 -5
- package/dist/src/ai/rerunner.js +1 -1
- package/dist/src/ai/researcher/cache.js +14 -6
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
- package/dist/src/ai/researcher/deep-analysis.js +7 -2
- package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
- package/dist/src/ai/researcher.js +44 -24
- package/dist/src/ai/rules.js +6 -7
- package/dist/src/ai/scout.d.ts +12 -2
- package/dist/src/ai/scout.js +103 -8
- package/dist/src/ai/tester.d.ts +9 -5
- package/dist/src/ai/tester.js +22 -42
- package/dist/src/ai/tools.js +10 -8
- package/dist/src/application-spec.d.ts +2 -0
- package/dist/src/application-spec.js +17 -12
- package/dist/src/commands/context-experience-command.js +0 -1
- package/dist/src/commands/context-knowledge-command.js +0 -1
- package/dist/src/commands/explore-command.d.ts +3 -2
- package/dist/src/commands/explore-command.js +35 -23
- package/dist/src/commands/help-json-command.js +3 -1
- package/dist/src/commands/init-command.js +8 -4
- package/dist/src/commands/learn-command.js +0 -2
- package/dist/src/commands/options/auth-option.d.ts +6 -0
- package/dist/src/commands/options/auth-option.js +14 -0
- package/dist/src/commands/options/index.d.ts +2 -0
- package/dist/src/commands/options/index.js +2 -0
- package/dist/src/commands/plan-command.d.ts +3 -0
- package/dist/src/commands/plan-command.js +43 -17
- package/dist/src/commands/plan-save-command.js +6 -17
- package/dist/src/commands/plans-command.js +1 -1
- package/dist/src/commands/test-command.js +12 -2
- package/dist/src/components/App.d.ts +1 -2
- package/dist/src/components/App.js +1 -3
- package/dist/src/components/InputPane.js +0 -5
- package/dist/src/config.d.ts +3 -2
- package/dist/src/config.js +3 -12
- package/dist/src/explorbot.d.ts +3 -2
- package/dist/src/explorbot.js +32 -10
- package/dist/src/explorer.js +6 -7
- package/dist/src/knowledge-tracker.d.ts +2 -2
- package/dist/src/knowledge-tracker.js +18 -6
- package/dist/src/playwright-recorder.d.ts +0 -1
- package/dist/src/playwright-recorder.js +0 -2
- package/dist/src/reporter.d.ts +1 -1
- package/dist/src/reporter.js +3 -3
- package/dist/src/test-plan.js +1 -1
- package/dist/src/utils/error-page.d.ts +17 -0
- package/dist/src/utils/error-page.js +53 -2
- package/dist/src/utils/html-diff.d.ts +1 -2
- package/dist/src/utils/html-diff.js +4 -44
- package/dist/src/utils/logger.js +27 -10
- package/dist/src/utils/markdown-terminal.js +1 -2
- package/dist/src/utils/next-steps.js +20 -16
- package/dist/src/utils/page-readiness.d.ts +2 -1
- package/dist/src/utils/page-readiness.js +24 -5
- package/dist/src/utils/test-plan-markdown.d.ts +3 -0
- package/dist/src/utils/test-plan-markdown.js +35 -4
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +5 -0
- package/docs/basics/providers.md +1 -1
- package/docs/contributing/regression-tests.md +19 -4
- package/docs/reference/commands.md +18 -2
- package/docs/reference/configuration.md +1 -1
- package/docs/web-testing/agents.md +8 -0
- package/docs/web-testing/customization.md +2 -0
- package/docs/web-testing/researcher.md +1 -1
- package/docs/workflow/agentic-usage.md +7 -0
- package/docs/workflow/knowledge.md +6 -0
- package/models.json +1 -1
- package/package.json +2 -1
- package/rules/navigator/output.md +2 -8
- package/rules/navigator/verification-actions.md +6 -2
- package/rules/planner/ignored-pages.md +1 -0
- package/src/action-result.ts +1 -2
- package/src/action.ts +0 -5
- package/src/ai/captain/idle-mode.ts +38 -26
- package/src/ai/captain/mixin.ts +2 -1
- package/src/ai/captain/web-mode.ts +12 -0
- package/src/ai/captain.ts +25 -7
- package/src/ai/driller.ts +1 -2
- package/src/ai/experience-compactor.ts +2 -2
- package/src/ai/judge.ts +10 -1
- package/src/ai/navigator.ts +12 -7
- package/src/ai/planner/subpages.ts +1 -2
- package/src/ai/planner.ts +140 -92
- package/src/ai/provider.ts +15 -6
- package/src/ai/rerunner.ts +1 -2
- package/src/ai/researcher/cache.ts +16 -7
- package/src/ai/researcher/deep-analysis.ts +7 -2
- package/src/ai/researcher/fingerprint-worker.ts +5 -5
- package/src/ai/researcher.ts +44 -28
- package/src/ai/rules.ts +6 -7
- package/src/ai/scout.ts +98 -10
- package/src/ai/tester.ts +29 -47
- package/src/ai/tools.ts +11 -9
- package/src/application-spec.ts +19 -13
- package/src/commands/clean-command.ts +1 -1
- package/src/commands/context-command.ts +0 -1
- package/src/commands/context-experience-command.ts +0 -1
- package/src/commands/context-knowledge-command.ts +0 -1
- package/src/commands/explore-command.ts +36 -23
- package/src/commands/help-json-command.ts +8 -6
- package/src/commands/init-command.ts +10 -3
- package/src/commands/learn-command.ts +0 -2
- package/src/commands/options/auth-option.ts +15 -0
- package/src/commands/options/index.ts +2 -0
- package/src/commands/plan-command.ts +44 -17
- package/src/commands/plan-save-command.ts +6 -17
- package/src/commands/plans-command.ts +1 -1
- package/src/commands/test-command.ts +11 -2
- package/src/components/App.tsx +2 -6
- package/src/components/InputPane.tsx +0 -5
- package/src/config.ts +5 -16
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +31 -11
- package/src/explorer.ts +6 -7
- package/src/knowledge-tracker.ts +18 -7
- package/src/playwright-recorder.ts +0 -2
- package/src/reporter.ts +3 -4
- package/src/test-plan.ts +1 -2
- package/src/utils/error-page.ts +64 -2
- package/src/utils/html-diff.ts +4 -56
- package/src/utils/html.ts +0 -1
- package/src/utils/logger.ts +30 -12
- package/src/utils/markdown-terminal.ts +1 -2
- package/src/utils/next-steps.ts +19 -17
- package/src/utils/page-readiness.ts +33 -7
- package/src/utils/test-plan-markdown.ts +36 -4
- package/src/utils/url-matcher.ts +6 -0
package/src/ai/researcher.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { ActionResult } from '../action-result.js';
|
|
3
3
|
import { setActivity } from '../activity.ts';
|
|
4
|
-
import {
|
|
4
|
+
import { type ExplorbotConfig, type ResearcherAgentConfig, agentSettings, outputPath } from '../config.ts';
|
|
5
5
|
import { executionController } from '../execution-controller.ts';
|
|
6
6
|
import type { ExperienceTracker } from '../experience-tracker.ts';
|
|
7
7
|
import type Explorer from '../explorer.ts';
|
|
@@ -10,13 +10,15 @@ import type { StateManager } from '../state-manager.js';
|
|
|
10
10
|
import { WebPageState } from '../state-manager.js';
|
|
11
11
|
import { Stats } from '../stats.ts';
|
|
12
12
|
import { diffAriaSnapshots } from '../utils/aria.ts';
|
|
13
|
-
import { ErrorPageError, detectPageCondition } from '../utils/error-page.ts';
|
|
13
|
+
import { ErrorPageError, type PageCondition, detectPageCondition } from '../utils/error-page.ts';
|
|
14
14
|
import { isBodyEmpty } from '../utils/html.ts';
|
|
15
15
|
import { createDebug, tag } from '../utils/logger.js';
|
|
16
16
|
import { mdq } from '../utils/markdown-query.ts';
|
|
17
|
+
import { waitForDomChange, waitForPageReadiness } from '../utils/page-readiness.ts';
|
|
17
18
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
18
19
|
import { annotatePageElements } from '../utils/web-annotate.ts';
|
|
19
20
|
import type { Agent, AgentDeps } from './agent.js';
|
|
21
|
+
import { UNDECIDED, pageEvidence } from './judge.ts';
|
|
20
22
|
import type { Navigator } from './navigator.ts';
|
|
21
23
|
import { ContextLengthError, type Provider } from './provider.js';
|
|
22
24
|
import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from './researcher/cache.ts';
|
|
@@ -34,6 +36,7 @@ import { TaskAgent } from './task-agent.ts';
|
|
|
34
36
|
export type { Locator } from './researcher/locators.ts';
|
|
35
37
|
|
|
36
38
|
const debugLog = createDebug('explorbot:researcher');
|
|
39
|
+
const HIDDEN_CONTENT_STATEMENT = 'The page has collapsed or hidden content behind controls that open or expand it.';
|
|
37
40
|
|
|
38
41
|
export const POSSIBLE_SECTIONS = {
|
|
39
42
|
overlay: 'dialog, modal, drawer, popup, or active form overlay',
|
|
@@ -136,7 +139,8 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
136
139
|
if (light) tag('info').log('Light research: single pass, locator verification skipped');
|
|
137
140
|
setActivity(`${this.emoji} Researching...`, 'action');
|
|
138
141
|
|
|
139
|
-
|
|
142
|
+
const retrying = opts._retriesLeft !== undefined;
|
|
143
|
+
if (!retrying) await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
|
|
140
144
|
await this.hooksRunner.runBeforeHook('researcher', state.url);
|
|
141
145
|
|
|
142
146
|
const { elements: annotatedElements } = await this.explorer.withPage(annotatePageElements);
|
|
@@ -148,7 +152,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
148
152
|
tag('warning').log(`Detected error page at ${state.url}`);
|
|
149
153
|
throw new ErrorPageError(state.url, this.actionResult!.title, this.actionResult!.httpStatus);
|
|
150
154
|
}
|
|
151
|
-
if (condition === 'loading') {
|
|
155
|
+
if (condition === 'loading' && !retrying) {
|
|
152
156
|
const settled = await this.waitUntilSettled(screenshot);
|
|
153
157
|
if (!settled) {
|
|
154
158
|
tag('warning').log(`Page at ${state.url} did not finish loading within timeout, continuing with best-effort research`);
|
|
@@ -290,7 +294,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
290
294
|
if (fallback) markSectionAsFocused(result, fallback);
|
|
291
295
|
}
|
|
292
296
|
|
|
293
|
-
if (!interrupted() && deep) {
|
|
297
|
+
if (!interrupted() && deep && (await this.hasHiddenContent())) {
|
|
294
298
|
try {
|
|
295
299
|
await this.performDeepAnalysis(researchState, result);
|
|
296
300
|
} catch (err) {
|
|
@@ -365,37 +369,49 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
365
369
|
|
|
366
370
|
private async waitUntilSettled(screenshot: boolean): Promise<boolean> {
|
|
367
371
|
const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
|
|
368
|
-
|
|
372
|
+
const deadline = Date.now() + errorPageTimeout * 1000;
|
|
369
373
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
try {
|
|
373
|
-
await this.explorer.withPage((page) => page.waitForLoadState('networkidle', { timeout: errorPageTimeout * 1000 }));
|
|
374
|
-
} catch {}
|
|
375
|
-
|
|
376
|
-
await this.explorer.withPage(annotatePageElements);
|
|
377
|
-
this.actionResult = await this.explorer.capture({ screenshot: includeScreenshot });
|
|
378
|
-
|
|
379
|
-
let condition = detectPageCondition(this.actionResult!);
|
|
380
|
-
if (condition === 'error') {
|
|
381
|
-
throw new ErrorPageError(this.actionResult!.url, this.actionResult!.title, this.actionResult!.httpStatus);
|
|
382
|
-
}
|
|
383
|
-
if (condition === 'ok') return true;
|
|
384
|
-
|
|
385
|
-
for (let i = 0; i < 3; i++) {
|
|
386
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
374
|
+
for (let remaining = errorPageTimeout * 1000; remaining > 0; remaining = deadline - Date.now()) {
|
|
375
|
+
const rendered = await this.explorer.withPage((page) => waitForPageReadiness(page, { timeout: remaining, spinnerSelectors: this.config.playwright.spinnerSelectors }));
|
|
387
376
|
await this.explorer.withPage(annotatePageElements);
|
|
388
|
-
this.actionResult = await this.explorer.capture({ screenshot:
|
|
389
|
-
condition =
|
|
390
|
-
if (condition === 'error')
|
|
391
|
-
throw new ErrorPageError(this.actionResult!.url, this.actionResult!.title, this.actionResult!.httpStatus);
|
|
392
|
-
}
|
|
377
|
+
this.actionResult = await this.explorer.capture({ screenshot: screenshot && this.provider.hasVision() });
|
|
378
|
+
const condition = await this.pageCondition(rendered);
|
|
379
|
+
if (condition === 'error') throw new ErrorPageError(this.actionResult!.url, this.actionResult!.title, this.actionResult!.httpStatus);
|
|
393
380
|
if (condition === 'ok') return true;
|
|
381
|
+
|
|
382
|
+
tag('step').log('Page is still loading, waiting for content...');
|
|
383
|
+
if (!(await this.explorer.withPage((page) => waitForDomChange(page, deadline - Date.now())))) return false;
|
|
394
384
|
}
|
|
395
385
|
|
|
396
386
|
return false;
|
|
397
387
|
}
|
|
398
388
|
|
|
389
|
+
private async pageCondition(rendered: boolean): Promise<PageCondition> {
|
|
390
|
+
const condition = detectPageCondition(this.actionResult!);
|
|
391
|
+
if (condition !== 'loading') return condition;
|
|
392
|
+
if (!this.judge && rendered) return 'ok';
|
|
393
|
+
if (!this.judge) return condition;
|
|
394
|
+
|
|
395
|
+
const conditions: Record<string, PageCondition> = {
|
|
396
|
+
'still loading: blank, splash screen, skeleton or loading placeholder': 'loading',
|
|
397
|
+
'error page: shows an error instead of the requested content': 'error',
|
|
398
|
+
'loaded: the main content is rendered': 'ok',
|
|
399
|
+
};
|
|
400
|
+
const decision = await this.judge.decide('The page was blank or barely rendered when it opened. What state is it in now?', [...Object.keys(conditions), UNDECIDED], pageEvidence(this.actionResult!));
|
|
401
|
+
return conditions[decision.value ?? ''] || 'ok';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async hasHiddenContent(): Promise<boolean> {
|
|
405
|
+
const captured = await this.explorer.capture({ screenshot: true });
|
|
406
|
+
const hidden = await this.provider.oracle(HIDDEN_CONTENT_STATEMENT, pageEvidence(captured), { agentName: 'researcher', screenshot: captured.screenshot, telemetry: { functionId: 'researcher.hasHiddenContent' } });
|
|
407
|
+
if (hidden === null) {
|
|
408
|
+
tag('warning').log('Could not check the page for hidden content, running deep analysis');
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
if (!hidden) tag('step').log('Nothing to expand on this page, skipping deep analysis');
|
|
412
|
+
return hidden;
|
|
413
|
+
}
|
|
414
|
+
|
|
399
415
|
private getConfiguredSections(): Record<string, string> {
|
|
400
416
|
const configSections = this.settings.sections;
|
|
401
417
|
if (!configSections?.length) return POSSIBLE_SECTIONS;
|
package/src/ai/rules.ts
CHANGED
|
@@ -45,7 +45,7 @@ const locatorStrategyRule = dedent`
|
|
|
45
45
|
3. Use ARIA snapshot to identify element state (focused, visible, selected, expanded, etc.)
|
|
46
46
|
4. Match semantic proximity - elements near fields you just interacted with
|
|
47
47
|
|
|
48
|
-
Once the correct element is identified pick the best unique locator following priority: ARIA → CSS → XPath
|
|
48
|
+
Once the correct element is identified pick the best unique locator following priority: ARIA → text → CSS → XPath
|
|
49
49
|
</disambiguation>
|
|
50
50
|
|
|
51
51
|
ARIA locators must specify role. Specify locator type as JSON string with role and text keys.
|
|
@@ -101,7 +101,7 @@ const locatorStrategyRule = dedent`
|
|
|
101
101
|
<xpath_rules>
|
|
102
102
|
XPath locators must start with //.
|
|
103
103
|
XPath may use positional indices [1], [2] and contains(., "text") as a last-resort disambiguator when attribute/text strategies are exhausted.
|
|
104
|
-
XPath should rely less on class names — prefer element hierarchy,
|
|
104
|
+
XPath should rely less on class names — prefer element hierarchy, attributes, and text content.
|
|
105
105
|
XPath and CSS MUST provide different strategies for finding the same element.
|
|
106
106
|
</xpath_rules>
|
|
107
107
|
|
|
@@ -118,7 +118,7 @@ const locatorStrategyRule = dedent`
|
|
|
118
118
|
|
|
119
119
|
<bad locator example>
|
|
120
120
|
'a.filter-tab:nth-of-type(1)' // WRONG: positional in CSS, use :has-text("Active") instead
|
|
121
|
-
'//a[contains(@class,"filter-tab") and contains(@class,"active")]' // WRONG: XPath repeats CSS approach, provide a different strategy e.g. //a[
|
|
121
|
+
'//a[contains(@class,"filter-tab") and contains(@class,"active")]' // WRONG: XPath repeats CSS approach, provide a different strategy e.g. //a[normalize-space()="Active"]
|
|
122
122
|
'//table//tbody/tr[1]//button[contains(@onclick,'fn()')]' // onclick is not semantic attribute
|
|
123
123
|
'//html/body/vue-button-123' // vue-framework specific locator
|
|
124
124
|
'link "New Item"' // WRONG: malformed string, use {"role":"link","text":"New Item"}
|
|
@@ -225,7 +225,7 @@ export const focusedElementRule = dedent`
|
|
|
225
225
|
When a text input element is focused (textbox, combobox, contenteditable):
|
|
226
226
|
|
|
227
227
|
To CLEAR the field before typing:
|
|
228
|
-
- I.pressKey(['
|
|
228
|
+
- I.pressKey(['CommandOrControl', 'a']); // Select all
|
|
229
229
|
- I.pressKey('Backspace'); // Delete selection
|
|
230
230
|
|
|
231
231
|
To TYPE into the focused element:
|
|
@@ -420,8 +420,7 @@ export const actionRule = dedent`
|
|
|
420
420
|
I.pressKey('Escape'); // Press Escape key
|
|
421
421
|
I.pressKey('Tab'); // Press Tab key
|
|
422
422
|
I.pressKey('Backspace'); // Press Backspace key
|
|
423
|
-
I.pressKey(['
|
|
424
|
-
I.pressKey(['Meta', 'a']); // Select all on Mac (Cmd+A)
|
|
423
|
+
I.pressKey(['CommandOrControl', 'a']); // Select all
|
|
425
424
|
I.pressKey(['Shift', 'Tab']); // Shift+Tab to go back
|
|
426
425
|
I.pressKey('ArrowDown'); // Navigate dropdown options
|
|
427
426
|
</example>
|
|
@@ -518,7 +517,7 @@ export const actionRule = dedent`
|
|
|
518
517
|
I.seeClipboardEquals('ABC-123');
|
|
519
518
|
</example>
|
|
520
519
|
|
|
521
|
-
[
|
|
520
|
+
[Prefer the commands listed here; use other CodeceptJS I.* commands when none of these fits]
|
|
522
521
|
[INTERACT ONLY WITH ELEMENTS THAT ARE ON THE PAGE HTML]
|
|
523
522
|
[DO NOT USE WAIT FUNCTIONS]
|
|
524
523
|
|
package/src/ai/scout.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
1
4
|
import dedent from 'dedent';
|
|
5
|
+
import matter from 'gray-matter';
|
|
6
|
+
import { outputPath } from '../config.ts';
|
|
2
7
|
import { tag } from '../utils/logger.ts';
|
|
3
8
|
import { loop } from '../utils/loop.ts';
|
|
9
|
+
import { sanitizeFilename } from '../utils/strings.ts';
|
|
4
10
|
import type { Agent } from './agent.ts';
|
|
5
11
|
import type { Provider } from './provider.ts';
|
|
6
12
|
import { type ScoutCorpus, createScoutTools, excludeCorpusUrls } from './scout/tools.ts';
|
|
@@ -9,6 +15,7 @@ const MAX_ITERATIONS = 3;
|
|
|
9
15
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
10
16
|
const CACHE_LIMIT = 40;
|
|
11
17
|
const URL_LISTING_LIMIT = 40;
|
|
18
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
12
19
|
|
|
13
20
|
export class Scout implements Agent {
|
|
14
21
|
emoji = '🔎';
|
|
@@ -26,11 +33,18 @@ export class Scout implements Agent {
|
|
|
26
33
|
async collectDocs(query: ScoutQuery): Promise<string> {
|
|
27
34
|
if (!this.isAvailable()) return '';
|
|
28
35
|
|
|
29
|
-
const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
|
|
36
|
+
const cacheKey = `${query.url || ''}|${query.feature || ''}|${(query.excludeUrls || []).join(',')}`;
|
|
30
37
|
const cached = this.cache.get(cacheKey);
|
|
31
38
|
if (cached !== undefined) return cached;
|
|
32
39
|
|
|
33
|
-
const
|
|
40
|
+
const fileCached = this.loadCachedDigest(query);
|
|
41
|
+
if (fileCached !== null) {
|
|
42
|
+
if (this.cache.size > CACHE_LIMIT) this.cache.clear();
|
|
43
|
+
this.cache.set(cacheKey, fileCached);
|
|
44
|
+
return fileCached;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls || []);
|
|
34
48
|
if (corpus.files.length === 0) return '';
|
|
35
49
|
|
|
36
50
|
const result = await this.runSession(corpus, query);
|
|
@@ -90,9 +104,13 @@ export class Scout implements Agent {
|
|
|
90
104
|
|
|
91
105
|
const digest = getResult();
|
|
92
106
|
if (digest) {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
tag('info').log(`Scout
|
|
107
|
+
const filePath = this.saveDigest(query, digest);
|
|
108
|
+
const words = digest.split(/\s+/).filter(Boolean).length;
|
|
109
|
+
tag('info').log(`Scout found relevant docs: ${words} words → ${filePath}`);
|
|
110
|
+
const lines = digest.split('\n').slice(0, 5).join('\n');
|
|
111
|
+
tag('substep').log(lines);
|
|
112
|
+
} else {
|
|
113
|
+
tag('info').log(`Scout found no relevant docs for ${query.feature || query.url || 'the current page'}`);
|
|
96
114
|
}
|
|
97
115
|
return digest;
|
|
98
116
|
}
|
|
@@ -129,7 +147,7 @@ export class Scout implements Agent {
|
|
|
129
147
|
${pagesListing}
|
|
130
148
|
|
|
131
149
|
These pages are already provided to the planner in full — do not re-report them:
|
|
132
|
-
${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
|
|
150
|
+
${(query.excludeUrls || []).map((url) => `- ${url}`).join('\n') || '- none'}
|
|
133
151
|
|
|
134
152
|
AVAILABLE TOOLS:
|
|
135
153
|
${toolNames.join(', ')}.
|
|
@@ -144,12 +162,32 @@ export class Scout implements Agent {
|
|
|
144
162
|
2. Read the files whose hits look most relevant
|
|
145
163
|
3. Report the digest as your final message — no tool call is needed to finish
|
|
146
164
|
|
|
147
|
-
|
|
165
|
+
<rules>
|
|
148
166
|
- Report only what the documentation states. Never fill gaps with assumptions about the application
|
|
149
167
|
- Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
|
|
150
168
|
- Name the page URL each item belongs to, so scenarios anchor to real routes
|
|
151
|
-
- Explore briefly: a few scans and reads are enough, then report
|
|
152
169
|
- A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
|
|
170
|
+
- For vague terms such as valid, supported, recent, all, or available, seek exact definitions and provide example of valid/invalid values.
|
|
171
|
+
- Follow relevant references for missing rules, shared policies, exceptions, and conflicting claims.
|
|
172
|
+
</rules>
|
|
173
|
+
|
|
174
|
+
Look for facts we need to collect for testing the related feature:
|
|
175
|
+
|
|
176
|
+
<facts>
|
|
177
|
+
- **Actors/access:** Find exact roles, permissions, ownership, scope, and denial behavior. Distinguish hidden, disabled, forbidden, and redirected.
|
|
178
|
+
- **Prerequisites:** Find required data, settings, integrations, plans, flags, and dependencies.
|
|
179
|
+
- **Actions/navigation:** Find entry points, available operations, destinations, confirmations, and cancellation behavior.
|
|
180
|
+
- **Inputs/validation:** Find accepted values, formats, defaults, required fields, exact limits/units, normalization, and error messages. Distinguish omitted, empty, whitespace, and unchanged.
|
|
181
|
+
- **Duplicates:** Find identity rules, uniqueness scope, and whether matches are rejected, reused, merged, or updated.
|
|
182
|
+
- **Conditions/states:** Find guards, exceptions, state transitions, prohibited actions, and intentional no-ops.
|
|
183
|
+
- **Outcomes:** Find visible feedback, persistent changes, side effects, and subsequent places where results appear.
|
|
184
|
+
- **Failures:** Find retained changes, rollback, partial success, recovery, and retry behavior. Never assume an error means nothing changed.
|
|
185
|
+
- **Async/time:** Find start versus completion, progress indicators, refresh behavior, delays, expiry, schedules, and retry limits.
|
|
186
|
+
- **Lists/bulk actions:** Find search/filter semantics, sorting, pagination, count definitions, selection scope, and skipped-item behavior.
|
|
187
|
+
- **Relationships/deletion:** Find inheritance, cascades, copy versus reference, unlink versus delete, restoration, and irreversible effects.
|
|
188
|
+
- **Files/integrations:** Find supported formats, mappings, processing limits, sync direction, overwrite precedence, and unavailable-service behavior.
|
|
189
|
+
- **Variants:** Find differences by role, plan, configuration, deployment, browser, or channel. Keep browser and API behavior separate.
|
|
190
|
+
</facts>
|
|
153
191
|
`;
|
|
154
192
|
|
|
155
193
|
const customPrompt = this.provider.getSystemPromptForAgent('scout');
|
|
@@ -166,15 +204,65 @@ export class Scout implements Agent {
|
|
|
166
204
|
Report the documented capabilities, states and transitions a test planner could turn into scenarios.
|
|
167
205
|
`;
|
|
168
206
|
}
|
|
207
|
+
|
|
208
|
+
private saveDigest(query: ScoutQuery, digest: string): string {
|
|
209
|
+
const filePath = this.digestPath(query);
|
|
210
|
+
const dir = outputPath('scout');
|
|
211
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
212
|
+
const frontmatter: Record<string, any> = {};
|
|
213
|
+
if (query.url) frontmatter.url = query.url;
|
|
214
|
+
if (query.feature) frontmatter.feature = query.feature;
|
|
215
|
+
if (query.excludeUrls?.length) frontmatter.excludeUrls = query.excludeUrls;
|
|
216
|
+
frontmatter.cachedAt = new Date().toISOString();
|
|
217
|
+
const content = matter.stringify(digest, frontmatter);
|
|
218
|
+
writeFileSync(filePath, content, 'utf8');
|
|
219
|
+
tag('data').log('scout', { path: filePath, content: digest });
|
|
220
|
+
return filePath;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private digestPath(query: ScoutQuery): string {
|
|
224
|
+
const cacheKey = `${query.url || ''}|${query.feature || ''}|${(query.excludeUrls || []).join(',')}`;
|
|
225
|
+
const hash = createHash('sha1').update(cacheKey).digest('hex').slice(0, 8);
|
|
226
|
+
const raw = query.feature || query.url || 'general';
|
|
227
|
+
const name = sanitizeFilename(raw) || 'general';
|
|
228
|
+
return join(outputPath('scout'), `digest-${name}-${hash}.md`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private loadCachedDigest(query: ScoutQuery): string | null {
|
|
232
|
+
const filePath = this.digestPath(query);
|
|
233
|
+
if (!existsSync(filePath)) return null;
|
|
234
|
+
if (Date.now() - statSync(filePath).mtimeMs > CACHE_TTL_MS) return null;
|
|
235
|
+
const content = matter(readFileSync(filePath, 'utf8')).content;
|
|
236
|
+
const words = content.split(/\s+/).filter(Boolean).length;
|
|
237
|
+
tag('info').log(`Scout found previous spec digest in ${relative(process.cwd(), filePath)}`);
|
|
238
|
+
tag('multiline').log(content);
|
|
239
|
+
return content;
|
|
240
|
+
}
|
|
169
241
|
}
|
|
170
242
|
|
|
171
243
|
function toPosix(path: string): string {
|
|
172
244
|
return path.split('\\').join('/');
|
|
173
245
|
}
|
|
174
246
|
|
|
175
|
-
export
|
|
247
|
+
export class ScoutQuery {
|
|
176
248
|
url?: string;
|
|
177
249
|
title?: string;
|
|
178
250
|
feature?: string;
|
|
179
|
-
excludeUrls
|
|
251
|
+
excludeUrls?: string[];
|
|
252
|
+
|
|
253
|
+
constructor(init?: Partial<ScoutQuery>) {
|
|
254
|
+
if (init) Object.assign(this, init);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static fromState(state: { url?: string; title?: string }): ScoutQuery {
|
|
258
|
+
return new ScoutQuery({ url: state.url, title: state.title });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
get exclude(): string[] | undefined {
|
|
262
|
+
return this.excludeUrls;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
set exclude(value: string[] | undefined) {
|
|
266
|
+
this.excludeUrls = value;
|
|
267
|
+
}
|
|
180
268
|
}
|
package/src/ai/tester.ts
CHANGED
|
@@ -11,16 +11,16 @@ import { Observability } from '../observability.ts';
|
|
|
11
11
|
import { type StateTransition, normalizeUrl } from '../state-manager.ts';
|
|
12
12
|
import { Stats } from '../stats.ts';
|
|
13
13
|
import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
|
|
14
|
-
import { ErrorPageError, isErrorPage } from '../utils/error-page.ts';
|
|
14
|
+
import { ErrorPageError, TestStartBlockedError, isErrorPage } from '../utils/error-page.ts';
|
|
15
15
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
16
16
|
import { loop } from '../utils/loop.ts';
|
|
17
17
|
import { compactErrorMessage } from '../utils/strings.ts';
|
|
18
|
+
import { isSamePageIgnoringQuery } from '../utils/url-matcher.ts';
|
|
18
19
|
import type { Agent, AgentDeps } from './agent.ts';
|
|
19
|
-
import type { Captain } from './captain.ts';
|
|
20
20
|
import type { Conversation } from './conversation.ts';
|
|
21
|
+
import { pageEvidence } from './judge.ts';
|
|
21
22
|
import { Navigator } from './navigator.ts';
|
|
22
23
|
import type { Pilot } from './pilot.ts';
|
|
23
|
-
import { Provider } from './provider.ts';
|
|
24
24
|
import { Researcher } from './researcher.ts';
|
|
25
25
|
import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from './rules.ts';
|
|
26
26
|
import { TaskAgent } from './task-agent.ts';
|
|
@@ -48,7 +48,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
48
48
|
private testRun: TestRun | null = null;
|
|
49
49
|
private currentConversation: Conversation | null = null;
|
|
50
50
|
private pilot: Pilot | null = null;
|
|
51
|
-
private
|
|
51
|
+
private supervisor: Supervisor | null = null;
|
|
52
52
|
|
|
53
53
|
MAX_ITERATIONS = 30;
|
|
54
54
|
MAX_EXTENSIONS = 2;
|
|
@@ -90,8 +90,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
90
90
|
this.pilot = pilot;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
this.
|
|
93
|
+
setSupervisor(supervisor: Supervisor): void {
|
|
94
|
+
this.supervisor = supervisor;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
private getCurrentState(): ActionResult {
|
|
@@ -124,7 +124,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
124
124
|
this.previousRegionPresent = null;
|
|
125
125
|
this.regionTransitioned = false;
|
|
126
126
|
this.stateManager.clearHistory();
|
|
127
|
-
this.resetFailureCount();
|
|
128
127
|
this.pilot?.reset();
|
|
129
128
|
|
|
130
129
|
const requestStore = this.requestStore;
|
|
@@ -154,6 +153,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
154
153
|
offFailedRequest?.();
|
|
155
154
|
return await this.abortStartedTestOnErrorPage(task, initialState);
|
|
156
155
|
}
|
|
156
|
+
const startEvidence = { ...pageEvidence(initialState), scenario: task.scenario, expected: task.expected, plannedSteps: task.plannedSteps, notes: task.notesToString() };
|
|
157
|
+
if ((await this.judge?.assert('This is the right page to start the test', startEvidence)) === false) {
|
|
158
|
+
offFailedRequest?.();
|
|
159
|
+
throw new TestStartBlockedError(task.scenario, task.startUrl, initialState.url || '');
|
|
160
|
+
}
|
|
157
161
|
|
|
158
162
|
const conversation = this.provider.startConversation(this.getSystemMessage(), 'tester');
|
|
159
163
|
conversation.markLastMessageCacheable();
|
|
@@ -259,7 +263,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
259
263
|
shouldContinue = false;
|
|
260
264
|
|
|
261
265
|
await loop(
|
|
262
|
-
async ({ stop,
|
|
266
|
+
async ({ stop, iteration, userInput }) => {
|
|
263
267
|
if (opts.deadline != null && Date.now() >= opts.deadline) {
|
|
264
268
|
deadlineReached = true;
|
|
265
269
|
task.addNote('Time budget reached. Stopped');
|
|
@@ -267,17 +271,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
267
271
|
return;
|
|
268
272
|
}
|
|
269
273
|
debugLog('iteration', iteration);
|
|
270
|
-
if (!(await this.explorer.recover()).ok) {
|
|
271
|
-
task.addNote('Browser page is unavailable');
|
|
272
|
-
task.finish(TestResult.FAILED);
|
|
273
|
-
stop();
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
274
|
const currentState = this.getCurrentState();
|
|
277
275
|
|
|
278
276
|
const tools = {
|
|
279
277
|
...codeceptjsTools,
|
|
280
|
-
...this.createTestFlowTools(task,
|
|
278
|
+
...this.createTestFlowTools(task, conversation),
|
|
281
279
|
...this.agentTools,
|
|
282
280
|
};
|
|
283
281
|
if (currentState.isInsideIframe) {
|
|
@@ -315,7 +313,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
315
313
|
conversation.compactToolResults(2);
|
|
316
314
|
|
|
317
315
|
if (iteration > 1) {
|
|
318
|
-
const isNewPage = this.previousUrl !== null && this.previousUrl
|
|
316
|
+
const isNewPage = this.previousUrl !== null && !isSamePageIgnoringQuery(this.previousUrl, currentState.url);
|
|
319
317
|
let nextStep = '';
|
|
320
318
|
nextStep += await this.reinjectContextIfNeeded(iteration, currentState);
|
|
321
319
|
nextStep += await this.prepareInstructionsForNextStep(task);
|
|
@@ -331,7 +329,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
331
329
|
guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
|
|
332
330
|
}
|
|
333
331
|
if (guidance) nextStep += `\n\n${guidance}`;
|
|
334
|
-
this.consecutiveFailures = 0;
|
|
335
332
|
this.lastAnalyzedStateHash = currentState.hash;
|
|
336
333
|
}
|
|
337
334
|
conversation.addUserText(nextStep);
|
|
@@ -357,14 +354,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
357
354
|
assertionPerformed = !!successfulToolNames.find((toolName: string) => this.ASSERTION_TOOLS.includes(toolName));
|
|
358
355
|
const wasSuccessful = result?.toolExecutions?.every((execution: any) => execution.wasSuccessful);
|
|
359
356
|
|
|
360
|
-
this.trackToolExecutions(result?.toolExecutions || []);
|
|
361
|
-
|
|
362
|
-
if (this.consecutiveEmptyResults >= 5) {
|
|
363
|
-
task.addNote('AI model is not responding with actions. Stopped');
|
|
364
|
-
stop();
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
357
|
if (actionPerformed && !wasSuccessful) {
|
|
369
358
|
const noted = new Set(Object.values(task.notes).map((note) => note.message));
|
|
370
359
|
result?.toolExecutions
|
|
@@ -419,10 +408,10 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
419
408
|
{
|
|
420
409
|
maxAttempts: this.MAX_ITERATIONS,
|
|
421
410
|
interruptPrompt: 'Test interrupted. Enter new instruction (or "stop" to cancel):',
|
|
422
|
-
onInterrupt: this.
|
|
411
|
+
onInterrupt: this.supervisor
|
|
423
412
|
? async (userInput, context) => {
|
|
424
413
|
if (!userInput) return;
|
|
425
|
-
const result = await this.
|
|
414
|
+
const result = await this.supervisor!(userInput, task);
|
|
426
415
|
tag('info').log(`🧑✈️ Supervisor: ${result.action} — ${result.message}`);
|
|
427
416
|
|
|
428
417
|
const terminalResults: Record<string, (typeof TestResult)[keyof typeof TestResult]> = {
|
|
@@ -500,12 +489,10 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
500
489
|
}
|
|
501
490
|
|
|
502
491
|
private get isStruggling(): boolean {
|
|
503
|
-
return this.
|
|
492
|
+
return this.stalledIterations >= this.MAX_STALLED_ITERATIONS - 1;
|
|
504
493
|
}
|
|
505
494
|
|
|
506
495
|
private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
|
|
507
|
-
if (task.hasFinished) return false;
|
|
508
|
-
|
|
509
496
|
const currentState = this.getCurrentState();
|
|
510
497
|
const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
|
|
511
498
|
const actionTools = [...this.ACTION_TOOLS, ...this.DELEGATED_ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
|
|
@@ -517,14 +504,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
517
504
|
return false;
|
|
518
505
|
}
|
|
519
506
|
|
|
520
|
-
|
|
521
|
-
if (!hasNoBrowserProgress) return false;
|
|
522
|
-
|
|
523
|
-
this.stalledIterations++;
|
|
524
|
-
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
|
|
525
|
-
|
|
526
|
-
task.addNote('No further browser progress on unchanged page; requesting final review');
|
|
527
|
-
return true;
|
|
507
|
+
return this.shouldStopAfterStall(task, 'No further browser progress on unchanged page; requesting final review');
|
|
528
508
|
}
|
|
529
509
|
|
|
530
510
|
private async prepareInstructionsForNextStep(task: Test): Promise<string> {
|
|
@@ -948,9 +928,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
948
928
|
return '';
|
|
949
929
|
}
|
|
950
930
|
|
|
951
|
-
private createTestFlowTools(task: Test,
|
|
931
|
+
private createTestFlowTools(task: Test, conversation: Conversation) {
|
|
952
932
|
const resetUrl = task.startUrl;
|
|
953
|
-
const visitedUrls = task.getVisitedUrls();
|
|
954
933
|
return {
|
|
955
934
|
reset: tool({
|
|
956
935
|
description: dedent`
|
|
@@ -1161,9 +1140,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1161
1140
|
}
|
|
1162
1141
|
}
|
|
1163
1142
|
|
|
1164
|
-
const remainingExpectations = task.getRemainingExpectations();
|
|
1165
|
-
// const suggestion = input.status !== null && remainingExpectations.length > 0 ? `Continue testing to check the remaining expected outcomes: ${remainingExpectations.join(', ')}` : 'Continue with your testing strategy based on these findings.';
|
|
1166
|
-
|
|
1167
1143
|
return {
|
|
1168
1144
|
success: true,
|
|
1169
1145
|
action: 'record',
|
|
@@ -1190,11 +1166,10 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1190
1166
|
}
|
|
1191
1167
|
|
|
1192
1168
|
if (result.recovered) {
|
|
1193
|
-
this.resetFailureCount();
|
|
1194
1169
|
this.previousUrl = null;
|
|
1195
1170
|
this.previousStateHash = null;
|
|
1196
1171
|
this.stalledIterations = 0;
|
|
1197
|
-
} else if (this.
|
|
1172
|
+
} else if (this.shouldStopAfterStall(task, 'No browser progress after repeated execution errors; requesting final review')) {
|
|
1198
1173
|
return 'stop';
|
|
1199
1174
|
}
|
|
1200
1175
|
|
|
@@ -1202,13 +1177,13 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1202
1177
|
return 'continue';
|
|
1203
1178
|
}
|
|
1204
1179
|
|
|
1205
|
-
private
|
|
1180
|
+
private shouldStopAfterStall(task: Test, note: string): boolean {
|
|
1206
1181
|
if (task.hasFinished) return false;
|
|
1207
1182
|
|
|
1208
1183
|
this.stalledIterations++;
|
|
1209
1184
|
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
|
|
1210
1185
|
|
|
1211
|
-
task.addNote(
|
|
1186
|
+
task.addNote(note);
|
|
1212
1187
|
return true;
|
|
1213
1188
|
}
|
|
1214
1189
|
|
|
@@ -1226,6 +1201,13 @@ interface TestSessionHandlers {
|
|
|
1226
1201
|
offFailedRequest?: () => void;
|
|
1227
1202
|
}
|
|
1228
1203
|
|
|
1204
|
+
export type Supervisor = (input: string, test: Test) => Promise<SupervisorAction>;
|
|
1205
|
+
|
|
1206
|
+
export interface SupervisorAction {
|
|
1207
|
+
action: 'inject' | 'stop' | 'pass' | 'skip';
|
|
1208
|
+
message: string;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1229
1211
|
export interface TestOptions {
|
|
1230
1212
|
startOnCurrentPage?: boolean;
|
|
1231
1213
|
deadline?: number;
|