explorbot 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/api-tester/src/apibot.ts +8 -13
- package/boat/api-tester/src/cli.ts +7 -3
- package/boat/api-tester/src/config.ts +45 -9
- package/boat/prima/src/cli.ts +33 -99
- package/boat/prima/src/envelope.ts +3 -1
- package/boat/prima/src/help.ts +72 -0
- package/boat/prima/src/prima.ts +41 -46
- package/dist/boat/api-tester/src/apibot.js +7 -6
- package/dist/boat/api-tester/src/cli.js +9 -3
- package/dist/boat/api-tester/src/config.js +32 -6
- package/dist/boat/prima/src/cli.js +30 -86
- package/dist/boat/prima/src/envelope.js +2 -1
- package/dist/boat/prima/src/help.js +63 -0
- package/dist/boat/prima/src/prima.js +39 -44
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +3 -0
- package/dist/src/action-result.js +5 -0
- package/dist/src/action.js +12 -1
- package/dist/src/ai/fisherman/tools.js +7 -1
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/pilot.d.ts +0 -1
- package/dist/src/ai/pilot.js +8 -24
- package/dist/src/ai/planner.d.ts +4 -0
- package/dist/src/ai/planner.js +28 -0
- package/dist/src/ai/provider.js +3 -1
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +4 -1
- package/dist/src/ai/researcher/sections.d.ts +1 -1
- package/dist/src/ai/researcher/sections.js +2 -1
- package/dist/src/ai/researcher.js +25 -11
- package/dist/src/ai/rules.js +8 -7
- package/dist/src/ai/scout/tools.d.ts +17 -0
- package/dist/src/ai/scout/tools.js +130 -0
- package/dist/src/ai/scout.d.ts +21 -0
- package/dist/src/ai/scout.js +150 -0
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +61 -30
- package/dist/src/application-spec.d.ts +3 -0
- package/dist/src/application-spec.js +21 -5
- package/dist/src/commands/config-command.js +6 -2
- package/dist/src/config.d.ts +9 -1
- package/dist/src/config.js +14 -0
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +33 -0
- package/dist/src/knowledge-tracker.d.ts +1 -0
- package/dist/src/knowledge-tracker.js +3 -0
- package/dist/src/state-manager.js +5 -1
- package/dist/src/utils/aria-ref.d.ts +16 -0
- package/dist/src/utils/aria-ref.js +47 -0
- package/dist/src/utils/aria.js +3 -3
- package/dist/src/utils/web-annotate.js +3 -15
- package/dist/src/utils/web-element.d.ts +0 -2
- package/dist/src/utils/web-element.js +0 -8
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +1 -0
- package/docs/reference/configuration.md +28 -1
- package/docs/web-testing/agents.md +9 -1
- package/docs/web-testing/planner.md +5 -0
- package/docs/workflow/agentic-usage.md +3 -1
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- package/src/ai/fisherman/tools.ts +8 -1
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/pilot.ts +8 -25
- package/src/ai/planner.ts +33 -0
- package/src/ai/provider.ts +2 -1
- package/src/ai/researcher/deep-analysis.ts +4 -2
- package/src/ai/researcher/sections.ts +2 -2
- package/src/ai/researcher.ts +28 -11
- package/src/ai/rules.ts +8 -7
- package/src/ai/scout/tools.ts +150 -0
- package/src/ai/scout.ts +173 -0
- package/src/ai/tester.ts +25 -30
- package/src/ai/tools.ts +75 -36
- package/src/application-spec.ts +22 -4
- package/src/commands/config-command.ts +4 -1
- package/src/config.ts +23 -0
- package/src/explorbot.ts +36 -0
- package/src/knowledge-tracker.ts +4 -0
- package/src/state-manager.ts +6 -1
- package/src/utils/aria-ref.ts +61 -0
- package/src/utils/aria.ts +3 -3
- package/src/utils/web-annotate.ts +3 -15
- package/src/utils/web-element.ts +0 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { ActionResult } from '../action-result.js';
|
|
3
3
|
import { setActivity } from "../activity.js";
|
|
4
|
-
import { outputPath } from "../config.js";
|
|
4
|
+
import { agentSettings, outputPath } from "../config.js";
|
|
5
5
|
import { executionController } from "../execution-controller.js";
|
|
6
6
|
import { Observability } from "../observability.js";
|
|
7
7
|
import { Stats } from "../stats.js";
|
|
@@ -13,7 +13,7 @@ import { mdq } from "../utils/markdown-query.js";
|
|
|
13
13
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
14
14
|
import { annotatePageElements } from "../utils/web-annotate.js";
|
|
15
15
|
import { ContextLengthError } from './provider.js';
|
|
16
|
-
import { findSimilarResearch, getCachedResearch, reportResearch, saveResearch } from "./researcher/cache.js";
|
|
16
|
+
import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from "./researcher/cache.js";
|
|
17
17
|
import { WithCoordinates } from "./researcher/coordinates.js";
|
|
18
18
|
import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
|
|
19
19
|
import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
|
|
@@ -42,12 +42,19 @@ export class Researcher extends ResearcherBase {
|
|
|
42
42
|
constructor(deps) {
|
|
43
43
|
super(deps);
|
|
44
44
|
this.experienceTracker = deps.stateManager.getExperienceTracker();
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
this.settings.reasoning ??= 'low';
|
|
46
|
+
}
|
|
47
|
+
get settings() {
|
|
48
|
+
return agentSettings(this.config, 'researcher');
|
|
49
|
+
}
|
|
50
|
+
isEnabled() {
|
|
51
|
+
return this.settings.enabled !== false;
|
|
52
|
+
}
|
|
53
|
+
enable() {
|
|
54
|
+
this.settings.enabled = true;
|
|
55
|
+
}
|
|
56
|
+
disable() {
|
|
57
|
+
this.settings.enabled = false;
|
|
51
58
|
}
|
|
52
59
|
getNavigator() {
|
|
53
60
|
throw new Error('not implemented');
|
|
@@ -70,7 +77,7 @@ export class Researcher extends ResearcherBase {
|
|
|
70
77
|
}
|
|
71
78
|
async research(state, opts = {}) {
|
|
72
79
|
const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
|
|
73
|
-
const maxRetries = this.
|
|
80
|
+
const maxRetries = this.settings.retries ?? 2;
|
|
74
81
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
75
82
|
this.actionResult = ActionResult.fromState(state);
|
|
76
83
|
const stateHash = this.actionResult.baseHash;
|
|
@@ -83,6 +90,13 @@ export class Researcher extends ResearcherBase {
|
|
|
83
90
|
return cached;
|
|
84
91
|
}
|
|
85
92
|
}
|
|
93
|
+
if (!this.isEnabled()) {
|
|
94
|
+
debugLog('Researcher is disabled, answering with the recorded map');
|
|
95
|
+
const recorded = getPreviousResearch(stateHash);
|
|
96
|
+
if (recorded)
|
|
97
|
+
reportResearch(stateHash, recorded);
|
|
98
|
+
return recorded;
|
|
99
|
+
}
|
|
86
100
|
Stats.researches++;
|
|
87
101
|
const sessionName = `researcher: ${state.url}`;
|
|
88
102
|
return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
|
|
@@ -286,7 +300,7 @@ export class Researcher extends ResearcherBase {
|
|
|
286
300
|
this.actionResult = await this.explorer.visit(url, { screenshot: screenshot ?? false });
|
|
287
301
|
}
|
|
288
302
|
async waitUntilSettled(screenshot) {
|
|
289
|
-
const errorPageTimeout = this.
|
|
303
|
+
const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
|
|
290
304
|
if (errorPageTimeout <= 0)
|
|
291
305
|
return false;
|
|
292
306
|
const includeScreenshot = screenshot && this.provider.hasVision();
|
|
@@ -316,7 +330,7 @@ export class Researcher extends ResearcherBase {
|
|
|
316
330
|
return false;
|
|
317
331
|
}
|
|
318
332
|
getConfiguredSections() {
|
|
319
|
-
const configSections = this.
|
|
333
|
+
const configSections = this.settings.sections;
|
|
320
334
|
if (!configSections?.length)
|
|
321
335
|
return POSSIBLE_SECTIONS;
|
|
322
336
|
const filtered = {};
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -6,8 +6,9 @@ const locatorPriorityRule = dedent `
|
|
|
6
6
|
|
|
7
7
|
1. ARIA locators (first choice) - target browser's accessibility tree, most reliable
|
|
8
8
|
Use JSON format: { "role": "button", "text": "Login" }
|
|
9
|
-
Copy role and text VERBATIM from the ARIA snapshot
|
|
10
|
-
|
|
9
|
+
Copy role and text VERBATIM from the ARIA snapshot, UI map, or the page diff that
|
|
10
|
+
reported the element — never guess the pair; a guessed role can silently match a
|
|
11
|
+
different element with the same text. If named nowhere, use text or CSS instead.
|
|
11
12
|
|
|
12
13
|
2. Text locators (second choice) - exact visible text, use only when unique on the page
|
|
13
14
|
Example: 'Login', 'Submit', 'Username'
|
|
@@ -225,10 +226,10 @@ export const unexpectedPopupRule = dedent `
|
|
|
225
226
|
If buttons are disabled unexpectedly, check if a popup is blocking interaction or if required form fields are empty.
|
|
226
227
|
|
|
227
228
|
Dismiss strategy (try in order):
|
|
228
|
-
1. I.
|
|
229
|
-
2. I.
|
|
230
|
-
3. I.click('
|
|
231
|
-
4. I.
|
|
229
|
+
1. I.pressKey('Escape') — press Escape to dismiss
|
|
230
|
+
2. I.click('Cancel') — click Cancel button if present
|
|
231
|
+
3. I.click({ role: 'button', text: 'Close' }) — click X/close button if present
|
|
232
|
+
4. I.clickXY(0, 0) via form() tool and check if page diff changed
|
|
232
233
|
</unexpected_popup_rule>
|
|
233
234
|
`;
|
|
234
235
|
export const sectionContextRule = dedent `
|
|
@@ -318,7 +319,7 @@ export const actionRule = dedent `
|
|
|
318
319
|
Prefer text/ARIA locators with context over complex CSS/XPath selectors.
|
|
319
320
|
For inline create/edit flows, after filling a field verify it contains the value, then confirm using the nearest explicit button/link, an adjacent icon-only confirm control in the same row/form, or Enter if the field remains focused.
|
|
320
321
|
If locator doesn't work, try CSS or XPath locators.
|
|
321
|
-
If nothing works, use
|
|
322
|
+
If nothing works, use visualClick() — it locates the target in a screenshot before clicking it.
|
|
322
323
|
|
|
323
324
|
When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
|
|
324
325
|
Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare function loadScoutCorpus(dirs: string[]): ScoutCorpus;
|
|
2
|
+
export declare function excludeCorpusUrls(corpus: ScoutCorpus, urls: string[]): ScoutCorpus;
|
|
3
|
+
export declare function createScoutTools(corpus: ScoutCorpus): Promise<{
|
|
4
|
+
tools: Record<string, any>;
|
|
5
|
+
scanner: "rg" | "grep";
|
|
6
|
+
getResult: () => string;
|
|
7
|
+
finishFromText: (text?: string) => void;
|
|
8
|
+
}>;
|
|
9
|
+
export interface ScoutCorpus {
|
|
10
|
+
dirs: string[];
|
|
11
|
+
files: ScoutCorpusFile[];
|
|
12
|
+
excludedPaths: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface ScoutCorpusFile {
|
|
15
|
+
path: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { tool } from 'ai';
|
|
4
|
+
import { createBashTool } from 'bash-tool';
|
|
5
|
+
import dedent from 'dedent';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { ConfigParser } from "../../config.js";
|
|
8
|
+
import { tag } from "../../utils/logger.js";
|
|
9
|
+
import { loadMarkdownFiles } from "../../utils/markdown-files.js";
|
|
10
|
+
import { readCaptainFile } from "../captain/file-tools.js";
|
|
11
|
+
const MAX_FILES = 500;
|
|
12
|
+
const MAX_FINDINGS = 6000;
|
|
13
|
+
let cachedScanner = null;
|
|
14
|
+
export function loadScoutCorpus(dirs) {
|
|
15
|
+
const files = [];
|
|
16
|
+
for (const dir of dirs) {
|
|
17
|
+
if (files.length >= MAX_FILES) {
|
|
18
|
+
tag('warning').log(`Scout corpus capped at ${MAX_FILES} files — remaining directories skipped`);
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
for (const file of loadMarkdownFiles(dir, { recursive: true })) {
|
|
22
|
+
if (files.length >= MAX_FILES)
|
|
23
|
+
break;
|
|
24
|
+
const entry = { path: file.filePath };
|
|
25
|
+
if (typeof file.data.url === 'string')
|
|
26
|
+
entry.url = file.data.url;
|
|
27
|
+
files.push(entry);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { dirs, files, excludedPaths: [] };
|
|
31
|
+
}
|
|
32
|
+
export function excludeCorpusUrls(corpus, urls) {
|
|
33
|
+
if (urls.length === 0)
|
|
34
|
+
return corpus;
|
|
35
|
+
const excludedUrls = new Set(urls);
|
|
36
|
+
const files = [];
|
|
37
|
+
const excludedPaths = [...corpus.excludedPaths];
|
|
38
|
+
for (const file of corpus.files) {
|
|
39
|
+
if (file.url && excludedUrls.has(file.url)) {
|
|
40
|
+
excludedPaths.push(file.path);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
files.push(file);
|
|
44
|
+
}
|
|
45
|
+
return { dirs: corpus.dirs, files, excludedPaths };
|
|
46
|
+
}
|
|
47
|
+
export async function createScoutTools(corpus) {
|
|
48
|
+
const scanner = await detectScanner();
|
|
49
|
+
const projectRoot = ConfigParser.getInstance().getProjectRoot();
|
|
50
|
+
let result = '';
|
|
51
|
+
let searchedOrRead = false;
|
|
52
|
+
const getResult = () => result;
|
|
53
|
+
const finishFromText = (text) => {
|
|
54
|
+
if (text && searchedOrRead)
|
|
55
|
+
result = text.slice(0, MAX_FINDINGS);
|
|
56
|
+
};
|
|
57
|
+
const files = {};
|
|
58
|
+
const readableFiles = new Set();
|
|
59
|
+
for (const file of corpus.files) {
|
|
60
|
+
files[toPosix(file.path)] = readFileSync(file.path, 'utf8');
|
|
61
|
+
readableFiles.add(resolve(file.path));
|
|
62
|
+
}
|
|
63
|
+
const toolkit = await createBashTool({
|
|
64
|
+
destination: '/',
|
|
65
|
+
files,
|
|
66
|
+
maxOutputLength: 20000,
|
|
67
|
+
});
|
|
68
|
+
const bashExecute = toolkit.bash.execute;
|
|
69
|
+
const bash = {
|
|
70
|
+
...toolkit.bash,
|
|
71
|
+
execute: async (input) => {
|
|
72
|
+
tag('step').log(`Scout: bash ${input.command}`);
|
|
73
|
+
searchedOrRead = true;
|
|
74
|
+
return bashExecute?.(input);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const tools = {
|
|
78
|
+
bash,
|
|
79
|
+
readFile: tool({
|
|
80
|
+
description: dedent `
|
|
81
|
+
Read one documentation file from the corpus.
|
|
82
|
+
Pass the exact path returned by a search result.
|
|
83
|
+
`,
|
|
84
|
+
inputSchema: z.object({
|
|
85
|
+
path: z.string().describe('File path from a search result'),
|
|
86
|
+
startLine: z.number().optional().describe('First line to read, 1-based. Negative values count from the end of the file'),
|
|
87
|
+
endLine: z.number().optional().describe('Last line to read, 1-based and inclusive. Negative values count from the end of the file'),
|
|
88
|
+
maxChars: z.number().optional().describe('Maximum characters to return, default 12000'),
|
|
89
|
+
}),
|
|
90
|
+
execute: async (input) => {
|
|
91
|
+
tag('step').log(`Scout: read ${input.path}`);
|
|
92
|
+
const output = readCaptainFile(projectRoot, input, corpus.dirs);
|
|
93
|
+
if (!output.success)
|
|
94
|
+
return output;
|
|
95
|
+
const resolvedPath = resolve(projectRoot || process.cwd(), output.path);
|
|
96
|
+
if (!readableFiles.has(resolvedPath)) {
|
|
97
|
+
return { success: false, message: 'File is outside the Scout corpus' };
|
|
98
|
+
}
|
|
99
|
+
searchedOrRead = true;
|
|
100
|
+
return output;
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
return { tools, scanner, getResult, finishFromText };
|
|
105
|
+
}
|
|
106
|
+
async function detectScanner() {
|
|
107
|
+
if (cachedScanner)
|
|
108
|
+
return cachedScanner;
|
|
109
|
+
if (await binaryRuns('rg')) {
|
|
110
|
+
cachedScanner = 'rg';
|
|
111
|
+
return cachedScanner;
|
|
112
|
+
}
|
|
113
|
+
if (await binaryRuns('grep')) {
|
|
114
|
+
cachedScanner = 'grep';
|
|
115
|
+
return cachedScanner;
|
|
116
|
+
}
|
|
117
|
+
throw new Error('Scout requires ripgrep or grep on PATH — neither was found');
|
|
118
|
+
}
|
|
119
|
+
async function binaryRuns(binary) {
|
|
120
|
+
try {
|
|
121
|
+
const proc = Bun.spawn([binary, '--version'], { stdout: 'ignore', stderr: 'ignore', stdin: 'ignore' });
|
|
122
|
+
return (await proc.exited) === 0;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function toPosix(path) {
|
|
129
|
+
return path.split('\\').join('/');
|
|
130
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Agent } from './agent.js';
|
|
2
|
+
import type { Provider } from './provider.js';
|
|
3
|
+
import { type ScoutCorpus } from './scout/tools.js';
|
|
4
|
+
export declare class Scout implements Agent {
|
|
5
|
+
provider: Provider;
|
|
6
|
+
corpus: ScoutCorpus;
|
|
7
|
+
emoji: string;
|
|
8
|
+
cache: Map<string, string>;
|
|
9
|
+
constructor(provider: Provider, corpus: ScoutCorpus);
|
|
10
|
+
isAvailable(): boolean;
|
|
11
|
+
collectDocs(query: ScoutQuery): Promise<string>;
|
|
12
|
+
runSession(corpus: ScoutCorpus, query: ScoutQuery): Promise<string | null>;
|
|
13
|
+
buildSystemPrompt(toolNames: string[], corpus: ScoutCorpus, query: ScoutQuery, scanner: 'rg' | 'grep'): string;
|
|
14
|
+
buildTaskPrompt(query: ScoutQuery): string;
|
|
15
|
+
}
|
|
16
|
+
export interface ScoutQuery {
|
|
17
|
+
url?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
feature?: string;
|
|
20
|
+
excludeUrls: string[];
|
|
21
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
2
|
+
import { tag } from "../utils/logger.js";
|
|
3
|
+
import { loop } from "../utils/loop.js";
|
|
4
|
+
import { createScoutTools, excludeCorpusUrls } from "./scout/tools.js";
|
|
5
|
+
const MAX_ITERATIONS = 3;
|
|
6
|
+
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
7
|
+
const CACHE_LIMIT = 40;
|
|
8
|
+
const URL_LISTING_LIMIT = 40;
|
|
9
|
+
export class Scout {
|
|
10
|
+
provider;
|
|
11
|
+
corpus;
|
|
12
|
+
emoji = '🔎';
|
|
13
|
+
cache = new Map();
|
|
14
|
+
constructor(provider, corpus) {
|
|
15
|
+
this.provider = provider;
|
|
16
|
+
this.corpus = corpus;
|
|
17
|
+
}
|
|
18
|
+
isAvailable() {
|
|
19
|
+
return this.corpus.files.length > 0;
|
|
20
|
+
}
|
|
21
|
+
async collectDocs(query) {
|
|
22
|
+
if (!this.isAvailable())
|
|
23
|
+
return '';
|
|
24
|
+
const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
|
|
25
|
+
const cached = this.cache.get(cacheKey);
|
|
26
|
+
if (cached !== undefined)
|
|
27
|
+
return cached;
|
|
28
|
+
const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls);
|
|
29
|
+
if (corpus.files.length === 0)
|
|
30
|
+
return '';
|
|
31
|
+
const result = await this.runSession(corpus, query);
|
|
32
|
+
if (result === null)
|
|
33
|
+
return '';
|
|
34
|
+
if (this.cache.size > CACHE_LIMIT)
|
|
35
|
+
this.cache.clear();
|
|
36
|
+
this.cache.set(cacheKey, result);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
async runSession(corpus, query) {
|
|
40
|
+
const { tools, scanner, getResult, finishFromText } = await createScoutTools(corpus);
|
|
41
|
+
const conversation = this.provider.startConversation(this.buildSystemPrompt(Object.keys(tools), corpus, query, scanner), 'scout', this.provider.getAgenticModel('scout'));
|
|
42
|
+
conversation.addUserText(this.buildTaskPrompt(query));
|
|
43
|
+
tag('info').log(`Scout: collecting documentation for ${query.feature || query.url || 'the current page'}`);
|
|
44
|
+
let failed = false;
|
|
45
|
+
await loop(async ({ stop, iteration }) => {
|
|
46
|
+
const invokeResult = await this.provider.invokeConversation(conversation, tools, {
|
|
47
|
+
maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
|
|
48
|
+
agentName: 'scout',
|
|
49
|
+
});
|
|
50
|
+
if (!invokeResult?.toolExecutions?.length) {
|
|
51
|
+
finishFromText(invokeResult?.response?.text);
|
|
52
|
+
stop();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (iteration >= MAX_ITERATIONS) {
|
|
56
|
+
const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
|
|
57
|
+
finishFromText(final?.response?.text);
|
|
58
|
+
stop();
|
|
59
|
+
}
|
|
60
|
+
}, {
|
|
61
|
+
maxAttempts: MAX_ITERATIONS,
|
|
62
|
+
observability: { name: `scout: ${query.feature || query.url || 'docs'}`, agent: 'scout' },
|
|
63
|
+
catch: async ({ error, stop }) => {
|
|
64
|
+
failed = true;
|
|
65
|
+
tag('warning').log(`Scout error: ${error.message}`);
|
|
66
|
+
stop();
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (failed)
|
|
70
|
+
return null;
|
|
71
|
+
const digest = getResult();
|
|
72
|
+
if (digest) {
|
|
73
|
+
const preview = digest.slice(0, 600);
|
|
74
|
+
const ellipsis = digest.length > 600 ? '…' : '';
|
|
75
|
+
tag('info').log(`Scout digest:\n${preview}${ellipsis}`);
|
|
76
|
+
}
|
|
77
|
+
return digest;
|
|
78
|
+
}
|
|
79
|
+
buildSystemPrompt(toolNames, corpus, query, scanner) {
|
|
80
|
+
const urls = corpus.files.map((file) => file.url).filter(Boolean);
|
|
81
|
+
const urlless = corpus.files.filter((file) => !file.url);
|
|
82
|
+
let pagesListing = '';
|
|
83
|
+
if (urls.length > 0) {
|
|
84
|
+
const listing = urls
|
|
85
|
+
.slice(0, URL_LISTING_LIMIT)
|
|
86
|
+
.map((url) => `- ${url}`)
|
|
87
|
+
.join('\n');
|
|
88
|
+
pagesListing = `Documented pages:\n${listing}`;
|
|
89
|
+
const remaining = urls.length - URL_LISTING_LIMIT;
|
|
90
|
+
if (remaining > 0)
|
|
91
|
+
pagesListing += `\n…and ${remaining} more — find them with ${scanner}`;
|
|
92
|
+
}
|
|
93
|
+
if (urlless.length > 0) {
|
|
94
|
+
const listing = urlless
|
|
95
|
+
.slice(0, URL_LISTING_LIMIT)
|
|
96
|
+
.map((file) => `- ${toPosix(file.path)}`)
|
|
97
|
+
.join('\n');
|
|
98
|
+
pagesListing += `\nFiles with no page URL (hand-written docs):\n${listing}`;
|
|
99
|
+
}
|
|
100
|
+
const prompt = dedent `
|
|
101
|
+
You are Scout — a documentation retrieval agent. You find collected documentation relevant to a testing focus and report it for test planning.
|
|
102
|
+
|
|
103
|
+
You never see the application itself. The documentation corpus is your only source of truth.
|
|
104
|
+
|
|
105
|
+
CORPUS:
|
|
106
|
+
${corpus.files.length} markdown files under:
|
|
107
|
+
- ${corpus.dirs.map(toPosix).join('\n- ')}
|
|
108
|
+
${pagesListing}
|
|
109
|
+
|
|
110
|
+
These pages are already provided to the planner in full — do not re-report them:
|
|
111
|
+
${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
|
|
112
|
+
|
|
113
|
+
AVAILABLE TOOLS:
|
|
114
|
+
${toolNames.join(', ')}.
|
|
115
|
+
Use tool names exactly as listed. Do not invent aliases or combined names.
|
|
116
|
+
Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
|
|
117
|
+
|
|
118
|
+
SCANNER:
|
|
119
|
+
${scanner} is the search command. Scan the working directory through bash() — explore freely, pipelines, globs and repeated searches are fine. Read files with readFile().
|
|
120
|
+
|
|
121
|
+
WORKFLOW:
|
|
122
|
+
1. Scan with ${scanner} using plain prose words from the focus — feature names, page purposes, capabilities
|
|
123
|
+
2. Read the files whose hits look most relevant
|
|
124
|
+
3. Report the digest as your final message — no tool call is needed to finish
|
|
125
|
+
|
|
126
|
+
RULES:
|
|
127
|
+
- Report only what the documentation states. Never fill gaps with assumptions about the application
|
|
128
|
+
- Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
|
|
129
|
+
- Name the page URL each item belongs to, so scenarios anchor to real routes
|
|
130
|
+
- Explore briefly: a few scans and reads are enough, then report
|
|
131
|
+
- A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
|
|
132
|
+
`;
|
|
133
|
+
const customPrompt = this.provider.getSystemPromptForAgent('scout');
|
|
134
|
+
if (customPrompt)
|
|
135
|
+
return `${prompt}\n\n${customPrompt}`;
|
|
136
|
+
return prompt;
|
|
137
|
+
}
|
|
138
|
+
buildTaskPrompt(query) {
|
|
139
|
+
return dedent `
|
|
140
|
+
Page URL: ${query.url || 'Unknown'}
|
|
141
|
+
Page title: ${query.title || 'Unknown'}
|
|
142
|
+
Focus: ${query.feature || 'the page as a whole'}
|
|
143
|
+
|
|
144
|
+
Report the documented capabilities, states and transitions a test planner could turn into scenarios.
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function toPosix(path) {
|
|
149
|
+
return path.split('\\').join('/');
|
|
150
|
+
}
|
package/dist/src/ai/tester.d.ts
CHANGED
package/dist/src/ai/tester.js
CHANGED
|
@@ -40,6 +40,7 @@ export class Tester extends TaskAgent {
|
|
|
40
40
|
MAX_ITERATIONS = 30;
|
|
41
41
|
MAX_EXTENSIONS = 2;
|
|
42
42
|
ASSERTION_TOOLS = ['verify'];
|
|
43
|
+
pendingReview = '';
|
|
43
44
|
researcher;
|
|
44
45
|
navigator;
|
|
45
46
|
agentTools;
|
|
@@ -97,6 +98,7 @@ export class Tester extends TaskAgent {
|
|
|
97
98
|
this.seenUiMapUrls.clear();
|
|
98
99
|
this.lastAnalyzedStateHash = null;
|
|
99
100
|
this.stalledIterations = 0;
|
|
101
|
+
this.pendingReview = '';
|
|
100
102
|
this.previousRegionPresent = null;
|
|
101
103
|
this.regionTransitioned = false;
|
|
102
104
|
this.stateManager.clearHistory();
|
|
@@ -293,7 +295,7 @@ export class Tester extends TaskAgent {
|
|
|
293
295
|
const result = await this.provider.invokeConversation(conversation, tools, {
|
|
294
296
|
maxToolRoundtrips: 3,
|
|
295
297
|
toolChoice: 'required',
|
|
296
|
-
stopWhen: () => task.hasFinished,
|
|
298
|
+
stopWhen: () => task.hasFinished || !!this.pendingReview,
|
|
297
299
|
});
|
|
298
300
|
if (!result)
|
|
299
301
|
throw new Error('Failed to get response from provider');
|
|
@@ -339,6 +341,15 @@ export class Tester extends TaskAgent {
|
|
|
339
341
|
`);
|
|
340
342
|
}
|
|
341
343
|
}
|
|
344
|
+
if (this.pendingReview && this.pilot) {
|
|
345
|
+
const reviewed = this.pendingReview;
|
|
346
|
+
this.pendingReview = '';
|
|
347
|
+
const reviewState = this.getCurrentState();
|
|
348
|
+
if (reviewed === 'finish')
|
|
349
|
+
await this.pilot.reviewFinish(task, reviewState, conversation, this.navigator);
|
|
350
|
+
if (reviewed === 'stop')
|
|
351
|
+
await this.pilot.reviewStop(task, reviewState, conversation);
|
|
352
|
+
}
|
|
342
353
|
if (task.hasFinished) {
|
|
343
354
|
stop();
|
|
344
355
|
return;
|
|
@@ -513,17 +524,20 @@ export class Tester extends TaskAgent {
|
|
|
513
524
|
}
|
|
514
525
|
if (region.isModal) {
|
|
515
526
|
const areaName = region.name ? ` "${region.name}"` : '';
|
|
516
|
-
let
|
|
517
|
-
if (region.root)
|
|
518
|
-
|
|
527
|
+
let scoping = 'Use <page_aria> to confirm the element you target is actually inside the overlay.';
|
|
528
|
+
if (region.root) {
|
|
529
|
+
scoping = `Its root is \`${region.root}\` — build every locator as ARIA scoped to that root, e.g. I.click({ role: 'button', text: 'Continue' }, '${region.root}')`;
|
|
530
|
+
}
|
|
519
531
|
context += dedent `
|
|
520
532
|
<overlay>
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
Use <page_aria> to confirm the element you target is actually inside the overlay.
|
|
525
|
-
</overlay>
|
|
533
|
+
You are inside an overlay${areaName} opened above the page.
|
|
534
|
+
${scoping}
|
|
535
|
+
Elements outside the overlay are behind it and not actionable while it is open — they may share names or roles with the ones inside, so never target them by bare text.
|
|
526
536
|
`;
|
|
537
|
+
const regionAria = currentState.getRegionARIA();
|
|
538
|
+
if (regionAria)
|
|
539
|
+
context += `\nIt holds exactly these elements:\n<overlay_aria>\n${regionAria}\n</overlay_aria>`;
|
|
540
|
+
context += '\n</overlay>\n';
|
|
527
541
|
}
|
|
528
542
|
if (!region.isModal && region.isOpen && isNewState) {
|
|
529
543
|
let rootHint = '';
|
|
@@ -920,18 +934,8 @@ export class Tester extends TaskAgent {
|
|
|
920
934
|
}),
|
|
921
935
|
execute: async ({ reason }) => {
|
|
922
936
|
task.addNote(`Stop requested: ${reason}`);
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
await this.pilot.reviewStop(task, currentState, conversation);
|
|
926
|
-
if (!task.hasFinished) {
|
|
927
|
-
return {
|
|
928
|
-
success: false,
|
|
929
|
-
action: 'stop',
|
|
930
|
-
message: 'Stop rejected; Continue execution',
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
else {
|
|
937
|
+
this.pendingReview = 'stop';
|
|
938
|
+
if (!this.pilot) {
|
|
935
939
|
task.addNote(reason, TestResult.FAILED);
|
|
936
940
|
task.finish(TestResult.FAILED);
|
|
937
941
|
}
|
|
@@ -966,18 +970,8 @@ export class Tester extends TaskAgent {
|
|
|
966
970
|
return { success: true, action: 'finish', message: 'already finished' };
|
|
967
971
|
}
|
|
968
972
|
task.addNote(`Finish requested: ${verify}`);
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
await this.pilot.reviewFinish(task, currentState, conversation, this.navigator);
|
|
972
|
-
if (!task.hasFinished) {
|
|
973
|
-
return {
|
|
974
|
-
success: false,
|
|
975
|
-
action: 'finish',
|
|
976
|
-
message: 'Finishing rejected; Continue execution',
|
|
977
|
-
};
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
973
|
+
this.pendingReview = 'finish';
|
|
974
|
+
if (!this.pilot) {
|
|
981
975
|
task.addNote('Test finished successfully', TestResult.PASSED);
|
|
982
976
|
task.finish(TestResult.PASSED);
|
|
983
977
|
}
|